feat: readdir with depth (#3446)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

This commit is contained in:
Daniel Salazar
2026-07-25 14:04:21 -07:00
committed by GitHub
parent 828b5d9430
commit e85cd9d53d
9 changed files with 969 additions and 211 deletions
+241 -1
View File
@@ -848,7 +848,11 @@ describe('FSController.readdirEntries', () => {
makeRes().res,
),
),
).rejects.toMatchObject({ statusCode: 400 });
).rejects.toMatchObject({
statusCode: 400,
// Matches the legacy `/readdir` code the SDK moved off of.
legacyCode: 'dest_is_not_a_directory',
});
});
});
@@ -1678,6 +1682,242 @@ describe('FSController.readdirEntries pagination', () => {
});
});
// -- /readdir recursive (nested listing) --
describe('FSController.readdirEntries recursive', () => {
// Seed a nested tree under Documents/tree and return its base path.
// Relative depths: l1a/l1b = 1, l2a = 2, l3a = 3, l4a = 4.
const makeTree = async () => {
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const base = `/${username}/Documents/tree`;
const dirs = [
base,
`${base}/l1a`,
`${base}/l1b`,
`${base}/l1a/l2a`,
`${base}/l1a/l2a/l3a`,
`${base}/l1a/l2a/l3a/l4a`,
];
for (const path of dirs) {
await withActor(actor, () =>
controller.mkdirEntry(
makeReq({ body: { path }, actor }),
makeRes().res,
),
);
}
return { actor, userId, base };
};
const readdir = async (
actor: Awaited<ReturnType<typeof makeUser>>['actor'],
body: Record<string, unknown>,
) => {
const { res, captured } = makeRes();
await withActor(actor, () =>
controller.readdirEntries(makeReq({ body, actor }), res),
);
return captured.body;
};
const rel = (base: string, items: Array<{ path: string }>) =>
items.map((e) => e.path.slice(base.length + 1)).sort();
it('depth 1 returns only direct children (like a normal readdir)', async () => {
const { actor, base } = await makeTree();
const page = (await readdir(actor, {
path: base,
recursive: true,
depth: 1,
})) as { items: Array<{ path: string }>; cursor?: string };
expect(rel(base, page.items)).toEqual(['l1a', 'l1b']);
});
it('deeper levels appear as depth grows', async () => {
const { actor, base } = await makeTree();
const d2 = (await readdir(actor, {
path: base,
recursive: true,
depth: 2,
})) as { items: Array<{ path: string }> };
expect(rel(base, d2.items)).toEqual(['l1a', 'l1a/l2a', 'l1b']);
const d3 = (await readdir(actor, {
path: base,
recursive: true,
depth: 3,
})) as { items: Array<{ path: string }> };
expect(rel(base, d3.items)).toEqual([
'l1a',
'l1a/l2a',
'l1a/l2a/l3a',
'l1b',
]);
});
it('caps depth at 10 so a huge depth returns the whole subtree', async () => {
const { actor, base } = await makeTree();
const page = (await readdir(actor, {
path: base,
recursive: true,
depth: 9999,
})) as { items: Array<{ path: string }> };
expect(rel(base, page.items)).toEqual([
'l1a',
'l1a/l2a',
'l1a/l2a/l3a',
'l1a/l2a/l3a/l4a',
'l1b',
]);
});
it('pages through the whole subtree with cursors, no dupes or gaps', async () => {
const { actor, base } = await makeTree();
const seen: string[] = [];
let cursor: string | null | undefined = null;
do {
const page = (await readdir(actor, {
path: base,
recursive: true,
depth: 10,
limit: 2,
cursor,
})) as { items: Array<{ path: string }>; cursor?: string };
expect(page.items.length).toBeLessThanOrEqual(2);
seen.push(...page.items.map((e) => e.path));
cursor = page.cursor;
} while (cursor);
expect(rel(base, seen.map((path) => ({ path })))).toEqual([
'l1a',
'l1a/l2a',
'l1a/l2a/l3a',
'l1a/l2a/l3a/l4a',
'l1b',
]);
});
it('counts the subtree with includeTotal', async () => {
const { actor, base } = await makeTree();
const page = (await readdir(actor, {
path: base,
recursive: true,
depth: 2,
cursor: null,
includeTotal: true,
})) as { items: unknown[]; total?: number };
expect(page.total).toBe(3); // l1a, l1b, l2a
});
it('enriches entries with type, thumbnail and associatedApp', async () => {
const { actor, base } = await makeTree();
const page = (await readdir(actor, {
path: base,
recursive: true,
depth: 1,
})) as {
items: Array<{
type?: unknown;
thumbnail?: unknown;
associatedApp?: unknown;
}>;
};
for (const item of page.items) {
expect(item.type).toBe('folder');
expect(item.thumbnail ?? null).toBeNull();
expect('associatedApp' in item).toBe(true);
}
});
it('gives files a MIME type and an associatedApp field', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const dir = `/${username}/Documents/files`;
await withActor(actor, () =>
controller.mkdirEntry(
makeReq({ body: { path: dir }, actor }),
makeRes().res,
),
);
await withActor(actor, () =>
controller.touchEntry(
makeReq({ body: { path: `${dir}/pic.png` }, actor }),
makeRes().res,
),
);
const page = (await readdir(actor, {
path: dir,
recursive: true,
depth: 1,
})) as {
items: Array<{
name: string;
type?: unknown;
associatedApp?: unknown;
}>;
};
const file = page.items.find((e) => e.name === 'pic.png')!;
expect(String(file.type)).toContain('image/png');
expect('associatedApp' in file).toBe(true);
});
it('masks denials for app-under-user actors as a 404 (legacy parity)', async () => {
const { actor: userActor } = await makeUser();
const username = userActor.user!.username!;
const appActor: Actor = {
...userActor,
app: { uid: `app-readdir-${uuidv4()}` },
};
// The user's Documents is outside the app's AppData subtree, so the
// app can't list it. Legacy `/readdir` masks this as a 404
// subject_does_not_exist rather than leaking a 403.
await expect(
withActor(appActor, () =>
controller.readdirEntries(
makeReq({
body: { path: `/${username}/Documents` },
actor: appActor,
}),
makeRes().res,
),
),
).rejects.toMatchObject({
statusCode: 404,
legacyCode: 'subject_does_not_exist',
});
});
it('rejects recursive listing at the root', async () => {
const { actor } = await makeUser();
await expect(
withActor(actor, () =>
controller.readdirEntries(
makeReq({
body: { path: '/', recursive: true },
actor,
}),
makeRes().res,
),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('does not leak another users identically-named subtree', async () => {
const { actor, base } = await makeTree();
// A second user with the same relative tree must not appear.
await makeTree();
const page = (await readdir(actor, {
path: base,
recursive: true,
depth: 10,
})) as { items: Array<{ path: string }> };
for (const item of page.items) {
expect(item.path.startsWith(`${base}/`)).toBe(true);
}
expect(page.items).toHaveLength(5);
});
});
// ── /touch additional branches ──────────────────────────────────────
describe('FSController.touchEntry additional branches', () => {
+123 -32
View File
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import Busboy from 'busboy';
@@ -60,7 +61,13 @@ import type {
ThumbnailUploadPrepareItem,
ThumbnailUploadPreparePayload,
} from './types.js';
import { toLegacyEntry } from './legacyFsHelpers.js';
import {
toLegacyEntry,
loadLegacyAssociatedApps,
fsEntryMimeType,
signEntryThumbnail,
assertAccess as assertLegacyAccess,
} from './legacyFsHelpers.js';
class UploadProgressTracker implements UploadProgressTrackerLike {
total = 0;
progress = 0;
@@ -89,6 +96,8 @@ class UploadProgressTracker implements UploadProgressTrackerLike {
}
const MAX_THUMBNAIL_BYTES = 2 * 1024 * 1024;
// Hard cap on how many levels below the target a recursive readdir descends.
const MAX_READDIR_DEPTH = 10;
const DEFAULT_BATCH_ACL_CHECK_CONCURRENCY = 32;
const DEFAULT_BATCH_WRITE_SIDE_EFFECT_CONCURRENCY = 8;
@@ -883,7 +892,18 @@ export class FSController extends PuterController {
Object.prototype.hasOwnProperty.call(body, 'cursor') ||
body.includeTotal === true;
// Undocumented: `recursive` lists descendants (prefix scan) up to
// `depth` levels below the target. Always paginated; sorts by path.
const recursive = this.#toBoolean(body.recursive) === true;
if (this.#isRootPathRef(body)) {
if (recursive) {
throw new HttpError(
400,
'recursive listing is not supported at the root',
{ legacyCode: 'bad_request' },
);
}
const { listRootEntries } =
await import('../../services/fs/rootListing.js');
const rootChildren = await listRootEntries(
@@ -901,9 +921,7 @@ export class FSController extends PuterController {
child.suggestedApps = rootSuggestions[index] ?? [];
}
}
const rootItems = rootChildren.map((child) =>
this.#toClientEntry(child),
);
const rootItems = await this.#toReaddirEntries(rootChildren);
if (paginated) {
res.json({
items: rootItems,
@@ -920,10 +938,19 @@ export class FSController extends PuterController {
const parent = await this.#resolveEntryForRequest(body);
if (!parent.isDir) {
throw new HttpError(400, 'Target is not a directory', {
legacyCode: 'bad_request',
legacyCode: 'dest_is_not_a_directory',
});
}
await this.#assertAccess(actor, parent.path, 'list');
// Use the legacy access assertion so this endpoint stays behaviorally
// identical to the `/readdir` route the SDK moved off of — same error
// codes and the same app-actor 404 masking on denial.
await assertLegacyAccess(
this.services.acl,
this.services.fs,
actor,
parent.path,
'list',
);
const limit = this.#toNumberOrUndefined(body.limit);
const offset = this.#toNumberOrUndefined(body.offset);
@@ -942,6 +969,41 @@ export class FSController extends PuterController {
const sortOrder =
(['asc', 'desc'] as const).find((v) => v === sortOrderRaw) ?? null;
if (recursive) {
const requestedDepth = this.#toNumberOrUndefined(body.depth);
const maxDepth = Math.min(
MAX_READDIR_DEPTH,
Math.max(1, Math.floor(requestedDepth ?? MAX_READDIR_DEPTH)),
);
const page = await this.services.fs.listDirectoryTreePage(
parent.userId,
parent.path,
{
limit,
cursor:
typeof body.cursor === 'string'
? body.cursor
: undefined,
maxDepth,
},
);
await this.#attachSuggestedApps(page.entries);
const total =
body.includeTotal === true
? await this.services.fs.countDirectoryTree(
parent.userId,
parent.path,
maxDepth,
)
: undefined;
res.json({
items: await this.#toReaddirEntries(page.entries),
...(page.cursor ? { cursor: page.cursor } : {}),
...(total !== undefined ? { total } : {}),
});
return;
}
if (paginated) {
const page = await this.services.fs.listDirectoryPage(parent.uuid, {
limit,
@@ -956,7 +1018,7 @@ export class FSController extends PuterController {
? await this.services.fs.countDirectory(parent.uuid)
: undefined;
res.json({
items: page.entries.map((child) => this.#toClientEntry(child)),
items: await this.#toReaddirEntries(page.entries),
...(page.cursor ? { cursor: page.cursor } : {}),
...(total !== undefined ? { total } : {}),
});
@@ -970,7 +1032,37 @@ export class FSController extends PuterController {
sortOrder,
});
await this.#attachSuggestedApps(children);
res.json(children.map((child) => this.#toClientEntry(child)));
res.json(await this.#toReaddirEntries(children));
}
/**
* Shape readdir entries in the v2 (camelCase) response shape, enriched with
* the three fields the SDK cannot reconstruct on its own so it can rebuild
* the v1 shape: `type` (MIME), a signed `thumbnail`, and `associatedApp`.
*/
async #toReaddirEntries(
entries: FSEntry[],
): Promise<Record<string, unknown>[]> {
const appsById = await loadLegacyAssociatedApps(
this.stores.app,
entries,
);
return Promise.all(
entries.map(async (entry) => {
const shaped = this.#toClientEntry(entry);
shaped.type = fsEntryMimeType(entry);
shaped.thumbnail = await signEntryThumbnail(
this.clients.event,
entry.uuid,
entry.thumbnail,
);
shaped.associatedApp =
entry.associatedAppId !== null
? (appsById.get(entry.associatedAppId) ?? null)
: null;
return shaped;
}),
);
}
async #attachSuggestedApps(entries: FSEntry[]): Promise<void> {
@@ -1343,12 +1435,12 @@ export class FSController extends PuterController {
}
/**
* Authorize creation of a new entry at `targetPath`. The standard rule
* is write on the parent, but we also accept write on the target itself
* — this lets an app create its own `/<user>/AppData/<app_uid>` folder
* (parent `AppData` is off-limits, but the target is the app's own
* subtree per ACLService's short-circuit) and lets recipients of a
* direct share on a not-yet-existent path materialize it.
* Authorize creation of a new entry at `targetPath`. The standard rule is
* write on the parent, but we also accept write on the target itself — this
* lets an app create its own `/<user>/AppData/<app_uid>` folder (parent
* `AppData` is off-limits, but the target is the app's own subtree per
* ACLService's short-circuit) and lets recipients of a direct share on a
* not-yet-existent path materialize it.
*/
async #assertCanCreate(actor: Actor, targetPath: string) {
const parent = pathPosix.dirname(targetPath);
@@ -1826,13 +1918,13 @@ export class FSController extends PuterController {
/**
* Bind `associatedAppId` onto the write input only when the actor is
* entitled to reference that app. `associatedAppId` is client-supplied
* and never trusted for authz, but it's echoed back in legacy FS
* responses — so an attacker could plant another tenant's private app id
* to confirm the row exists (an enumeration oracle) and harvest its
* metadata. Allow binding to public apps (their existence isn't secret)
* or to apps the actor owns; drop the association otherwise so the file
* simply carries no associated app.
* entitled to reference that app. `associatedAppId` is client-supplied and
* never trusted for authz, but it's echoed back in legacy FS responses — so
* an attacker could plant another tenant's private app id to confirm the
* row exists (an enumeration oracle) and harvest its metadata. Allow
* binding to public apps (their existence isn't secret) or to apps the
* actor owns; drop the association otherwise so the file simply carries no
* associated app.
*
* `#resolveWriteFileMetadata` has already copied the raw client value onto
* `fileMetadata`, so a dropped association must be actively stripped — not
@@ -1873,8 +1965,7 @@ export class FSController extends PuterController {
// the ActorUser type. Access via the escape hatch until a proper
// storage-quota mechanism is in place.
const actorUser = req.actor?.user as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
const candidates = [
this.#toStorageCapacityCandidate(actorUser?.free_storage),
+100 -79
View File
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { posix as pathPosix } from 'node:path';
@@ -43,9 +44,8 @@ import type { IConfig } from '../../types.js';
/**
* Shared helpers used by the legacy FS route shims (LegacyFSController).
*
* Legacy clients speak snake_case and expect specific response shapes —
* these helpers encapsulate that translation so the route handlers stay
* terse.
* Legacy clients speak snake_case and expect specific response shapes — these
* helpers encapsulate that translation so the route handlers stay terse.
*/
// -- Body parsing -----------------------------------------------------
@@ -220,15 +220,15 @@ export async function assertAccess(
}
/**
* Authorize creation of a new entry at `targetPath`. The standard rule is
* write on the parent, but we also allow it when the actor has explicit
* write on the target itself — this covers an app creating its own
* `/<user>/AppData/<app_uid>` folder (parent `AppData` is off-limits, but
* the target is the app's own subtree per ACLService's short-circuit) and
* shares granted directly on a not-yet-created path.
* Authorize creation of a new entry at `targetPath`. The standard rule is write
* on the parent, but we also allow it when the actor has explicit write on the
* target itself — this covers an app creating its own
* `/<user>/AppData/<app_uid>` folder (parent `AppData` is off-limits, but the
* target is the app's own subtree per ACLService's short-circuit) and shares
* granted directly on a not-yet-created path.
*
* On failure, delegates to `assertAccess` on the parent so the error
* shape stays identical to the previous parent-only check.
* On failure, delegates to `assertAccess` on the parent so the error shape
* stays identical to the previous parent-only check.
*/
export async function assertCanCreate(
aclService: ACLService,
@@ -272,20 +272,21 @@ const toIntBool = (v: unknown): number => (v ? 1 : 0);
/**
* Convert an AppStore-normalized app row into the v1 `associated_app` shape
* embedded in legacy FS entries. Booleans round-trip back to integers (0/1)
* because the v1 wire contract emits them that way and existing clients key
* off it. Other columns pass through as-is — `metadata` is already parsed.
* because the v1 wire contract emits them that way and existing clients key off
* it. Other columns pass through as-is — `metadata` is already parsed.
*
* Two redactions, because `associatedAppId` can name an app the actor does
* not own (the column is client-writable display metadata, never trusted for
* authz — see FSController) and may point cross-tenant:
* - Owner identifiers (`owner_user_id`, `app_owner`) are never emitted —
* they're internal user references with no client-side display use.
* - For private or protected apps, the direct hosting URL and launch
* capability flags (`index_url`, `godmode`, `maximize_on_start`,
* `background`, `metadata`) are dropped. This mirrors AppDriver, which
* withholds `index_url` from callers without read entitlement. Display
* fields (name, icon, title) still pass through so the GUI can label the
* file's associated app.
* Two redactions, because `associatedAppId` can name an app the actor does not
* own (the column is client-writable display metadata, never trusted for authz
* — see FSController) and may point cross-tenant:
*
* - Owner identifiers (`owner_user_id`, `app_owner`) are never emitted — they're
* internal user references with no client-side display use.
* - For private or protected apps, the direct hosting URL and launch capability
* flags (`index_url`, `godmode`, `maximize_on_start`, `background`,
* `metadata`) are dropped. This mirrors AppDriver, which withholds
* `index_url` from callers without read entitlement. Display fields (name,
* icon, title) still pass through so the GUI can label the file's associated
* app.
*/
function mapAppForLegacyAssociatedApp(
app: Record<string, unknown>,
@@ -319,13 +320,15 @@ function mapAppForLegacyAssociatedApp(
/**
* Batch-load `associated_app` payloads for a set of entries. Dedupes app ids
* across the input, hands them to `AppStore.getByIds` (one pipelined Redis
* MGET + a single `id IN (…)` query for any cache misses), and returns a map
* keyed by app id holding the v1-shaped embed. Callers pass the result to
* `toLegacyEntry` via `opts.appsById` so each entry hydrates without a
* second round-trip.
* MGET
*
* Empty input short-circuits — readdir on a directory of plain files makes
* zero extra calls.
* - A single `id IN (…)` query for any cache misses), and returns a map keyed by
* app id holding the v1-shaped embed. Callers pass the result to
* `toLegacyEntry` via `opts.appsById` so each entry hydrates without a second
* round-trip.
*
* Empty input short-circuits — readdir on a directory of plain files makes zero
* extra calls.
*/
export async function loadLegacyAssociatedApps(
appStore: AppRowLookup,
@@ -348,12 +351,55 @@ export async function loadLegacyAssociatedApps(
}
/**
* Produce the snake_case entry shape legacy clients expect. If `thumbnail`
* is set, asks the thumbnail extension (via `thumbnail.read` event) to swap
* an S3 URL for a signed one. Pass `fsEntryStore`/`userStore` to hydrate
* `is_empty` (directories) and `owner` — both are required fields per
* the legacy stat contract but need extra DB lookups. Pass `appsById`
* (built via `loadLegacyAssociatedApps`) to populate `associated_app`.
* The v1 `type` field: a MIME content-type (e.g. "image/png; charset=utf-8")
* for files, or "folder" for directories. The GUI's icon lookup keys off
* `type.startsWith('image/')`, so a bare extension breaks icon selection.
*/
export function fsEntryMimeType(entry: {
isDir: boolean;
name: string;
}): string | null {
return entry.isDir ? 'folder' : contentTypeFromMime(entry.name) || null;
}
/**
* Swap an S3 thumbnail key for a signed URL via the thumbnail extension
* (`thumbnail.read` event). Returns the input unchanged when there's no
* thumbnail or no event client; returns null if signing yields nothing.
*/
export async function signEntryThumbnail(
eventClient: EventClient | undefined,
uuid: string,
thumbnail: string | null,
): Promise<string | null> {
if (
typeof thumbnail !== 'string' ||
thumbnail.length === 0 ||
!eventClient
) {
return thumbnail ?? null;
}
const thumbnailEntry = { uuid, thumbnail };
try {
// emitAndWait — listener mutates `thumbnail` on the payload; plain
// `emit` is fire-and-forget and would drop the rewrite.
await eventClient.emitAndWait('thumbnail.read', thumbnailEntry, {});
} catch {
// ignore — non-critical.
}
return typeof thumbnailEntry.thumbnail === 'string' &&
thumbnailEntry.thumbnail.length > 0
? thumbnailEntry.thumbnail
: null;
}
/**
* Produce the snake_case entry shape legacy clients expect. If `thumbnail` is
* set, asks the thumbnail extension (via `thumbnail.read` event) to swap an S3
* URL for a signed one. Pass `fsEntryStore`/`userStore` to hydrate `is_empty`
* (directories) and `owner` — both are required fields per the legacy stat
* contract but need extra DB lookups. Pass `appsById` (built via
* `loadLegacyAssociatedApps`) to populate `associated_app`.
*/
export async function toLegacyEntry(
eventClient: EventClient | undefined,
@@ -367,13 +413,7 @@ export async function toLegacyEntry(
} = {},
): Promise<Record<string, unknown>> {
const dirname = pathPosix.dirname(entry.path);
// v1 contract: `type` is a MIME content-type (e.g. "image/png; charset=utf-8")
// for files, or "folder" for directories. The GUI's icon lookup does
// `type.startsWith('image/')` etc., so a bare extension breaks every
// banner that falls through the name-extension ladder in item_icon.js.
const mimeType = entry.isDir
? 'folder'
: contentTypeFromMime(entry.name) || null;
const mimeType = fsEntryMimeType(entry);
const pathComponents = entry.path.split('/');
const appdata_app =
@@ -444,28 +484,11 @@ export async function toLegacyEntry(
}
// Let the thumbnail extension swap an s3:// key for a signed URL.
if (
typeof response.thumbnail === 'string' &&
(response.thumbnail as string).length > 0 &&
eventClient
) {
const thumbnailEntry = {
uuid: entry.uuid,
thumbnail: response.thumbnail as string,
};
try {
// emitAndWait — listener mutates `thumbnail` on the payload;
// plain `emit` is fire-and-forget and would drop the rewrite.
await eventClient.emitAndWait('thumbnail.read', thumbnailEntry, {});
} catch {
// ignore — non-critical.
}
response.thumbnail =
typeof thumbnailEntry.thumbnail === 'string' &&
thumbnailEntry.thumbnail.length > 0
? thumbnailEntry.thumbnail
: null;
}
response.thumbnail = await signEntryThumbnail(
eventClient,
entry.uuid,
response.thumbnail as string | null,
);
return response;
}
@@ -475,8 +498,8 @@ export { normalizeAbsolutePath };
// -- Signing ---------------------------------------------------------
/**
* Pull the signing config off the app config. Throws if either value is
* missing — these are required for signed URL routes to function.
* Pull the signing config off the app config. Throws if either value is missing
* — these are required for signed URL routes to function.
*/
export function signingConfigFromAppConfig(config: IConfig): SigningConfig {
const secret = config.url_signature_secret;
@@ -498,9 +521,7 @@ export function signingConfigFromAppConfig(config: IConfig): SigningConfig {
return { secret, apiBaseUrl };
}
/**
* Convenience wrapper: turn an FSEntry into a signed-file response object.
*/
/** Convenience wrapper: turn an FSEntry into a signed-file response object. */
export function signEntry(
entry: {
uuid: string;
+71 -45
View File
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { posix as pathPosix } from 'node:path';
@@ -124,10 +125,10 @@ export class FSService extends PuterService {
}
/**
* FS-domain permission rules. App/site/user registrations live in their
* own services (AppPermissionService, SubdomainPermissionService,
* AuthService). Splitting by domain keeps the dependency surface narrow:
* each service only pulls the stores it actually needs.
* FS-domain permission rules. App/site/user registrations live in their own
* services (AppPermissionService, SubdomainPermissionService, AuthService).
* Splitting by domain keeps the dependency surface narrow: each service
* only pulls the stores it actually needs.
*
* The path rewriter relies on `FSEntryStore.getEntryByPath`'s Redis cache
* (60s TTL), which is invalidated on every rename/move/delete through the
@@ -2760,12 +2761,36 @@ export class FSService extends PuterService {
}
/**
* Search by file name for a user. Linear-scan with LIKE — cheap for
* typical library sizes, revisit if we need full-text.
* Cursor-paginated nested listing: descendants of `path` up to `maxDepth`
* levels deep, ordered by path. Owner-scoped by `userId` + path prefix.
*/
async listDirectoryTreePage(
userId: number,
path: string,
options: { limit?: number; cursor?: string | null; maxDepth: number },
): Promise<{ entries: FSEntry[]; cursor?: string }> {
return this.stores.fsEntry.listDescendantsPage(userId, path, options);
}
async countDirectoryTree(
userId: number,
path: string,
maxDepth: number,
): Promise<number> {
return this.stores.fsEntry.countDescendantsToDepth(
userId,
path,
maxDepth,
);
}
/**
* Search by file name for a user. Linear-scan with LIKE — cheap for typical
* library sizes, revisit if we need full-text.
*
* `pathScope` restricts results to entries at or under that path.
* App-under-user callers pass their AppData root so search can't leak
* paths the actor isn't allowed to read.
* App-under-user callers pass their AppData root so search can't leak paths
* the actor isn't allowed to read.
*/
async searchByName(
userId: number,
@@ -2793,8 +2818,8 @@ export class FSService extends PuterService {
/**
* Stream bytes of a file entry from S3. The returned stream is a Node
* Readable; caller pipes it into the HTTP response and emits metering
* once the stream ends. Honours HTTP Range when provided.
* Readable; caller pipes it into the HTTP response and emits metering once
* the stream ends. Honours HTTP Range when provided.
*
* Throws 400 if the entry isn't a file, 500 if the entry has no backing
* bucket (should never happen for real files).
@@ -2923,9 +2948,9 @@ export class FSService extends PuterService {
}
/**
* Resolve or create a parent directory for a given target path. Returns
* the parent entry. Throws 400 if the path has no parent (root) or 404
* when parents are missing and create is disabled.
* Resolve or create a parent directory for a given target path. Returns the
* parent entry. Throws 400 if the path has no parent (root) or 404 when
* parents are missing and create is disabled.
*/
async #resolveOrCreateParent(
userId: number,
@@ -2951,9 +2976,10 @@ export class FSService extends PuterService {
/**
* Create a directory at `path`. Options:
* - overwrite: if a non-directory exists, remove it and create dir
* - dedupeName: if conflict, append ` (N)`
* - createMissingParents: create intermediate dirs
*
* - Overwrite: if a non-directory exists, remove it and create dir
* - DedupeName: if conflict, append ` (N)`
* - CreateMissingParents: create intermediate dirs
*
* Returns the created (or existing-on-dedupe-false-no-conflict) entry.
*/
@@ -3255,10 +3281,10 @@ export class FSService extends PuterService {
/**
* Hard-delete every FS entry owned by `userId`: S3 objects first, then
* every `fsentries` row. Used by account deletion. Paginates through
* files (5k at a time) so large users don't blow the heap, batches S3
* deletes per bucket+region, and finishes with one bulk DELETE to
* sweep dirs/shortcuts/symlinks that don't have backing objects.
* every `fsentries` row. Used by account deletion. Paginates through files
* (5k at a time) so large users don't blow the heap, batches S3 deletes per
* bucket+region, and finishes with one bulk DELETE to sweep
* dirs/shortcuts/symlinks that don't have backing objects.
*
* Safe to call concurrently with other ops on the same user only in the
* sense that orphaned S3 objects may linger if a write races us; the DB
@@ -3369,13 +3395,12 @@ export class FSService extends PuterService {
/**
* Emit one of the lifecycle events that `extension.on('fs.…')` consumers
* expect (cf-file-cache, future thumbnails-style extensions). Payload
* carries multiple aliases (`node` / `entry` / `uid`) so handlers using
* any existing calling convention just work.
* carries multiple aliases (`node` / `entry` / `uid`) so handlers using any
* existing calling convention just work.
*
* Currently emitted:
* fs.create.{file,directory,shortcut,symlink}
* fs.write.file — overwrite of an existing file
* fs.rename — in-place name change (move emits fs.move.node separately)
* Currently emitted: fs.create.{file,directory,shortcut,symlink}
* fs.write.file — overwrite of an existing file fs.rename — in-place name
* change (move emits fs.move.node separately)
*
* Skipped intentionally: `fs.pending.*` (no real entry yet at signed-URL
* issue time) and per-flavor `fs.move.file` (move already emits
@@ -3404,8 +3429,8 @@ export class FSService extends PuterService {
/**
* Move an entry to a new parent (and optionally rename in the same op).
* Works for files and directories. Updates descendant paths when moving
* a directory.
* Works for files and directories. Updates descendant paths when moving a
* directory.
*/
async move(
userId: number,
@@ -3416,9 +3441,9 @@ export class FSService extends PuterService {
overwrite?: boolean;
dedupeName?: boolean;
/**
* Optional metadata to overwrite on the moved entry. Callers use this
* for trash/restore: when moving into Trash the GUI stores
* `{ original_name, original_path, trashed_ts }` here so the restore
* Optional metadata to overwrite on the moved entry. Callers use
* this for trash/restore: when moving into Trash the GUI stores `{
* original_name, original_path, trashed_ts }` here so the restore
* path and trash listing can recover the pre-trash name.
*/
newMetadata?: Record<string, unknown> | null;
@@ -3516,10 +3541,10 @@ export class FSService extends PuterService {
/**
* Copy an entry to a new parent. For directories, walks descendants and
* issues S3 CopyObject + DB inserts. Thumbnail URLs on entries ride
* along in the DB column — the thumbnail extension is notified via
* `fs.copy.node` so it can duplicate the backing S3 object (otherwise
* deleting one copy would nuke the other's thumbnail).
* issues S3 CopyObject + DB inserts. Thumbnail URLs on entries ride along
* in the DB column — the thumbnail extension is notified via `fs.copy.node`
* so it can duplicate the backing S3 object (otherwise deleting one copy
* would nuke the other's thumbnail).
*/
async copy(
userId: number,
@@ -3782,7 +3807,8 @@ export class FSService extends PuterService {
}
/**
* This method checks if the specified actor has permission to access the entry provided. It will throw an error if the actor is not permitted
* This method checks if the specified actor has permission to access the
* entry provided. It will throw an error if the actor is not permitted
*/
async checkFSAccess(
entry: FSEntry,
+127 -48
View File
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { statfs } from 'node:fs/promises';
@@ -58,8 +59,8 @@ const DEFAULT_DB_CHUNK_CONCURRENCY = 4;
/**
* Store backing the `fsentries` table. Owns DB CRUD over filesystem entries,
* Redis caching keyed by uuid/path/id, and pending-upload-session state in
* the system KV store. Constructed by the store registry; depends on `kv`.
* Redis caching keyed by uuid/path/id, and pending-upload-session state in the
* system KV store. Constructed by the store registry; depends on `kv`.
*/
export class FSEntryStore extends PuterStore {
declare protected stores: LayerInstances<typeof puterStores>;
@@ -971,12 +972,10 @@ export class FSEntryStore extends PuterStore {
}
/**
* Recursive-CTE lineage resolver. Returns the entry at `path`, or null
* if any segment is unresolvable. On success, backfills the `path`
* column on every row in the lineage (one UPDATE per row, gated on
* `path IS NULL OR path != expected` so we don't write the same value
* back on warm rows).
*
* Recursive-CTE lineage resolver. Returns the entry at `path`, or null if
* any segment is unresolvable. On success, backfills the `path` column on
* every row in the lineage (one UPDATE per row, gated on `path IS NULL OR
* path != expected` so we don't write the same value back on warm rows).
*/
async #resolveEntryByPathLineage(
normalizedPath: string,
@@ -1061,22 +1060,22 @@ export class FSEntryStore extends PuterStore {
/**
* Upward-walk variant of the lineage resolver. Used by uuid/id lookups
* where we already have the row but its `path` column is NULL same
* legacy state the path-based resolver heals from the other direction.
* where we already have the row but its `path` column is NULL same legacy
* state the path-based resolver heals from the other direction.
*
* Walks `parent_uid` chains from the entry up to the home row
* (`parent_uid IS NULL`), then reuses `#backfillLineagePaths` to write
* the correct `path` on every ancestor + the entry itself. Returns the
* re-fetched target entry so the caller doesn't need a second query.
* Walks `parent_uid` chains from the entry up to the home row (`parent_uid
* IS NULL`), then reuses `#backfillLineagePaths` to write the correct
* `path` on every ancestor + the entry itself. Returns the re-fetched
* target entry so the caller doesn't need a second query.
*
* Returns null if the chain is broken (an intermediate `parent_uid`
* doesn't resolve to a row) those rows are unrecoverable from this
* direction and the caller should fall back to whatever degraded
* behaviour it had before.
* Returns null if the chain is broken (an intermediate `parent_uid` doesn't
* resolve to a row) those rows are unrecoverable from this direction and
* the caller should fall back to whatever degraded behaviour it had
* before.
*
* Same security posture as `#resolveEntryByPathLineage`: this method
* only resolves + heals data, it does not authorize access. Callers
* still gate via ACL on the returned entry.
* Same security posture as `#resolveEntryByPathLineage`: this method only
* resolves + heals data, it does not authorize access. Callers still gate
* via ACL on the returned entry.
*/
async #healEntryPathByLineageUp(
targetUuid: string,
@@ -1143,9 +1142,9 @@ export class FSEntryStore extends PuterStore {
* Heal in place: for every entry in `entries` whose `path` column is NULL
* (legacy rows that pre-date the path-cache write), run the lineage-up
* resolver and replace the slot with the healed entry. Heals run in
* parallel typical workloads have zero such rows so the loop is a
* no-op; rare bursts (a freshly migrated account) only pay one
* recursive-CTE round-trip per row instead of N sequential.
* parallel typical workloads have zero such rows so the loop is a no-op;
* rare bursts (a freshly migrated account) only pay one recursive-CTE
* round-trip per row instead of N sequential.
*
* Mutates the array in place.
*/
@@ -1276,11 +1275,10 @@ export class FSEntryStore extends PuterStore {
}
/**
* Batched lookup by id. Dedupes input ids, reads cache via per-id GETs,
* and resolves remaining misses with a single
* `SELECT … WHERE id IN (…)` per chunk. Use this in place of
* `Promise.all(ids.map(getEntryById))` to avoid one connection per row
* on large id sets.
* Batched lookup by id. Dedupes input ids, reads cache via per-id GETs, and
* resolves remaining misses with a single `SELECT … WHERE id IN (…)` per
* chunk. Use this in place of `Promise.all(ids.map(getEntryById))` to avoid
* one connection per row on large id sets.
*
* Missing ids (no DB row) are simply absent from the returned map.
*/
@@ -2272,13 +2270,13 @@ export class FSEntryStore extends PuterStore {
/**
* Create a single non-file entry: directory, shortcut, or symlink.
*
* Unlike `batchCreateEntries` (which is geared to S3-backed files),
* these rows carry no bucket metadata. The caller is responsible for
* parent/name conflict resolution this method assumes the parent
* exists and the target name is free.
* Unlike `batchCreateEntries` (which is geared to S3-backed files), these
* rows carry no bucket metadata. The caller is responsible for parent/name
* conflict resolution this method assumes the parent exists and the
* target name is free.
*
* Returns the inserted entry with a refreshed row read. Throws 409 on
* a unique-key collision (caller should pre-check and dedupe).
* Returns the inserted entry with a refreshed row read. Throws 409 on a
* unique-key collision (caller should pre-check and dedupe).
*/
async createNonFileEntry(input: {
userId: number;
@@ -2374,8 +2372,8 @@ export class FSEntryStore extends PuterStore {
}
/**
* Update accessed/modified/created timestamps in place. Used by `touch`
* for entries that already exist.
* Update accessed/modified/created timestamps in place. Used by `touch` for
* entries that already exist.
*/
async touchEntryTimestamps(
uuid: string,
@@ -2480,8 +2478,7 @@ export class FSEntryStore extends PuterStore {
} = {},
): Promise<{ entries: FSEntry[]; cursor?: string }> {
const payload = decodeCursor(options.cursor) as
| { v: unknown; id: number; s?: string; o?: string }
| undefined;
{ v: unknown; id: number; s?: string; o?: string } | undefined;
const requestedSort = options.sortBy ?? null;
const requestedOrder = options.sortOrder ?? null;
@@ -2630,6 +2627,88 @@ export class FSEntryStore extends PuterStore {
return Number(rows[0]?.n ?? 0);
}
// Slashes in a path — the depth marker. Paths are absolute and carry no
// trailing slash, so a direct child of `/u/foo` (2 slashes) is `/u/foo/x`
// (3 slashes): its depth relative to the prefix is 1.
#slashCount(value: string): number {
let count = 0;
for (let i = 0; i < value.length; i++) {
if (value[i] === '/') count++;
}
return count;
}
/**
* Cursor-paginated descendants of a directory, limited to `maxDepth` levels
* below the prefix. Prefix + user_id scan (same index as
* `listDescendantsByPath`) with a portable slash-count depth filter. Keyset
* pagination on `path ASC` path is unique per user, so no id tiebreaker.
*/
async listDescendantsPage(
userId: number,
pathPrefix: string,
options: { limit?: number; cursor?: string | null; maxDepth: number },
): Promise<{ entries: FSEntry[]; cursor?: string }> {
const normalizedPrefix = this.#normalizePath(pathPrefix);
if (normalizedPrefix === '/') {
throw new HttpError(400, 'Refusing to list descendants of root', {
legacyCode: 'bad_request',
});
}
const likePattern = `${this.#escapeLikePattern(normalizedPrefix)}/%`;
const maxSlashes =
this.#slashCount(normalizedPrefix) + Math.max(1, options.maxDepth);
const limit = normalizeLimit(options.limit, { cap: 10_000 }) ?? 1000;
const payload = decodeCursor(options.cursor) as
{ p: string } | undefined;
const seek = payload ? 'AND path > ?' : '';
const params: unknown[] = payload
? [userId, likePattern, maxSlashes, payload.p, limit + 1]
: [userId, likePattern, maxSlashes, limit + 1];
const rows = (await this.clients.db.read(
`SELECT ${this.#selectFsentriesColumns()}
FROM fsentries
WHERE user_id = ? AND path LIKE ? ESCAPE '!'
AND (LENGTH(path) - LENGTH(REPLACE(path, '/', ''))) <= ? ${seek}
ORDER BY path ASC
LIMIT ?`,
params,
)) as unknown as FSEntryRow[];
const hasMore = rows.length > limit;
const pageRows = hasMore ? rows.slice(0, limit) : rows;
const entries = await this.#finalizeChildEntries(pageRows);
let cursor: string | undefined;
if (hasMore) {
const last = pageRows[pageRows.length - 1]!;
cursor = encodeCursor({ p: last.path });
}
return { entries, ...(cursor ? { cursor } : {}) };
}
async countDescendantsToDepth(
userId: number,
pathPrefix: string,
maxDepth: number,
): Promise<number> {
const normalizedPrefix = this.#normalizePath(pathPrefix);
if (normalizedPrefix === '/') return 0;
const likePattern = `${this.#escapeLikePattern(normalizedPrefix)}/%`;
const maxSlashes =
this.#slashCount(normalizedPrefix) + Math.max(1, maxDepth);
const rows = (await this.clients.db.read(
`SELECT COUNT(*) AS n FROM fsentries
WHERE user_id = ? AND path LIKE ? ESCAPE '!'
AND (LENGTH(path) - LENGTH(REPLACE(path, '/', ''))) <= ?`,
[userId, likePattern, maxSlashes],
)) as unknown as { n: number | string }[];
return Number(rows[0]?.n ?? 0);
}
// Sum of sizes under a path (inclusive). Files only — dirs have null size.
// NOTE: linear scan under the path prefix index; optimize later if it
// becomes hot (e.g., incremental size counters or materialized totals).
@@ -1,6 +1,7 @@
import * as utils from '../../../lib/utils.js';
import { fetchAllPages, iteratePages } from '../../../lib/pagination.js';
import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js';
import mapV2EntryToV1 from '../utils/mapV2EntryToV1.js';
// Track in-flight requests to avoid duplicate backend calls
// Each entry stores: { promise, timestamp }
@@ -27,17 +28,31 @@ const requestOnce = function (options, pageParams) {
}
}
// create xhr object
const xhr = utils.initXhr('/readdir', this.APIOrigin, undefined, 'post', 'text/plain;actually=json');
// create xhr object. Backend serves readdir on the v2 `/fs/readdir`
// route, which returns camelCase entries; we normalize them to the v1
// shape below so existing callers see an unchanged response.
const xhr = utils.initXhr('/fs/readdir', this.APIOrigin, undefined, 'post', 'text/plain;actually=json');
// set up event handlers for load and error events
utils.setupXhrEventHandlers(xhr, undefined, undefined, (result) => {
// Normalize each v2 entry to the v1 shape, in place, so the bare
// array and the `{items, cursor?, total?}` envelope both return the
// legacy shape and the cache is populated with it.
let normalized;
if ( Array.isArray(result) ) {
normalized = result.map(mapV2EntryToV1);
} else if ( result && Array.isArray(result.items) ) {
normalized = { ...result, items: result.items.map(mapV2EntryToV1) };
} else {
normalized = result;
}
// set each individual item's cache
const entries = Array.isArray(result) ? result : (result?.items ?? []);
const entries = Array.isArray(normalized) ? normalized : (normalized?.items ?? []);
for ( const item of entries ) {
puter._cache.set(`item:${ item.path}`, item);
}
resolve(result);
resolve(normalized);
}, reject);
// Build request payload - support both path and uid parameters
@@ -51,6 +66,8 @@ const requestOnce = function (options, pageParams) {
if ( options.offset !== undefined ) payload.offset = options.offset;
if ( options.sortBy !== undefined ) payload.sortBy = options.sortBy;
if ( options.sortOrder !== undefined ) payload.sortOrder = options.sortOrder;
if ( options.recursive !== undefined ) payload.recursive = options.recursive;
if ( options.depth !== undefined ) payload.depth = options.depth;
if ( pageParams ) {
payload.cursor = pageParams.cursor ?? null;
if ( pageParams.includeTotal !== undefined ) {
@@ -125,9 +142,10 @@ const readdir = function (...args) {
options.offset === undefined;
// Generate cache key based on path. Only full listings are cached —
// pages and limit/offset-truncated results never are.
// pages and limit/offset-truncated results never are. Recursive
// listings are never cached (they'd collide with the direct listing).
let cacheKey;
if ( options.path && unbound ) {
if ( options.path && unbound && ! options.recursive ) {
cacheKey = `readdir:${ options.path}`;
}
@@ -154,6 +172,8 @@ const readdir = function (...args) {
includeTotal: options.includeTotal,
sortBy: options.sortBy,
sortOrder: options.sortOrder,
recursive: options.recursive,
depth: options.depth,
});
// Check if there's already an in-flight request for the same parameters
@@ -0,0 +1,59 @@
import path from '../../../lib/path.js';
/**
* Convert a v2 (camelCase) fsentry from `/fs/readdir` into the v1 snake_case
* shape existing callers and the GUI consume. The backend readdir response is
* enriched with the three fields the client can't reconstruct on its own
* (`type`, a signed `thumbnail`, and `associatedApp`); everything else is a
* rename or a trivial derivation here. Mirrors the backend's `toLegacyEntry`.
*
* @param {Record<string, unknown>} entry
* @returns {Record<string, unknown>}
*/
const mapV2EntryToV1 = (entry) => {
if ( ! entry || typeof entry !== 'object' ) return entry;
const entryPath = typeof entry.path === 'string' ? entry.path : '';
const dirname = entryPath ? path.dirname(entryPath) : entry.dirname;
const pathComponents = entryPath.split('/');
const appdata_app = pathComponents[2] === 'AppData'
? pathComponents[3]
: undefined;
const subdomains = Array.isArray(entry.subdomains) ? entry.subdomains : [];
return {
id: entry.uuid,
uid: entry.uid ?? entry.uuid,
uuid: entry.uuid,
parent_id: entry.parentUid ?? null,
parent_uid: entry.parentUid ?? null,
path: entry.path,
dirname,
dirpath: dirname,
name: entry.name,
is_dir: Boolean(entry.isDir),
is_shortcut: entry.isShortcut ? 1 : 0,
shortcut_to: entry.shortcutTo ?? null,
is_symlink: entry.isSymlink ? 1 : 0,
symlink_path: entry.symlinkPath ?? null,
type: entry.type ?? null,
writable: true,
is_public: entry.isPublic ?? null,
thumbnail: entry.thumbnail ?? null,
immutable: Boolean(entry.immutable),
metadata: entry.metadata ?? null,
modified: entry.modified,
created: entry.created ?? null,
accessed: entry.accessed ?? null,
size: entry.size ?? null,
layout: entry.layout ?? null,
subdomains,
workers: Array.isArray(entry.workers) ? entry.workers : [],
has_website: entry.hasWebsite ?? subdomains.length > 0,
suggested_apps: entry.suggestedApps,
associated_app: entry.associatedApp ?? null,
appdata_app,
};
};
export default mapV2EntryToV1;
+184
View File
@@ -141,6 +141,27 @@ const isApiPuterComOrigin = () => {
}
};
// Build a known directory tree under `base` for the recursive-readdir tests:
//
// base/
// top.txt (depth 1)
// a/ (depth 1)
// a1.txt (depth 2)
// b/ (depth 2)
// deep.txt (depth 3)
//
// 5 descendants total. Returns nothing — callers readdir `base` themselves.
const setupRecursiveTree = async (base) => {
await puter.fs.mkdir(base);
await puter.fs.write(`${base}/top.txt`, 'x');
await puter.fs.write(`${base}/a/a1.txt`, 'x', { createMissingParents: true });
await puter.fs.write(`${base}/a/b/deep.txt`, 'x', { createMissingParents: true });
};
// Path relative to `base` for an entry whose absolute path contains `/base/…`.
// `base` is a unique randName, so splitting on `/${base}/` is unambiguous.
const relToBase = (base, absPath) => String(absPath).split(`/${base}/`)[1];
window.fsTests = [
{
name: "testFSWrite",
@@ -953,4 +974,167 @@ window.fsTests = [
}
}
},
{
name: "testFSReadDirRecursive",
description: "Test recursive readdir returns the whole subtree (descendants beyond direct children)",
test: async function() {
const base = puter.randName();
try {
await setupRecursiveTree(base);
const result = await puter.fs.readdir({ path: base, recursive: true });
assert(Array.isArray(result), "Recursive unbound readdir should return an array");
const rel = result.map((e) => relToBase(base, e.path)).sort();
assert(
JSON.stringify(rel) === JSON.stringify(['a', 'a/a1.txt', 'a/b', 'a/b/deep.txt', 'top.txt']),
"Recursive readdir returned the wrong subtree: " + JSON.stringify(rel),
);
pass("testFSReadDirRecursive passed");
} catch (error) {
fail("testFSReadDirRecursive failed:", error);
} finally {
try { await puter.fs.delete(base, { recursive: true }); } catch (e) {}
}
}
},
{
name: "testFSReadDirRecursiveDepth",
description: "Test the recursive readdir `depth` option controls how many levels below the target are returned",
test: async function() {
const base = puter.randName();
try {
await setupRecursiveTree(base);
const d1 = await puter.fs.readdir({ path: base, recursive: true, depth: 1 });
assert(d1.length === 2, "depth 1 should return only direct children, got " + d1.length);
const d2 = await puter.fs.readdir({ path: base, recursive: true, depth: 2 });
assert(d2.length === 4, "depth 2 should include grandchildren, got " + d2.length);
const d3 = await puter.fs.readdir({ path: base, recursive: true, depth: 3 });
assert(d3.length === 5, "depth 3 should include the whole tree, got " + d3.length);
pass("testFSReadDirRecursiveDepth passed");
} catch (error) {
fail("testFSReadDirRecursiveDepth failed:", error);
} finally {
try { await puter.fs.delete(base, { recursive: true }); } catch (e) {}
}
}
},
{
name: "testFSReadDirRecursiveDepthCap",
description: "Test a very large recursive `depth` is capped server-side and simply returns the whole subtree",
test: async function() {
const base = puter.randName();
try {
await setupRecursiveTree(base);
const result = await puter.fs.readdir({ path: base, recursive: true, depth: 9999 });
assert(result.length === 5, "capped-depth recursive readdir should return the whole subtree, got " + result.length);
pass("testFSReadDirRecursiveDepthCap passed");
} catch (error) {
fail("testFSReadDirRecursiveDepthCap failed:", error);
} finally {
try { await puter.fs.delete(base, { recursive: true }); } catch (e) {}
}
}
},
{
name: "testFSReadDirRecursivePagination",
description: "Test recursive readdir pages through the whole subtree with a cursor, no duplicates or gaps",
test: async function() {
const base = puter.randName();
try {
await setupRecursiveTree(base);
const seen = [];
let cursor = null;
let guard = 0;
do {
const page = await puter.fs.readdir({ path: base, recursive: true, depth: 10, limit: 2, cursor });
assert(page.items.length <= 2, "each page should respect the limit");
seen.push(...page.items.map((e) => relToBase(base, e.path)));
cursor = page.cursor;
assert(++guard < 20, "pagination did not terminate");
} while (cursor);
assert(
JSON.stringify(seen.sort()) === JSON.stringify(['a', 'a/a1.txt', 'a/b', 'a/b/deep.txt', 'top.txt']),
"paged recursive readdir missed or duplicated entries: " + JSON.stringify(seen),
);
pass("testFSReadDirRecursivePagination passed");
} catch (error) {
fail("testFSReadDirRecursivePagination failed:", error);
} finally {
try { await puter.fs.delete(base, { recursive: true }); } catch (e) {}
}
}
},
{
name: "testFSReadDirRecursiveIncludeTotal",
description: "Test recursive readdir with includeTotal reports the size of the subtree at the requested depth",
test: async function() {
const base = puter.randName();
try {
await setupRecursiveTree(base);
const page = await puter.fs.readdir({ path: base, recursive: true, depth: 2, cursor: null, includeTotal: true });
assert(page.total === 4, "includeTotal should count the depth-2 subtree (4), got " + page.total);
pass("testFSReadDirRecursiveIncludeTotal passed");
} catch (error) {
fail("testFSReadDirRecursiveIncludeTotal failed:", error);
} finally {
try { await puter.fs.delete(base, { recursive: true }); } catch (e) {}
}
}
},
{
name: "testFSReadDirRecursiveStream",
description: "Test recursive readdir with stream:true iterates the whole subtree page by page via for-await",
test: async function() {
const base = puter.randName();
try {
await setupRecursiveTree(base);
const seen = [];
let pages = 0;
for await (const page of puter.fs.readdir({ path: base, recursive: true, depth: 10, limit: 2, stream: true })) {
pages++;
assert(page.items.length <= 2, "stream pages should respect the limit");
seen.push(...page.items.map((e) => relToBase(base, e.path)));
}
assert(pages >= 2, "streaming a 5-entry tree with limit 2 should yield multiple pages");
assert(
JSON.stringify(seen.sort()) === JSON.stringify(['a', 'a/a1.txt', 'a/b', 'a/b/deep.txt', 'top.txt']),
"streamed recursive readdir missed or duplicated entries: " + JSON.stringify(seen),
);
pass("testFSReadDirRecursiveStream passed");
} catch (error) {
fail("testFSReadDirRecursiveStream failed:", error);
} finally {
try { await puter.fs.delete(base, { recursive: true }); } catch (e) {}
}
}
},
{
name: "testFSReadDirRecursiveV1Shape",
description: "Test recursive readdir entries keep the v1 shape (is_dir, path, name, MIME type) the GUI/apps expect",
test: async function() {
const base = puter.randName();
try {
await setupRecursiveTree(base);
const result = await puter.fs.readdir({ path: base, recursive: true, depth: 10 });
for (const entry of result) {
assert(typeof entry.is_dir === 'boolean', "entry.is_dir should be a boolean");
assert(typeof entry.path === 'string' && entry.path.length > 0, "entry.path should be a non-empty string");
assert(typeof entry.name === 'string' && entry.name.length > 0, "entry.name should be a non-empty string");
assert('associated_app' in entry, "entry should carry associated_app");
}
const dir = result.find((e) => e.name === 'a');
const file = result.find((e) => e.name === 'deep.txt');
assert(dir && dir.is_dir === true && dir.type === 'folder', "directory entry should have type 'folder'");
assert(file && file.is_dir === false && String(file.type).includes('text/plain'), "text file should have a text/plain MIME type, got " + (file && file.type));
pass("testFSReadDirRecursiveV1Shape passed");
} catch (error) {
fail("testFSReadDirRecursiveV1Shape failed:", error);
} finally {
try { await puter.fs.delete(base, { recursive: true }); } catch (e) {}
}
}
},
];
+38
View File
@@ -247,6 +247,44 @@ export default suite('fs', {
);
},
'readdir recursive lists a nested subtree in v1 shape': async (t) => {
const base = `${home(t)}/fs-suite-recursive`;
await t.puter.fs.write(`${base}/a/b/deep.txt`, 'x', {
createMissingParents: true,
});
await t.puter.fs.write(`${base}/top.txt`, 'y');
// depth 1: only direct children
const shallow = (await t.puter.fs.readdir({
path: base,
recursive: true,
depth: 1,
cursor: null,
})) as { items: Array<{ name: string; is_dir: unknown }> };
const shallowNames = shallow.items.map((e) => e.name).sort();
t.assert.deepEqual(shallowNames, ['a', 'top.txt']);
// deeper: descendants appear, and paging terminates
const seen: string[] = [];
let cursor: string | null | undefined = null;
do {
const page = (await t.puter.fs.readdir({
path: base,
recursive: true,
depth: 5,
limit: 2,
cursor,
})) as {
items: Array<{ path: string; is_dir: unknown }>;
cursor?: string;
};
seen.push(...page.items.map((e) => e.path));
cursor = page.cursor;
} while (cursor);
const rel = seen.map((p) => p.slice(base.length + 1)).sort();
t.assert.deepEqual(rel, ['a', 'a/b', 'a/b/deep.txt', 'top.txt']);
},
'copy duplicates a file': async (t) => {
const src = `${home(t)}/fs-suite-copy-src.txt`;
const dstDir = `${home(t)}/fs-suite-copy-dst`;