From ee2f14576be5314f1e384ba3febcad2bff9ae051 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 25 Aug 2026 17:44:41 -0400 Subject: [PATCH] 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. --- .../controllers/fs/FSController.http.test.ts | 127 ++++++++++++++++++ src/backend/controllers/fs/FSController.ts | 61 ++++++--- .../controllers/fs/LegacyFSController.test.ts | 79 +++++++++++ .../controllers/fs/LegacyFSController.ts | 50 ++++--- src/backend/controllers/fs/legacyFsHelpers.ts | 3 + src/backend/controllers/fs/requestTypes.ts | 1 + .../controllers/share/ShareController.ts | 45 +------ src/backend/controllers/share/clientShare.ts | 84 ++++++++++++ 8 files changed, 367 insertions(+), 83 deletions(-) create mode 100644 src/backend/controllers/share/clientShare.ts diff --git a/src/backend/controllers/fs/FSController.http.test.ts b/src/backend/controllers/fs/FSController.http.test.ts index f85b4aed8..1320b852f 100644 --- a/src/backend/controllers/fs/FSController.http.test.ts +++ b/src/backend/controllers/fs/FSController.http.test.ts @@ -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>; + }; + + 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>; + 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([]); + }); + }); }); diff --git a/src/backend/controllers/fs/FSController.ts b/src/backend/controllers/fs/FSController.ts index f07c0b609..a42c6a25f 100644 --- a/src/backend/controllers/fs/FSController.ts +++ b/src/backend/controllers/fs/FSController.ts @@ -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 { - const appsById = await loadLegacyAssociatedApps( - this.stores.app, - entries, - ); + async #toReaddirEntries( + actor: Actor, + entries: FSEntry[], + ): Promise { + 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 - | undefined; + Record | undefined; const candidates = [ this.#toStorageCapacityCandidate(actorUser?.free_storage), diff --git a/src/backend/controllers/fs/LegacyFSController.test.ts b/src/backend/controllers/fs/LegacyFSController.test.ts index fc58d9fd3..bf0b652b8 100644 --- a/src/backend/controllers/fs/LegacyFSController.test.ts +++ b/src/backend/controllers/fs/LegacyFSController.test.ts @@ -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; + 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(); diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index 12c7025d5..6540fd832 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -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 | 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, + }), ), ); diff --git a/src/backend/controllers/fs/legacyFsHelpers.ts b/src/backend/controllers/fs/legacyFsHelpers.ts index 1f8a36aaa..1a4064ec4 100644 --- a/src/backend/controllers/fs/legacyFsHelpers.ts +++ b/src/backend/controllers/fs/legacyFsHelpers.ts @@ -449,6 +449,8 @@ export async function toLegacyEntry( getById: (id: number) => Promise | null>; }; appsById?: Map>; + /** Omitted from the response when undefined. */ + isShared?: boolean | null; } = {}, ): Promise> { // 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 diff --git a/src/backend/controllers/fs/requestTypes.ts b/src/backend/controllers/fs/requestTypes.ts index c245a8160..819098990 100644 --- a/src/backend/controllers/fs/requestTypes.ts +++ b/src/backend/controllers/fs/requestTypes.ts @@ -177,6 +177,7 @@ export interface ClientFSEntry { workers: FSEntrySubdomain[]; hasWebsite: boolean; suggestedApps: unknown[]; + isShared?: boolean | null; id?: never; userId?: never; diff --git a/src/backend/controllers/share/ShareController.ts b/src/backend/controllers/share/ShareController.ts index 45f5da79f..8b2d2c441 100644 --- a/src/backend/controllers/share/ShareController.ts +++ b/src/backend/controllers/share/ShareController.ts @@ -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 { diff --git a/src/backend/controllers/share/clientShare.ts b/src/backend/controllers/share/clientShare.ts new file mode 100644 index 000000000..e7eb9fe58 --- /dev/null +++ b/src/backend/controllers/share/clientShare.ts @@ -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 . + */ + +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)), + ); +}