Report sharing in stat and readdir

isShared on /fs/stat and /fs/readdir, is_shared on their legacy
counterparts, and return_shares now fills the shares array the legacy
stat has been stubbing with []. Null for entries the caller does not own,
so a share recipient is never told who else can reach the owner's files.
The share-to-wire mapper moves out of ShareController so both controllers
publish one shape.
This commit is contained in:
Juan Castro
2026-08-25 17:44:41 -04:00
parent 5115fdd48c
commit ee2f14576b
8 changed files with 367 additions and 83 deletions
@@ -226,4 +226,131 @@ describe('GET /fs/readdir over HTTP', () => {
const body = (await response.json()) as Array<{ name: string }>;
expect(body.map((e) => e.name)).toContain('Documents');
});
describe('the share flag', () => {
const post = async (path: string, token: string, body: unknown) => {
const response = await fetch(new URL(path, env.apiOrigin), {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
expect(response.status).toBe(200);
return response.json() as Promise<Record<string, unknown>>;
};
const mkdir = (path: string, token: string) =>
post('/fs/mkdir', token, { path });
const readdir = async (path: string, token: string) => {
const response = await fetch(readdirUrl({ path, auth_token: token }));
expect(response.status).toBe(200);
const entries = (await response.json()) as Array<{
name: string;
isShared: boolean | null;
}>;
return new Map(entries.map((e) => [e.name, e.isShared]));
};
const base = () =>
`/${env.users.user.username}/Documents/flag-${crypto.randomUUID().slice(0, 8)}`;
it('marks a shared child in a listing, and clears it on revoke', async () => {
const owner = env.users.user;
const recipient = env.users.other;
const parent = base();
await mkdir(parent, owner.token);
const shared = await mkdir(`${parent}/shared`, owner.token);
await mkdir(`${parent}/private`, owner.token);
// A write response says nothing about sharing.
expect(shared).not.toHaveProperty('isShared');
await post('/share', owner.token, {
recipients: [recipient.username],
items: [{ uid: shared.uuid }],
mode: 'read',
});
expect(await readdir(parent, owner.token)).toEqual(
new Map([
['shared', true],
['private', false],
]),
);
await post('/share/revoke', owner.token, {
recipients: [recipient.username],
items: [{ uid: shared.uuid }],
});
expect(await readdir(parent, owner.token)).toEqual(
new Map([
['shared', false],
['private', false],
]),
);
});
it('names the recipients in stat only when asked', async () => {
const owner = env.users.user;
const recipient = env.users.other;
const parent = base();
await mkdir(parent, owner.token);
const shared = await mkdir(`${parent}/shared`, owner.token);
await post('/share', owner.token, {
recipients: [recipient.username],
items: [{ uid: shared.uuid }],
mode: 'read',
});
const plain = await post('/fs/stat', owner.token, {
uid: shared.uuid,
});
expect(plain.isShared).toBe(true);
expect(plain).not.toHaveProperty('shares');
const withShares = await post('/fs/stat', owner.token, {
uid: shared.uuid,
return_shares: true,
});
const shares = withShares.shares as Array<Record<string, unknown>>;
expect(shares).toHaveLength(1);
expect(shares[0]).toMatchObject({
holder: recipient.username,
issuer: owner.username,
mode: 'read',
inherited_from: null,
});
for (const key of ['holder_user_id', 'issuer_user_id', 'fsentry_id']) {
expect(shares[0]).not.toHaveProperty(key);
}
});
it('tells a recipient nothing about who else can reach the item', async () => {
const owner = env.users.user;
const recipient = env.users.other;
const parent = base();
await mkdir(parent, owner.token);
const shared = await mkdir(`${parent}/shared`, owner.token);
await post('/share', owner.token, {
recipients: [recipient.username],
items: [{ uid: shared.uuid }],
mode: 'read',
});
const stat = await post('/fs/stat', recipient.token, {
uid: shared.uuid,
return_shares: true,
});
expect(stat.isShared).toBeNull();
// Asked for, so the key is there — but it names nobody.
expect(stat.shares).toEqual([]);
});
});
});
+40 -21
View File
@@ -45,6 +45,7 @@ import {
runWithConcurrencyLimitSettled,
} from '../../util/concurrency.js';
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
import { listClientShares } from '../share/clientShare.js';
import { PuterController } from '../types.js';
import { STORAGE_OP_COSTS } from '../../services/metering/costs.js';
import {
@@ -972,17 +973,28 @@ export class FSController extends PuterController {
await this.#assertAccess(actor, entry.path, 'see');
const wantsSize = this.#toBoolean(body.return_size);
const subtreeSize =
entry.isDir && wantsSize
? await this.services.fs.getSubtreeSize(userId, entry.path)
: undefined;
entry.suggestedApps =
await this.services.suggestedApps.getSuggestedApps(entry);
const [subtreeSize, suggestedApps, shareFlags, shares] =
await Promise.all([
entry.isDir && wantsSize
? this.services.fs.getSubtreeSize(userId, entry.path)
: undefined,
this.services.suggestedApps.getSuggestedApps(entry),
this.services.share.shareFlags(actor, [entry]),
this.#toBoolean(body.return_shares)
? listClientShares(
this.services.share,
this.clients.event,
actor,
entry.uuid,
)
: undefined,
]);
entry.suggestedApps = suggestedApps;
res.json({
...this.#toClientEntry(entry),
...this.#toClientEntry(entry, shareFlags.get(entry.uuid) ?? null),
...(subtreeSize !== undefined ? { size: subtreeSize } : {}),
...(shares !== undefined ? { shares } : {}),
});
}
@@ -994,7 +1006,7 @@ export class FSController extends PuterController {
* (with public folders enabled) any authenticated user. The legacy read
* path already curates its output; this does the same for the v2 routes.
*/
#toClientEntry(entry: FSEntry): ClientFSEntry {
#toClientEntry(entry: FSEntry, isShared?: boolean | null): ClientFSEntry {
// Allowlist, not a denylist: a denylist silently ships every column
// added to `fsentries` later. Omits the numeric primary keys (`id`,
// `parentId`, `associatedAppId`), the storage columns, the owning
@@ -1026,6 +1038,8 @@ export class FSController extends PuterController {
workers: entry.workers ?? [],
hasWebsite: entry.hasWebsite ?? subdomains.length > 0,
suggestedApps: entry.suggestedApps ?? [],
// Read paths pass a value; write responses leave it off entirely.
...(isShared === undefined ? {} : { isShared }),
};
}
@@ -1134,7 +1148,7 @@ export class FSController extends PuterController {
child.suggestedApps = rootSuggestions[index] ?? [];
}
}
const rootItems = await this.#toReaddirEntries(rootChildren);
const rootItems = await this.#toReaddirEntries(actor, rootChildren);
if (paginated) {
res.json({
items: rootItems,
@@ -1207,7 +1221,7 @@ export class FSController extends PuterController {
)
: undefined;
res.json({
items: await this.#toReaddirEntries(page.entries),
items: await this.#toReaddirEntries(actor, page.entries),
...(page.cursor ? { cursor: page.cursor } : {}),
...(total !== undefined ? { total } : {}),
});
@@ -1227,7 +1241,7 @@ export class FSController extends PuterController {
? await this.services.fs.countDirectory(parent.uuid)
: undefined;
res.json({
items: await this.#toReaddirEntries(page.entries),
items: await this.#toReaddirEntries(actor, page.entries),
...(page.cursor ? { cursor: page.cursor } : {}),
...(total !== undefined ? { total } : {}),
});
@@ -1241,7 +1255,7 @@ export class FSController extends PuterController {
sortOrder,
});
await this.#attachSuggestedApps(children);
res.json(await this.#toReaddirEntries(children));
res.json(await this.#toReaddirEntries(actor, children));
}
/**
@@ -1249,14 +1263,20 @@ export class FSController extends PuterController {
* 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<ClientReaddirEntry[]> {
const appsById = await loadLegacyAssociatedApps(
this.stores.app,
entries,
);
async #toReaddirEntries(
actor: Actor,
entries: FSEntry[],
): Promise<ClientReaddirEntry[]> {
const [appsById, shareFlags] = await Promise.all([
loadLegacyAssociatedApps(this.stores.app, entries),
this.services.share.shareFlags(actor, entries),
]);
return Promise.all(
entries.map(async (entry) => ({
...this.#toClientEntry(entry),
...this.#toClientEntry(
entry,
shareFlags.get(entry.uuid) ?? null,
),
// Fields the client cannot derive on its own.
type: fsEntryMimeType(entry),
thumbnail: await signEntryThumbnail(
@@ -2193,8 +2213,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),
@@ -373,6 +373,47 @@ describe('LegacyFSController.stat', () => {
expect(body.size).toBe(0);
});
it('reports the share flag, and the recipients when asked', async () => {
const { actor } = await makeUser();
const recipient = await makeUser();
const username = actor.user!.username!;
const path = `/${username}/Documents/shared-folder`;
await withActor(actor, () =>
controller.mkdir(makeReq({ body: { path }, actor }), makeRes().res),
);
const before = makeRes();
await withActor(actor, () =>
controller.stat(makeReq({ body: { path }, actor }), before.res),
);
expect(before.captured.body).toMatchObject({ is_shared: false });
await withActor(actor, () =>
server.services.share.share(actor, {
path,
recipient: { username: recipient.actor.user!.username! },
mode: 'read',
}),
);
const after = makeRes();
await withActor(actor, () =>
controller.stat(
makeReq({ body: { path, return_shares: true }, actor }),
after.res,
),
);
const body = after.captured.body as Record<string, unknown>;
expect(body.is_shared).toBe(true);
expect(body.shares).toMatchObject([
{
holder: recipient.actor.user!.username,
issuer: username,
mode: 'read',
},
]);
});
it('throws 401 when the request has no actor', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
@@ -650,6 +691,44 @@ describe('LegacyFSController.readdir', () => {
expect(names).toContain('beta');
});
it('flags a shared child in the listing', async () => {
const { actor } = await makeUser();
const recipient = await makeUser();
const username = actor.user!.username!;
const parent = `/${username}/Documents/flagged`;
for (const name of ['', '/shared', '/private']) {
await withActor(actor, () =>
controller.mkdir(
makeReq({ body: { path: `${parent}${name}` }, actor }),
makeRes().res,
),
);
}
await withActor(actor, () =>
server.services.share.share(actor, {
path: `${parent}/shared`,
recipient: { username: recipient.actor.user!.username! },
mode: 'read',
}),
);
const { res, captured } = makeRes();
await withActor(actor, () =>
controller.readdir(makeReq({ body: { path: parent }, actor }), res),
);
const entries = captured.body as Array<{
name: string;
is_shared: boolean | null;
}>;
expect(new Map(entries.map((e) => [e.name, e.is_shared]))).toEqual(
new Map([
['shared', true],
['private', false],
]),
);
});
it('returns the root listing when path = "/"', async () => {
const { actor } = await makeUser();
const { res, captured } = makeRes();
@@ -47,6 +47,7 @@ import {
hostedIndexUrlBackingIsUnavailable,
} from '../../util/hostedAppBacking.js';
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
import { listClientShares } from '../share/clientShare.js';
import { PuterController } from '../types.js';
import {
FS_BATCH_CONCURRENT,
@@ -437,12 +438,12 @@ export class LegacyFSController extends PuterController {
'see',
);
entry.suggestedApps =
await this.services.suggestedApps.getSuggestedApps(entry);
const appsById = await loadLegacyAssociatedApps(this.stores.app, [
entry,
const [suggestedApps, appsById, shareFlags] = await Promise.all([
this.services.suggestedApps.getSuggestedApps(entry),
loadLegacyAssociatedApps(this.stores.app, [entry]),
this.services.share.shareFlags(actor, [entry]),
]);
entry.suggestedApps = suggestedApps;
const shaped = await toLegacyEntry(this.clients.event, entry, {
fsEntryStore: this.stores.fsEntry,
@@ -452,6 +453,7 @@ export class LegacyFSController extends PuterController {
) => Promise<Record<string, unknown> | null>;
},
appsById,
isShared: shareFlags.get(entry.uuid) ?? null,
});
// Optional hydrations:
@@ -461,13 +463,17 @@ export class LegacyFSController extends PuterController {
entry.path,
);
}
// Legacy clients sometimes ask for `return_versions`, `return_shares`.
// We don't have parity for these yet — return empty arrays to avoid
// breaking `response.x.forEach(...)` patterns. `return_owner` is a
// no-op flag here: the `owner` field is already populated by
// `toLegacyEntry` as `{ username }`.
// `return_versions` has no parity yet; the empty array keeps `forEach`
// callers working. `return_owner` is a no-op — `owner` is always set.
if (getBoolean(body, 'return_versions')) shaped.versions = [];
if (getBoolean(body, 'return_shares')) shaped.shares = [];
if (getBoolean(body, 'return_shares')) {
shaped.shares = await listClientShares(
this.services.share,
this.clients.event,
actor,
entry.uuid,
);
}
res.json(shaped);
};
@@ -500,14 +506,15 @@ export class LegacyFSController extends PuterController {
child.suggestedApps = rootSuggestions[index] ?? [];
}
}
const rootAppsById = await loadLegacyAssociatedApps(
this.stores.app,
rootChildren,
);
const [rootAppsById, rootShareFlags] = await Promise.all([
loadLegacyAssociatedApps(this.stores.app, rootChildren),
this.services.share.shareFlags(actor, rootChildren),
]);
const shaped = await Promise.all(
rootChildren.map((c) =>
toLegacyEntry(this.clients.event, c, {
appsById: rootAppsById,
isShared: rootShareFlags.get(c.uuid) ?? null,
}),
),
);
@@ -581,14 +588,17 @@ export class LegacyFSController extends PuterController {
}
}
const appsById = await loadLegacyAssociatedApps(
this.stores.app,
children,
);
const [appsById, shareFlags] = await Promise.all([
loadLegacyAssociatedApps(this.stores.app, children),
this.services.share.shareFlags(actor, children),
]);
const shaped = await Promise.all(
children.map((c) =>
toLegacyEntry(this.clients.event, c, { appsById }),
toLegacyEntry(this.clients.event, c, {
appsById,
isShared: shareFlags.get(c.uuid) ?? null,
}),
),
);
@@ -449,6 +449,8 @@ export async function toLegacyEntry(
getById: (id: number) => Promise<Record<string, unknown> | null>;
};
appsById?: Map<number, Record<string, unknown>>;
/** Omitted from the response when undefined. */
isShared?: boolean | null;
} = {},
): Promise<Record<string, unknown>> {
// Someone else's entry is published under its masked path; the owner's
@@ -496,6 +498,7 @@ export async function toLegacyEntry(
? (opts.appsById.get(entry.associatedAppId) ?? null)
: null,
appdata_app,
...(opts.isShared === undefined ? {} : { is_shared: opts.isShared }),
};
// `is_empty` — only meaningful for directories. Single-row probe so we
@@ -177,6 +177,7 @@ export interface ClientFSEntry {
workers: FSEntrySubdomain[];
hasWebsite: boolean;
suggestedApps: unknown[];
isShared?: boolean | null;
id?: never;
userId?: never;
@@ -27,7 +27,7 @@ import type {
ShareTarget,
} from '../../services/share/ShareService.js';
import { expandTildePath } from '../../services/fs/resolveNode.js';
import { signEntryThumbnail } from '../fs/legacyFsHelpers.js';
import { toClientShare } from './clientShare.js';
import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
import { normalizeLimit } from '../../util/pagination.js';
import { PuterController } from '../types.js';
@@ -443,47 +443,8 @@ export class ShareController extends PuterController {
return username;
}
/**
* Only ever the username never the internal id, and never an email the
* caller didn't already supply.
*
* `thumbnail` is stored as an `s3://bucket/key` URI, so it is swapped for a
* signed URL rather than emitted: the raw value names internal storage and
* no client can render it.
*/
async #toClientShare(share: ResolvedShare) {
const thumbnail =
share.thumbnail === undefined
? undefined
: await signEntryThumbnail(
this.clients.event,
share.entryUid,
share.thumbnail,
);
return {
uid: share.uid,
mode: share.mode,
path: share.path,
// A share listing has no fsentry behind it for a client to stat.
...(share.name === undefined ? {} : { name: share.name }),
...(share.type === undefined ? {} : { type: share.type }),
...(thumbnail === undefined ? {} : { thumbnail }),
...(share.owner === undefined
? {}
: { owner: share.owner.username }),
...(share.pending
? { pending: true, recipient_email: share.recipientEmail }
: {}),
uid_entry: share.entryUid,
is_dir: share.isDir,
issuer: share.issuer.username,
holder: share.holder.username,
created_at: share.createdAt,
issued_by_app: share.issuedByApp ?? null,
inherited_from: share.inheritedFrom ?? null,
modified: share.modified,
size: share.size,
};
#toClientShare(share: ResolvedShare) {
return toClientShare(this.clients.event, share);
}
#requireActor(req: Request): Actor {
@@ -0,0 +1,84 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* 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.
*
* 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/>.
*/
import type { Actor } from '../../core/actor.js';
import type { EventClient } from '../../clients/event/EventClient.js';
import type {
ResolvedShare,
ShareService,
} from '../../services/share/ShareService.js';
import { signEntryThumbnail } from '../fs/legacyFsHelpers.js';
/**
* One share on the wire: only ever the username never the internal id, and
* never an email the caller didn't already supply.
*
* `thumbnail` is stored as an `s3://bucket/key` URI, so it is swapped for a
* signed URL rather than emitted: the raw value names internal storage and no
* client can render it.
*/
export async function toClientShare(
eventClient: EventClient | undefined,
share: ResolvedShare,
) {
const thumbnail =
share.thumbnail === undefined
? undefined
: await signEntryThumbnail(
eventClient,
share.entryUid,
share.thumbnail,
);
return {
uid: share.uid,
mode: share.mode,
path: share.path,
// A share listing has no fsentry behind it for a client to stat.
...(share.name === undefined ? {} : { name: share.name }),
...(share.type === undefined ? {} : { type: share.type }),
...(thumbnail === undefined ? {} : { thumbnail }),
...(share.owner === undefined ? {} : { owner: share.owner.username }),
...(share.pending
? { pending: true, recipient_email: share.recipientEmail }
: {}),
uid_entry: share.entryUid,
is_dir: share.isDir,
issuer: share.issuer.username,
holder: share.holder.username,
created_at: share.createdAt,
issued_by_app: share.issuedByApp ?? null,
inherited_from: share.inheritedFrom ?? null,
modified: share.modified,
size: share.size,
};
}
/** Who can reach `uid`, or empty when the caller may not manage it. */
export async function listClientShares(
shareService: ShareService,
eventClient: EventClient | undefined,
actor: Actor,
uid: string,
) {
const shares = await shareService.tryListSharesOf(actor, { uid });
if (shares === null) return [];
return Promise.all(
shares.map((share) => toClientShare(eventClient, share)),
);
}