diff --git a/src/backend/clients/database/SqliteDatabaseClient.test.ts b/src/backend/clients/database/SqliteDatabaseClient.test.ts index 724271576..ab968e267 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.test.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.test.ts @@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js'; import { SqliteDatabaseClient } from './SqliteDatabaseClient.js'; /** Highest schema version the migration table can reach. */ -const CURRENT_SCHEMA_VERSION = 66; +const CURRENT_SCHEMA_VERSION = 67; /** * These suites migrate real files on disk. Idle they finish in well under a diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts index cd29500e1..87354ad94 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.ts @@ -100,6 +100,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [ [63, ['0068_referral-code-unique.sql']], [64, ['0069_user-block.sql']], [65, ['0070_drop-orphaned-default-groups.sql']], + [66, ['0071_share_issuer_index.sql']], ]; export class SqliteDatabaseClient extends AbstractDatabaseClient { diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_14.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_14.sql new file mode 100644 index 000000000..43adeca38 --- /dev/null +++ b/src/backend/clients/database/migrations/postgres/postgres_mig_14.sql @@ -0,0 +1,23 @@ +-- 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 . + +-- "Shared by me" pages by keyset: issuer equality, then `id` as the cursor. +-- The single-column issuer index (postgres_mig_1) serves the equality but not +-- the ordering; this composite lets the issued half of that listing range-scan +-- without a sort. +CREATE INDEX IF NOT EXISTS idx_share_issuer + ON share (issuer_user_id, id); diff --git a/src/backend/clients/database/migrations/sqlite/0071_share_issuer_index.sql b/src/backend/clients/database/migrations/sqlite/0071_share_issuer_index.sql new file mode 100644 index 000000000..8c5ccf2eb --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0071_share_issuer_index.sql @@ -0,0 +1,23 @@ +-- 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 . + +-- "Shared by me" pages by keyset: issuer equality, then `id` as the cursor. +-- The issuer foreign key created no index here (unlike mysql, where the FK's +-- index implicitly ends in the primary key), so without this the issued half +-- of that listing walks the whole table. +CREATE INDEX IF NOT EXISTS `idx_share_issuer` + ON `share` (`issuer_user_id`, `id`); diff --git a/src/backend/controllers/share/ShareController.http.test.ts b/src/backend/controllers/share/ShareController.http.test.ts index 7257fc727..1756cdfcd 100644 --- a/src/backend/controllers/share/ShareController.http.test.ts +++ b/src/backend/controllers/share/ShareController.http.test.ts @@ -18,7 +18,11 @@ */ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; +import { + createTestUser, + setupPuterTestEnv, + type PuterTestEnv, +} from '../../testUtil.js'; import { ShareController } from './ShareController.js'; /** @@ -191,6 +195,161 @@ describe('share endpoints over HTTP', () => { expect(after.items.find((i) => i.uid_entry === file.uid)).toBeUndefined(); }); + describe('GET /share/shared-by-me', () => { + /** Fresh accounts: the shared ones accumulate shares across this file. */ + const makeUser = async () => { + const username = `sbm${Math.random().toString(36).slice(2, 9)}`; + return createTestUser(env.server, { + username, + password: 'puter-test-user-password', + }); + }; + + it('lists what the caller shared out, across unrelated items', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const stranger = await makeUser(); + const files = [await makeFile(owner), await makeFile(owner)]; + + for (const file of files) { + const res = await post('/share', owner.token, { + recipients: [recipient.username], + items: [{ uid: file.uid }], + mode: 'read', + }); + expect(res.status).toBe(200); + } + await post('/share', stranger.token, { + recipients: [recipient.username], + items: [{ uid: (await makeFile(stranger)).uid }], + mode: 'read', + }); + + const res = await get('/share/shared-by-me', owner.token, { + includeTotal: 'true', + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + items: Array>; + total?: number; + }; + + expect(body.items.map((i) => i.uid_entry).sort()).toEqual( + files.map((f) => f.uid).sort(), + ); + expect(body.total).toBe(files.length); + for (const item of body.items) { + expect(item.holder).toBe(recipient.username); + expect(item.issuer).toBe(owner.username); + expect(item.issued_by_app).toBeNull(); + for (const key of [ + 'issuer_user_id', + 'holder_user_id', + 'fsentry_id', + 'recipient_email', + ]) { + expect(item).not.toHaveProperty(key); + } + } + + // Another account's shares are not the caller's business, whoever + // they went to. + const strangersView = (await ( + await get('/share/shared-by-me', stranger.token, {}) + ).json()) as { items: Array> }; + for (const file of files) { + expect( + strangersView.items.find((i) => i.uid_entry === file.uid), + ).toBeUndefined(); + } + }); + + it('pages with a cursor and stops when there is none', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const files = [ + await makeFile(owner), + await makeFile(owner), + await makeFile(owner), + ]; + for (const file of files) { + await post('/share', owner.token, { + recipients: [recipient.username], + items: [{ uid: file.uid }], + mode: 'read', + }); + } + + const seen: string[] = []; + let cursor: string | undefined; + for (let page = 0; page < 5; page++) { + const params: Record = { limit: '1' }; + if (cursor) params.cursor = cursor; + const body = (await ( + await get('/share/shared-by-me', owner.token, params) + ).json()) as { + items: Array<{ uid_entry: string }>; + cursor?: string; + }; + seen.push(...body.items.map((i) => i.uid_entry)); + cursor = body.cursor; + if (!cursor) break; + } + + expect(cursor).toBeUndefined(); + expect(seen.sort()).toEqual(files.map((f) => f.uid).sort()); + }); + + it('does not leak another user\'s rows when their cursor is replayed', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const attacker = await makeUser(); + const files = [await makeFile(owner), await makeFile(owner)]; + for (const file of files) { + await post('/share', owner.token, { + recipients: [recipient.username], + items: [{ uid: file.uid }], + mode: 'read', + }); + } + + // A cursor minted while the owner pages their own listing. + const first = (await ( + await get('/share/shared-by-me', owner.token, { limit: '1' }) + ).json()) as { items: Array<{ uid_entry: string }>; cursor?: string }; + expect(first.cursor).toBeDefined(); + + // The attacker has shared nothing. Replaying the owner's cursor as + // themselves must still bind to the attacker's own rows, not the + // owner's — the query scopes on the caller's id, and the cursor is + // only a resume position within that scope. + const replayed = (await ( + await get('/share/shared-by-me', attacker.token, { + cursor: first.cursor!, + }) + ).json()) as { items: Array<{ uid_entry: string }> }; + + expect(replayed.items).toEqual([]); + }); + + // The two directions of one listing; a gate on only one of them is a + // hole in whichever was forgotten. + it('is gated like the inbound listing', () => { + const proto = ShareController.prototype as { + __puterRoutes?: Array<{ + method: string; + path: string; + options?: Record; + }>; + }; + const routes = proto.__puterRoutes ?? []; + const inbound = routes.find((r) => r.path === '/shared-with-me'); + const outbound = routes.find((r) => r.path === '/shared-by-me'); + expect(outbound?.method.toLowerCase()).toBe('get'); + expect(outbound?.options).toEqual(inbound?.options); + }); + }); + it('revokes every item in the request, not just the first', async () => { const owner = env.users.user; const recipient = env.users.other; diff --git a/src/backend/controllers/share/ShareController.ts b/src/backend/controllers/share/ShareController.ts index 0420cac59..fa16c6cae 100644 --- a/src/backend/controllers/share/ShareController.ts +++ b/src/backend/controllers/share/ShareController.ts @@ -265,10 +265,48 @@ export class ShareController extends PuterController { rateLimit: SHARE_LIST_LIMIT, }) async listSharedWithMe(req: Request, res: Response): Promise { + await this.#listSharePage(req, res, (actor, opts) => + this.services.share.listSharedWithMe(actor, opts), + ); + } + + /** + * GET /share/shared-by-me — paginated listing of everything the caller has + * shared out. `GET /share/shares` answers this for one item at a time, + * which cannot answer it at all for a caller who doesn't know what to ask + * about. + */ + @Get('/shared-by-me', { + subdomain: 'api', + requireVerified: true, + rateLimit: SHARE_LIST_LIMIT, + }) + async listSharedByMe(req: Request, res: Response): Promise { + await this.#listSharePage(req, res, (actor, opts) => + this.services.share.listSharedByMe(actor, opts), + ); + } + + /** + * Parse-and-shape shared by the paginated share listings, so the two cannot + * drift: same query contract in, same envelope out. + */ + async #listSharePage( + req: Request, + res: Response, + list: ( + actor: Actor, + opts: { limit?: number; cursor?: string; includeTotal?: boolean }, + ) => Promise<{ + items: ResolvedShare[]; + cursor?: string; + total?: number; + }>, + ): Promise { const actor = this.#requireActor(req); const query = this.#query(req); - const page = await this.services.share.listSharedWithMe(actor, { + const page = await list(actor, { limit: normalizeLimit(query.limit, { cap: LIST_LIMIT_CAP }), cursor: typeof query.cursor === 'string' ? query.cursor : undefined, includeTotal: query.includeTotal === 'true', diff --git a/src/backend/services/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts index 862499b59..baf1fdd77 100644 --- a/src/backend/services/share/ShareService.test.ts +++ b/src/backend/services/share/ShareService.test.ts @@ -585,6 +585,423 @@ describe('ShareService', () => { }); }); + describe('the outbound listing', () => { + const listSharedByMe = ( + actor: Actor, + opts?: { limit?: number; cursor?: string; includeTotal?: boolean }, + ) => + runWithContext({ actor }, () => + server.services.share.listSharedByMe(actor, opts), + ); + + it('gathers shares on unrelated items into one listing', async () => { + const owner = await makeUser(); + const first = await makeUser(); + const second = await makeUser(); + const files = [ + await makeFile(owner.user), + await makeFile(owner.user), + await makeFile(owner.user), + ]; + + for (const [index, file] of files.entries()) { + await share(owner.actor, { + uid: file.uuid, + recipient: { + email: index === 2 ? second.email : first.email, + }, + mode: 'read', + }); + } + + const listed = await listSharedByMe(owner.actor); + expect(listed.items.map((i) => i.entryUid).sort()).toEqual( + files.map((f) => f.uuid).sort(), + ); + // The caller owns these, so the paths are their own. + expect(listed.items.map((i) => i.path).sort()).toEqual( + files.map((f) => f.path).sort(), + ); + expect(listed.items.map((i) => i.holder.username).sort()).toEqual( + [ + first.user.username, + first.user.username, + second.user.username, + ].sort(), + ); + }); + + it('shows the owner what a manage delegate shared from their item', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const third = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: delegate.email }, + mode: 'manage', + }); + await share(delegate.actor, { + uid: file.uuid, + recipient: { email: third.email }, + mode: 'read', + }); + + const listed = await listSharedByMe(owner.actor); + const byHolder = new Map( + listed.items.map((i) => [i.holder.username, i]), + ); + expect([...byHolder.keys()].sort()).toEqual( + [delegate.user.username, third.user.username].sort(), + ); + expect(byHolder.get(third.user.username)?.issuer.username).toBe( + delegate.user.username, + ); + expect(byHolder.get(third.user.username)?.entryUid).toBe(file.uuid); + }); + + it('masks the owner path on a share the caller issued as a delegate', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const third = await makeUser(); + const file = await makeFile(owner.user); + const unrelated = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: delegate.email }, + mode: 'manage', + }); + await share(owner.actor, { + uid: unrelated.uuid, + recipient: { email: third.email }, + mode: 'read', + }); + await share(delegate.actor, { + uid: file.uuid, + recipient: { email: third.email }, + mode: 'read', + }); + + // Only what the delegate handed out — the owner's own share of an + // item they never touched is not theirs to see. + const listed = await listSharedByMe(delegate.actor); + expect(listed.items).toHaveLength(1); + expect(listed.items[0].entryUid).toBe(file.uuid); + expect(listed.items[0].path).toBe( + `/${owner.user.username}/${file.uuid}/${file.name}`, + ); + expect(listed.items[0].owner?.username).toBe( + owner.user.username, + ); + }); + + it('drops a delegate-issued share from both listings once revoked, never from the recipient\'s', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: delegate.email }, + mode: 'manage', + }); + await share(delegate.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + const heldByRecipient = ( + items: Array<{ holder: { username: string | null } }>, + ) => items.some((i) => i.holder.username === recipient.user.username); + + expect( + heldByRecipient((await listSharedByMe(owner.actor)).items), + ).toBe(true); + expect( + (await listSharedByMe(delegate.actor)).items.map( + (i) => i.entryUid, + ), + ).toEqual([file.uuid]); + // The recipient only holds the share; that is inbound for them, + // not something they issued or own the node for. + expect((await listSharedByMe(recipient.actor)).items).toEqual([]); + + await unshare(delegate.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + }); + + expect( + heldByRecipient((await listSharedByMe(owner.actor)).items), + ).toBe(false); + expect((await listSharedByMe(delegate.actor)).items).toEqual([]); + }); + + it('never lists a share another user made', async () => { + const owner = await makeUser(); + const other = await makeUser(); + const recipient = await makeUser(); + const ownersFile = await makeFile(owner.user); + const othersFile = await makeFile(other.user); + + await share(owner.actor, { + uid: ownersFile.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + await share(other.actor, { + uid: othersFile.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + const listed = await listSharedByMe(other.actor); + expect(listed.items.map((i) => i.entryUid)).toEqual([ + othersFile.uuid, + ]); + }); + + it('drops a share once it is revoked', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + expect( + (await listSharedByMe(owner.actor)).items.map( + (i) => i.entryUid, + ), + ).toEqual([file.uuid]); + + await unshare(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + }); + expect((await listSharedByMe(owner.actor)).items).toEqual([]); + }); + + it('is an empty page for someone who has shared nothing', async () => { + const nobody = await makeUser(); + const listed = await listSharedByMe(nobody.actor, { + includeTotal: true, + }); + expect(listed.items).toEqual([]); + expect(listed.cursor).toBeUndefined(); + expect(listed.total).toBe(0); + }); + + it('carries an unclaimed invite as pending', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + const email = `invitee-${Math.random() + .toString(36) + .slice(2, 8)}@test.local`; + + await share(owner.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + + const listed = await listSharedByMe(owner.actor); + expect(listed.items).toHaveLength(1); + expect(listed.items[0].pending).toBe(true); + expect(listed.items[0].recipientEmail).toBe(email); + expect(listed.items[0].holder.username).toBeNull(); + }); + + it("drops only the withdrawn issuer's row when another grant keeps the holder reachable", async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const holder = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: delegate.email }, + mode: 'manage', + }); + await share(owner.actor, { + uid: file.uuid, + recipient: { email: holder.email }, + mode: 'read', + }); + await share(delegate.actor, { + uid: file.uuid, + recipient: { email: holder.email }, + mode: 'read', + }); + + // The owner's grant goes through the permission API and the index + // row is left behind (what `onGrantRevoked` cannot fix when it + // can't name the issuer). The delegate's grant still reaches the + // holder — that must not keep the owner's dead row listed. + await runWithContext({ actor: owner.actor }, () => + server.services.permission.revokeUserUserPermission( + owner.actor, + holder.user.username!, + `fs:${file.uuid}:read`, + ), + ); + + const listed = await listSharedByMe(owner.actor); + const pairs = listed.items + .map((i) => `${i.issuer.username}>${i.holder.username}`) + .sort(); + expect(pairs).toEqual( + [ + `${owner.user.username}>${delegate.user.username}`, + `${delegate.user.username}>${holder.user.username}`, + ].sort(), + ); + }); + + it("takes a revoked delegate's unclaimed invites with them", async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const file = await makeFile(owner.user); + const email = `orphan-${Math.random() + .toString(36) + .slice(2, 8)}@test.local`; + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: delegate.email }, + mode: 'manage', + }); + await share(delegate.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + expect( + (await listSharedByMe(delegate.actor)).items.some( + (i) => i.pending, + ), + ).toBe(true); + + await unshare(owner.actor, { + uid: file.uuid, + recipient: { email: delegate.email }, + }); + + // The row itself is gone, not just hidden: nothing else would + // ever retire it. + expect(await server.stores.share.listPendingByEmail(email)).toEqual( + [], + ); + expect((await listSharedByMe(delegate.actor)).items).toEqual([]); + }); + + it('hides an invite whose issuer lost their authority outside unshare', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const file = await makeFile(owner.user); + const email = `stale-${Math.random() + .toString(36) + .slice(2, 8)}@test.local`; + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: delegate.email }, + mode: 'manage', + }); + await share(delegate.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + + // Withdrawn through the permission API: no share-row cleanup runs, + // but the listing must not keep publishing the entry to an issuer + // whose access is gone. + await runWithContext({ actor: owner.actor }, () => + server.services.permission.revokeUserUserPermission( + owner.actor, + delegate.user.username!, + `manage:fs:${file.uuid}`, + ), + ); + + expect( + await server.stores.share.listPendingByEmail(email), + ).toHaveLength(1); + expect((await listSharedByMe(delegate.actor)).items).toEqual([]); + expect( + (await listSharedByMe(owner.actor)).items.some( + (i) => i.pending, + ), + ).toBe(false); + }); + + it('reads the app off an invite recorded under the legacy key', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + const email = `legacy-${Math.random() + .toString(36) + .slice(2, 8)}@test.local`; + + await share(owner.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + const [row] = await server.stores.share.listPendingByEmail(email); + await server.clients.db.write( + 'UPDATE `share` SET `data` = ? WHERE `uid` = ?', + [JSON.stringify({ issuerAppUid: 'app-legacy' }), row.uid], + ); + + const listed = await listSharedByMe(owner.actor); + expect(listed.items).toHaveLength(1); + expect(listed.items[0].issuedByApp).toBe('app-legacy'); + }); + + it('walks every page through the cursor', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const files = [ + await makeFile(owner.user), + await makeFile(owner.user), + await makeFile(owner.user), + ]; + for (const file of files) { + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + } + + const seen: string[] = []; + let cursor: string | undefined; + let total: number | undefined; + for (let page = 0; page < 5; page++) { + const listed = await listSharedByMe(owner.actor, { + limit: 1, + cursor, + includeTotal: cursor === undefined, + }); + seen.push(...listed.items.map((i) => i.entryUid)); + total ??= listed.total; + cursor = listed.cursor; + if (!cursor) break; + } + + expect(cursor).toBeUndefined(); + expect(seen.sort()).toEqual(files.map((f) => f.uuid).sort()); + expect(total).toBe(files.length); + }); + }); + it('takes downstream access with a delegate who leaves', async () => { const owner = await makeUser(); const delegate = await makeUser(); diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index f036a3c6c..e766e0693 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -21,6 +21,7 @@ import { contentType as contentTypeFromMime } from 'mime-types'; import { posix as pathPosix } from 'node:path'; import { userRelatedActor, type Actor } from '../../core/actor'; import { HttpError, isHttpError } from '../../core/http/HttpError.js'; +import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js'; import { isUniqueViolation } from '../../util/dbError.js'; import { abuseKey, @@ -62,11 +63,30 @@ interface ShareIndexRow { data?: unknown; } +/** + * As above, from a listing that carries unclaimed invites: those have no + * holder. + */ +interface OutboundShareRow extends Omit { + holder_user_id: number | null; + recipient_email?: string; +} + export interface ShareInput extends ShareTarget { recipient: ShareRecipient; mode: AclMode; } +/** What still backs one (holder, entry) pair; see `#grantEvidence`. */ +interface GrantEvidence { + /** Issuers with a live, attributable grant. */ + issuers: Set; + /** A live grant that names no issuer — a legacy flat entry. */ + unattributed: boolean; + /** The holder owns the entry outright, so no grant is needed. */ + owned: boolean; +} + /** One live share, resolved for a response. */ export interface ResolvedShare { uid: string; @@ -119,9 +139,17 @@ const SHAREABLE_MODES: ReadonlySet = new Set([ * Every permission a share of one node can rest on. `manage` is spelled with * the prefix leading, so a prefix match on `fs:` does not reach it. */ -/** The app recorded on a share row, when one issued it. */ +/** + * The app recorded on a share row, when one issued it. Two spellings in the + * wild: pending rows were written with `issuerAppUid` before the keys were + * unified on `issuedByApp`, and claiming carries `data` forward verbatim. + */ const issuedByApp = (row: { data?: unknown }): string | null => { - const value = (row.data as { issuedByApp?: unknown } | null)?.issuedByApp; + const data = row.data as { + issuedByApp?: unknown; + issuerAppUid?: unknown; + } | null; + const value = data?.issuedByApp ?? data?.issuerAppUid; return typeof value === 'string' && value !== '' ? value : null; }; @@ -898,77 +926,188 @@ export class ShareService extends PuterService { } /** - * Of `entries`, the uuids `holderId` still holds a live grant on. + * What still backs each (holder, entry) pair: the issuers with a live + * grant, whether a live grant exists that names no issuer (legacy flat + * entries), and whether the holder now owns the entry outright (a move + * handed the tree over — no grant row, held all the same). * * The index is not proof of access: a grant can be withdrawn or downgraded * by a path that never touches a share row (an ACL mode change, say), and - * this listing publishes name, size and a signed thumbnail URL — so it has - * to be checked against the grants themselves. + * the listings publish name, size and a signed thumbnail URL — so rows are + * checked against the grants themselves. * - * Two batched reads for the whole page, both keyed on the holder: the - * linked rows are indexed on `holder_user_id`, and the flat view is a - * multi-get. An ACL walk per row would be the same answer at hundreds of - * times the cost. + * Two batched reads for all pairs together, however many holders: the + * linked rows by `holder IN … AND permission IN …`, the flat view as one + * multi-get. Reading per holder here turned a full outbound page into + * hundreds of queries on a cold cache. */ + async #grantEvidence( + pairs: Array<{ holderId: number; entry: FSEntry }>, + ): Promise> { + const evidence = new Map(); + const unique = new Map(); + for (const pair of pairs) { + unique.set(`${pair.holderId}:${pair.entry.id}`, pair); + } + if (unique.size === 0) return evidence; + + const refs = [...unique.values()].flatMap(({ holderId, entry }) => + entryPermissions(entry.uuid).map((permission) => ({ + holderUserId: holderId, + permission, + })), + ); + const [linked, flat] = await Promise.all([ + this.stores.permission.readLinkedUserUserPermsForHolders( + refs.map((ref) => ref.holderUserId), + refs.map((ref) => ref.permission), + ), + this.stores.permission.getFlatUserPermsForRefs(refs), + ]); + + // Both reads folded onto (holder, permission); the linked read spans + // every holder's permissions, so it can return pairs never asked for — + // they simply go unread below. + const byHolderPerm = new Map< + string, + { issuers: Set; unattributed: boolean } + >(); + const record = ( + holderId: number, + permission: string, + issuer: unknown, + ) => { + const key = `${holderId}:${permission}`; + const found = byHolderPerm.get(key) ?? { + issuers: new Set(), + unattributed: false, + }; + const issuerId = Number(issuer); + if (Number.isFinite(issuerId)) found.issuers.add(issuerId); + else found.unattributed = true; + byHolderPerm.set(key, found); + }; + for (const row of linked) { + record( + Number(row.holder_user_id), + row.permission, + row.issuer_user_id, + ); + } + for (const { ref, value } of flat) { + if (value.deleted) continue; + record(ref.holderUserId, ref.permission, value.issuer_user_id); + } + + for (const [key, { holderId, entry }] of unique) { + const merged: GrantEvidence = { + issuers: new Set(), + unattributed: false, + owned: entry.userId === holderId, + }; + for (const permission of entryPermissions(entry.uuid)) { + const found = byHolderPerm.get(`${holderId}:${permission}`); + if (!found) continue; + for (const issuer of found.issuers) merged.issuers.add(issuer); + merged.unattributed ||= found.unattributed; + } + evidence.set(key, merged); + } + return evidence; + } + + /** Of `entries`, the uuids `holderId` still holds a live grant on. */ async #liveGrants( holderId: number, entries: FSEntry[], ): Promise> { - if (entries.length === 0) return new Set(); - const wanted = entries.flatMap((entry) => entryPermissions(entry.uuid)); - const [linked, flat] = await Promise.all([ - this.stores.permission.readLinkedUserUserPerms(holderId, wanted), - this.stores.permission.getFlatUserPerms(holderId, wanted), - ]); - + const evidence = await this.#grantEvidence( + entries.map((entry) => ({ holderId, entry })), + ); const live = new Set(); - for (const row of linked) { - const uuid = uuidFromEntryPermission(row.permission); - if (uuid) live.add(uuid); - } - // Not positional against `wanted`: misses are dropped, keys deduped. - for (const value of flat) { - if (!value?.permission || value.deleted) continue; - const uuid = uuidFromEntryPermission(value.permission); - if (uuid) live.add(uuid); - } - // An owner listing something shared *to* them can't happen, but a - // recipient who has since become the owner (a move handed the tree - // over) holds it outright and has no grant row. for (const entry of entries) { - if (entry.userId === holderId) live.add(entry.uuid); + const found = evidence.get(`${holderId}:${entry.id}`); + if (!found) continue; + if (found.owned || found.unattributed || found.issuers.size > 0) { + live.add(entry.uuid); + } } return live; } - /** The `:` pairs whose grant is still standing. */ + /** The (holder, node) pairs `rows` name that both sides resolve for. */ + #rowPairs( + rows: Array<{ holder_user_id: number | null; fsentry_id: number }>, + nodeById: Map, + ): Array<{ holderId: number; entry: FSEntry }> { + const pairs: Array<{ holderId: number; entry: FSEntry }> = []; + for (const row of rows) { + // `Number(null)` is 0, so a pending row must not slip through as + // holder 0. + if (!row.holder_user_id) continue; + const holderId = Number(row.holder_user_id); + const node = nodeById.get(Number(row.fsentry_id)); + if (!node || !Number.isFinite(holderId)) continue; + pairs.push({ holderId, entry: node }); + } + return pairs; + } + + /** + * The `:` pairs some grant still backs, whoever issued + * it. The right bound for fan-out: a holder is reachable through anyone's + * grant. + */ async #reachingHolders( rows: ShareIndexRow[], nodeById: Map, ): Promise> { - const nodesByHolder = new Map>(); + const evidence = await this.#grantEvidence( + this.#rowPairs(rows, nodeById), + ); + const live = new Set(); + for (const [key, found] of evidence) { + if (found.owned || found.unattributed || found.issuers.size > 0) { + live.add(key); + } + } + return live; + } + + /** + * The `::` triples whose own grant still + * stands. Sharper than `#reachingHolders`, and what the listings need: on + * the pair alone, an issuer whose grant was withdrawn outside `unshare` is + * shown a dead share for as long as anyone else still grants the same + * holder the same node. A grant that names no issuer backs every issuer's + * row for its pair, but only while no attributable grant exists — once any + * does, the attributed set is the answer. + */ + async #reachingGrants( + rows: Array<{ + holder_user_id: number | null; + issuer_user_id: number; + fsentry_id: number; + }>, + nodeById: Map, + ): Promise> { + const evidence = await this.#grantEvidence( + this.#rowPairs(rows, nodeById), + ); + const live = new Set(); for (const row of rows) { const holderId = Number(row.holder_user_id); const node = nodeById.get(Number(row.fsentry_id)); if (!node || !Number.isFinite(holderId)) continue; - const nodes = nodesByHolder.get(holderId) ?? new Map(); - nodes.set(node.id as number, node); - nodesByHolder.set(holderId, nodes); + const found = evidence.get(`${holderId}:${node.id}`); + if (!found) continue; + const issuerId = Number(row.issuer_user_id); + const backed = + found.owned || + found.issuers.has(issuerId) || + (found.unattributed && found.issuers.size === 0); + if (backed) live.add(`${holderId}:${node.id}:${issuerId}`); } - - const live = new Set(); - await Promise.all( - [...nodesByHolder].map(async ([holderId, nodes]) => { - const uuids = await this.#liveGrants(holderId, [ - ...nodes.values(), - ]); - for (const node of nodes.values()) { - if (uuids.has(node.uuid)) { - live.add(`${holderId}:${node.id}`); - } - } - }), - ); return live; } @@ -1081,6 +1220,15 @@ export class ShareService extends PuterService { if (seen.has(issuerId)) return 0; seen.add(issuerId); + // Their unclaimed invites go the same way as their re-shares: an + // invite rests on the same authority, and nothing else retires it — + // claiming re-checks, but only when the recipient shows up, and until + // then the row keeps the entry in the revoked issuer's listing. + await this.stores.share.deletePendingByIssuerSubtree( + issuerId, + entry.id, + ); + // The whole subtree, not just this node: `manage` inherits downwards, // so a grant on a descendant can rest on authority held here. const rows = ( @@ -1293,26 +1441,15 @@ export class ShareService extends PuterService { if (!entry || this.#isTrashed(entry)) continue; if (!live.has(entry.uuid)) continue; if (!reachable.has(entry.uuid)) continue; - const issuer = issuers.get(Number(row.issuer_user_id)); - const owner = issuers.get(Number(entry.userId)); - items.push({ - uid: row.uid, - mode: row.mode, - path: maskEntryPath(entry), - name: entry.name, - type: entry.isDir - ? 'folder' - : contentTypeFromMime(entry.name) || null, - thumbnail: entry.thumbnail ?? null, - entryUid: entry.uuid, - isDir: Boolean(entry.isDir), - owner: { username: owner?.username ?? null }, - issuer: { username: issuer?.username ?? null }, - holder: { username: actor.user.username ?? null }, - createdAt: row.created_at, - modified: entry.modified, - size: entry.size, - }); + items.push( + this.#resolvedShareRow(row, entry, issuers, { + entryMeta: true, + // Which app the sharer went through is their business, not + // the recipient's. + provenance: false, + holderUsername: actor.user.username ?? null, + }), + ); } return { @@ -1324,6 +1461,90 @@ export class ShareService extends PuterService { }; } + /** + * What the caller has shared out, across every item: the shares they + * issued, plus the ones a `manage` delegate issued on a node they own. + * `listSharesOf` answers the same question for a node the caller can + * already name; this is what answers it when they can't. + * + * Trashed items are kept, unlike the inbound listing: the grant on one is + * still standing, and this is where someone comes to find that out. + */ + async listSharedByMe( + actor: Actor, + opts: { limit?: number; cursor?: string; includeTotal?: boolean } = {}, + ): Promise<{ + items: ResolvedShare[]; + cursor?: string; + total?: number; + }> { + const userId = this.#requireUserId(actor); + const page = await this.stores.share.listOutbound(userId, { + limit: opts.limit, + cursor: opts.cursor, + }); + const rows: OutboundShareRow[] = page.items; + + const entries = await this.stores.fsEntry.getEntriesByIds( + rows.map((row) => Number(row.fsentry_id)), + ); + const users = await this.stores.user.getByIds([ + ...rows.flatMap((row) => [ + Number(row.issuer_user_id), + ...(row.holder_user_id ? [Number(row.holder_user_id)] : []), + ]), + ...[...entries.values()].map((entry) => entry.userId), + ]); + + const nodeById = new Map( + [...entries.values()].map((entry) => [entry.id, entry]), + ); + // Three bounds. A claimed row needs the grant *this issuer* made to + // still be there — on the pair alone, a grant withdrawn outside + // `unshare` stays listed while anyone else grants the same holder the + // same node. An invite needs its issuer to still hold the authority it + // would grant, or a revoked delegate keeps reading the entry's name + // and size out of invites that can never be claimed. And an app sees + // only the part of any of it the credential reaches in its own right. + const [stillReaches, pendingAllowed, reachable] = await Promise.all([ + this.#reachingGrants(rows, nodeById), + this.#pendingStillAuthorized( + rows.filter((row) => !row.holder_user_id), + nodeById, + users, + ), + this.#reachableBy(actor, [...entries.values()]), + ]); + + const items: ResolvedShare[] = []; + for (const row of rows) { + const entry = entries.get(Number(row.fsentry_id)); + if (!entry) continue; + if (!reachable.has(entry.uuid)) continue; + const pending = !row.holder_user_id; + if (pending && !pendingAllowed.has(row.uid)) continue; + if ( + !pending && + !stillReaches.has( + `${Number(row.holder_user_id)}:${entry.id}:${Number(row.issuer_user_id)}`, + ) + ) { + continue; + } + items.push( + this.#resolvedShareRow(row, entry, users, { entryMeta: true }), + ); + } + + return { + items, + ...(page.cursor ? { cursor: page.cursor } : {}), + ...(opts.includeTotal + ? { total: await this.stores.share.countOutbound(userId) } + : {}), + }; + } + /** * Who can reach one node. Includes shares a `manage` delegate issued, which * the permission tables alone can't show the owner. @@ -1380,106 +1601,43 @@ export class ShareService extends PuterService { const users = await this.stores.user.getByIds(userIds); const maskedPath = maskEntryPath(entry); - // As in `#liveGrants`: an index row outlives the grant it records. - const stillReaches = await this.#reachingHolders( + // As in `#liveGrants`: an index row outlives the grant it records — + // and it names an issuer, so it is that issuer's grant that has to + // still be there. + const stillReaches = await this.#reachingGrants( [...rows, ...inherited.map((i) => i.row)], nodeById, ); const isLive = (row: ShareIndexRow): boolean => stillReaches.has( - `${Number(row.holder_user_id)}:${Number(row.fsentry_id)}`, + `${Number(row.holder_user_id)}:${Number(row.fsentry_id)}:${Number(row.issuer_user_id)}`, ); + // Every item reports the queried node — a grant on an ancestor is + // still published against the path the caller asked about. const inheritedShares: ResolvedShare[] = inherited .filter(({ row }) => isLive(row)) - .map(({ row, via }) => ({ - uid: String(row.uid), - mode: String(row.mode), - path: maskedPath, - entryUid: entry.uuid, - isDir: Boolean(entry.isDir), - issuer: { - username: - users.get(Number(row.issuer_user_id))?.username ?? null, - }, - holder: { - username: - users.get(Number(row.holder_user_id))?.username ?? null, - }, - createdAt: row.created_at, - issuedByApp: issuedByApp(row), - inheritedFrom: via, - modified: entry.modified, - size: entry.size, - })); + .map(({ row, via }) => + this.#resolvedShareRow(row, entry, users, { + path: maskedPath, + via, + }), + ); const own: ResolvedShare[] = rows .filter(isLive) - .map( - (row: { - uid: string; - mode: string; - issuer_user_id: number; - holder_user_id: number; - created_at: unknown; - data?: unknown; - }): ResolvedShare => ({ - uid: row.uid, - mode: row.mode, + .map((row: OutboundShareRow) => + this.#resolvedShareRow(row, entry, users, { path: maskedPath, - entryUid: entry.uuid, - isDir: Boolean(entry.isDir), - issuer: { - username: - users.get(Number(row.issuer_user_id))?.username ?? - null, - }, - holder: { - username: - users.get(Number(row.holder_user_id))?.username ?? - null, - }, - createdAt: row.created_at, - issuedByApp: issuedByApp(row), - inheritedFrom: null, - modified: entry.modified, - size: entry.size, }), ); // Nobody holds an invite yet, but whoever manages the node needs to // see who was asked, and be able to take it back. const pending: ResolvedShare[] = pendingRows.map( - (row: { - uid: string; - mode: string; - issuer_user_id: number; - recipient_email: string; - created_at: unknown; - data?: unknown; - }): ResolvedShare => ({ - uid: row.uid, - mode: row.mode, - path: maskedPath, - entryUid: entry.uuid, - isDir: Boolean(entry.isDir), - issuer: { - username: - users.get(Number(row.issuer_user_id))?.username ?? null, - }, - holder: { username: null }, - pending: true, - // What the sharer typed, when it differs from the canonical - // form the row is keyed on — that is the address they will - // recognize in the dialog. - recipientEmail: - (row.data as { invitedAddress?: string } | null) - ?.invitedAddress ?? row.recipient_email, - createdAt: row.created_at, - issuedByApp: issuedByApp(row), - inheritedFrom: null, - modified: entry.modified, - size: entry.size, - }), + (row: OutboundShareRow) => + this.#resolvedShareRow(row, entry, users, { + path: maskedPath, + }), ); return inheritedShares.concat(own, pending); @@ -1871,6 +2029,118 @@ export class ShareService extends PuterService { } } + /** + * One index row resolved for a listing. The listings' differences are + * arguments rather than a hand-built literal per call site: `entry` is the + * node the item reports (the row's own node in the flat listings, the + * queried node in `listSharesOf`), `entryMeta` adds what the caller can't + * stat for themselves, `provenance` withholds who-issued-how from a listing + * whose caller it isn't for, and `via` marks access inherited from an + * ancestor. A row with no holder is an unclaimed invite and reports the + * address it was aimed at — the typed form when that differs from the + * canonical one the row is keyed on, since that is what the sharer will + * recognize in a dialog. + */ + #resolvedShareRow( + row: OutboundShareRow, + entry: FSEntry, + users: Map, + opts: { + entryMeta?: boolean; + provenance?: boolean; + holderUsername?: string | null; + via?: string | null; + path?: string; + } = {}, + ): ResolvedShare { + const pending = !row.holder_user_id; + return { + uid: String(row.uid), + mode: String(row.mode), + path: opts.path ?? maskEntryPath(entry), + ...(opts.entryMeta + ? { + name: entry.name, + type: entry.isDir + ? 'folder' + : contentTypeFromMime(entry.name) || null, + thumbnail: entry.thumbnail ?? null, + owner: { + username: + users.get(Number(entry.userId))?.username ?? null, + }, + } + : {}), + entryUid: entry.uuid, + isDir: Boolean(entry.isDir), + issuer: { + username: + users.get(Number(row.issuer_user_id))?.username ?? null, + }, + holder: { + username: + opts.holderUsername !== undefined + ? opts.holderUsername + : pending + ? null + : (users.get(Number(row.holder_user_id))?.username ?? + null), + }, + ...(pending + ? { + pending: true, + recipientEmail: + (row.data as { invitedAddress?: string } | null) + ?.invitedAddress ?? row.recipient_email, + } + : {}), + createdAt: row.created_at, + ...(opts.provenance === false + ? {} + : { + issuedByApp: issuedByApp(row), + inheritedFrom: opts.via ?? null, + }), + modified: entry.modified, + size: entry.size, + }; + } + + /** + * Of `rows` (unclaimed invites), the uids whose issuer still holds the + * authority the invite would grant. A dead invite can never become access — + * claiming re-authorizes — but listed it would keep publishing the entry's + * name and size to an issuer whose own access was revoked. + * + * The owner's invites are theirs by definition; only a delegate's cost a + * check, and those are rare on any page. + */ + async #pendingStillAuthorized( + rows: OutboundShareRow[], + nodeById: Map, + users: Map, + ): Promise> { + const allowed = new Set(); + const toCheck: OutboundShareRow[] = []; + for (const row of rows) { + const entry = nodeById.get(Number(row.fsentry_id)); + const issuer = users.get(Number(row.issuer_user_id)); + if (!entry || !issuer?.username) continue; + if (entry.userId === issuer.id) allowed.add(row.uid); + else toCheck.push(row); + } + await runWithConcurrencyLimitSettled(toCheck, 8, async (row) => { + const entry = nodeById.get(Number(row.fsentry_id)) as FSEntry; + const issuer = users.get(Number(row.issuer_user_id)) as UserRow; + const ok = await this.services.permission.canManagePermission( + this.#actorFor(issuer), + entryPermissionForMode(entry.uuid, String(row.mode)), + ); + if (ok) allowed.add(row.uid); + }); + return allowed; + } + #resolve( row: { uid: string; mode: string; created_at?: unknown }, entry: FSEntry, diff --git a/src/backend/stores/permission/PermissionStore.ts b/src/backend/stores/permission/PermissionStore.ts index 379b1529d..4e5df77fc 100644 --- a/src/backend/stores/permission/PermissionStore.ts +++ b/src/backend/stores/permission/PermissionStore.ts @@ -303,6 +303,69 @@ export class PermissionStore extends PuterStore { return decoded.filter((row) => wanted.has(row.permission)); } + /** + * Batched {@link readLinkedUserUserPerms} across holders: an indexed `holder + * IN … AND permission IN …` read instead of one row-set per holder, which + * on a cold cache fans a listing page out into that many queries. Chunked + * to stay under the dialects' placeholder limits. + */ + async readLinkedUserUserPermsForHolders( + holderUserIds: number[], + permissions: string[], + ): Promise { + const holders = [...new Set(holderUserIds)].filter((id) => + Number.isFinite(id), + ); + const perms = [...new Set(permissions)]; + if (holders.length === 0 || perms.length === 0) return []; + + const rows: LinkedUserUserPermRow[] = []; + for (let h = 0; h < holders.length; h += 200) { + const holderChunk = holders.slice(h, h + 200); + for (let p = 0; p < perms.length; p += 700) { + const permChunk = perms.slice(p, p + 700); + const found = await this.clients.db.read( + 'SELECT * FROM `user_to_user_permissions` WHERE ' + + `\`holder_user_id\` IN (${holderChunk.map(() => '?').join(', ')}) ` + + `AND \`permission\` IN (${permChunk.map(() => '?').join(', ')})`, + [...holderChunk, ...permChunk], + ); + for (const row of found) { + rows.push(this.#decodeExtra(row)); + } + } + } + return rows; + } + + /** + * Batched {@link getFlatUserPerms} across holders — one multi-get for all of + * a listing page's (holder, permission) pairs. Entries come back paired + * with the ref they answer, because a flat value doesn't name its holder. + */ + async getFlatUserPermsForRefs( + refs: FlatPermRef[], + ): Promise> { + if (refs.length === 0) return []; + const keys = refs.map(({ holderUserId, permission }) => + PermissionUtil.join( + PERM_KEY_PREFIX, + String(holderUserId), + permission, + ), + ); + const { res } = await this.stores.kv.get({ key: keys }); + const values = Array.isArray(res) ? res : [res]; + const out: Array<{ ref: FlatPermRef; value: FlatPermValue }> = []; + for (let i = 0; i < refs.length; i++) { + const value = values[i]; + if (value !== null && typeof value === 'object') { + out.push({ ref: refs[i], value: value as FlatPermValue }); + } + } + return out; + } + async upsertUserUserPerm( holderUserId: number, issuerUserId: number, diff --git a/src/backend/stores/share/ShareStore.js b/src/backend/stores/share/ShareStore.js index 9831603b2..9fb478cf7 100644 --- a/src/backend/stores/share/ShareStore.js +++ b/src/backend/stores/share/ShareStore.js @@ -18,12 +18,13 @@ */ import { v4 as uuidv4 } from 'uuid'; +import { HttpError } from '../../core/http/HttpError.js'; import { encodeCursor, decodeCursor } from '../../util/pagination'; import { PuterStore } from '../types'; -/** Default page size for `listByHolder`. */ -const DEFAULT_HOLDER_PAGE_SIZE = 50; -const MAX_HOLDER_PAGE_SIZE = 200; +/** Default page size for the keyset listings. */ +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 200; /** Ids per `IN` list in `getSharedFsentryIds`; a listing can run to thousands. */ const SHARED_IDS_CHUNK_SIZE = 1000; @@ -78,12 +79,8 @@ export class ShareStore extends PuterStore { * or rename would strand them. */ async listByHolder(holderUserId, { limit, cursor } = {}) { - const size = Math.min( - Math.max(1, Math.floor(Number(limit) || DEFAULT_HOLDER_PAGE_SIZE)), - MAX_HOLDER_PAGE_SIZE, - ); - const decoded = decodeCursor(cursor, 'share cursor'); - const afterId = Number(decoded?.id ?? 0) || 0; + const size = this.#pageSize(limit); + const afterId = this.#afterId(cursor); // One extra row tells us whether another page exists. const rows = await this.clients.db.read( @@ -103,6 +100,75 @@ export class ShareStore extends PuterStore { }; } + /** + * Shares a user has made, keyset-paginated on `id`: the rows they issued + * themselves, plus the rows a manage delegate issued on a node they own — + * which is the only place those are visible, since the permission tables + * are keyed issuer to holder. + * + * Two reads rather than one `OR` spanning the join, which no index can + * serve. The issued half is a pure range scan on `idx_share_issuer`. The + * delegated half probes `idx_share_fsentry` per owned node and sorts what + * it finds — bounded by how many shares exist on the user's nodes, which + * delegates alone can create. Unclaimed invites are included (an invite is + * something the user sent); the legacy invite rows that name no node are + * not. + * + * @param {number} userId + * @param {{ limit?: number; cursor?: string }} [opts] + */ + async listOutbound(userId, { limit, cursor } = {}) { + const size = this.#pageSize(limit); + const afterId = this.#afterId(cursor); + + const [issued, delegated] = await Promise.all([ + this.clients.db.read( + 'SELECT * FROM `share` WHERE `issuer_user_id` = ? AND ' + + '`fsentry_id` IS NOT NULL AND `id` > ? ORDER BY `id` LIMIT ?', + [userId, afterId, size + 1], + ), + this.clients.db.read( + 'SELECT `share`.* FROM `share` JOIN `fsentries` ON ' + + '`fsentries`.`id` = `share`.`fsentry_id` WHERE ' + + '`fsentries`.`user_id` = ? AND `share`.`issuer_user_id` <> ? ' + + 'AND `share`.`id` > ? ORDER BY `share`.`id` LIMIT ?', + [userId, userId, afterId, size + 1], + ), + ]); + + // The halves are disjoint and each ordered by id, so merging them and + // cutting at `size` is the true next page: whatever either half lost to + // its own limit sorts after the cut and returns on the following one. + const merged = [...issued, ...delegated].sort( + (a, b) => Number(a.id) - Number(b.id), + ); + const hasMore = merged.length > size; + const items = merged.slice(0, size).map((r) => this.#normalizeRow(r)); + const last = items[items.length - 1]; + return { + items, + cursor: hasMore && last ? encodeCursor({ id: last.id }) : undefined, + }; + } + + /** How many rows `listOutbound` walks, both halves counted. */ + async countOutbound(userId) { + const [issued, delegated] = await Promise.all([ + this.clients.db.read( + 'SELECT COUNT(*) AS `count` FROM `share` WHERE ' + + '`issuer_user_id` = ? AND `fsentry_id` IS NOT NULL', + [userId], + ), + this.clients.db.read( + 'SELECT COUNT(*) AS `count` FROM `share` JOIN `fsentries` ON ' + + '`fsentries`.`id` = `share`.`fsentry_id` WHERE ' + + '`fsentries`.`user_id` = ? AND `share`.`issuer_user_id` <> ?', + [userId, userId], + ), + ]); + return Number(issued[0]?.count ?? 0) + Number(delegated[0]?.count ?? 0); + } + /** Everyone with an active share on one node, whoever issued it. */ async listByFsentry(fsentryId) { return this.listByFsentries([fsentryId]); @@ -288,10 +354,20 @@ export class ShareStore extends PuterStore { '`holder_user_id` IS NULL LIMIT 1', [recipientEmail, fsentryId, issuerUserId], ); + // Same key an active share records the app under, so one reader covers + // an invite and the grant it becomes. Attribution follows the most + // recent issuance: re-inviting refreshes `data`, exactly as + // `upsertActive` does on conflict. + const data = JSON.stringify({ + ...(issuerAppUid ? { issuedByApp: issuerAppUid } : {}), + ...(displayEmail && displayEmail !== recipientEmail + ? { invitedAddress: displayEmail } + : {}), + }); if (existing[0]?.uid) { await this.clients.db.write( - 'UPDATE `share` SET `mode` = ? WHERE `uid` = ?', - [mode, existing[0].uid], + 'UPDATE `share` SET `mode` = ?, `data` = ? WHERE `uid` = ?', + [mode, data, existing[0].uid], ); return { row: await this.getByUid(existing[0].uid), @@ -303,19 +379,7 @@ export class ShareStore extends PuterStore { await this.clients.db.write( 'INSERT INTO `share` (`uid`, `issuer_user_id`, `recipient_email`, ' + '`fsentry_id`, `mode`, `data`) VALUES (?, ?, ?, ?, ?, ?)', - [ - uid, - issuerUserId, - recipientEmail, - fsentryId, - mode, - JSON.stringify({ - ...(issuerAppUid ? { issuerAppUid } : {}), - ...(displayEmail && displayEmail !== recipientEmail - ? { invitedAddress: displayEmail } - : {}), - }), - ], + [uid, issuerUserId, recipientEmail, fsentryId, mode, data], ); return { row: await this.getByUid(uid), created: true }; } @@ -476,6 +540,41 @@ export class ShareStore extends PuterStore { return result?.affectedRows ?? result?.changes ?? 0; } + /** + * Drop the unclaimed invites `issuerUserId` sent on a directory and + * everything beneath it. Used when an issuer loses their authority over the + * node: nothing else retires their invites — the claim path drops them one + * by one, but only when the recipient shows up. + * + * @param {number} issuerUserId + * @param {number} fsentryId + */ + async deletePendingByIssuerSubtree(issuerUserId, fsentryId) { + // Read-then-delete rather than a CTE inside the DELETE, which the + // dialects disagree on. The gap between the two only ever leaves an + // invite standing, and the claim path re-checks authority anyway. + const rows = await this.clients.db.read( + 'WITH RECURSIVE `subtree`(`id`) AS (' + + 'SELECT `id` FROM `fsentries` WHERE `id` = ? ' + + 'UNION ALL ' + + 'SELECT `f`.`id` FROM `fsentries` `f` ' + + 'JOIN `subtree` `s` ON `f`.`parent_id` = `s`.`id`' + + ') ' + + 'SELECT `share`.`uid` FROM `share` ' + + 'JOIN `subtree` ON `share`.`fsentry_id` = `subtree`.`id` ' + + 'WHERE `share`.`holder_user_id` IS NULL AND ' + + '`share`.`issuer_user_id` = ?', + [fsentryId, issuerUserId], + ); + if (rows.length === 0) return 0; + const placeholders = rows.map(() => '?').join(', '); + const result = await this.clients.db.write( + `DELETE FROM \`share\` WHERE \`uid\` IN (${placeholders})`, + rows.map((row) => row.uid), + ); + return result?.affectedRows ?? result?.changes ?? 0; + } + async deleteByUid(uid) { const result = await this.clients.db.write( 'DELETE FROM `share` WHERE `uid` = ?', @@ -530,6 +629,32 @@ export class ShareStore extends PuterStore { // -- Internals ---------------------------------------------------- + /** @param {number} [limit] */ + #pageSize(limit) { + return Math.min( + Math.max(1, Math.floor(Number(limit) || DEFAULT_PAGE_SIZE)), + MAX_PAGE_SIZE, + ); + } + + /** + * The id a keyset page resumes after; 0 for the first page. A cursor that + * decodes but names no usable id — another endpoint's cursor, say — is + * refused rather than read as page one, which would silently restart a + * client's iteration from the top. + */ + #afterId(cursor) { + const decoded = decodeCursor(cursor, 'share cursor'); + if (decoded === undefined) return 0; + const id = Number(decoded.id); + if (!Number.isInteger(id) || id < 0) { + throw new HttpError(400, 'invalid share cursor', { + legacyCode: 'bad_request', + }); + } + return id; + } + #normalizeRow(row) { if (!row) return null; if (typeof row.data === 'string') { diff --git a/src/backend/stores/share/ShareStore.test.js b/src/backend/stores/share/ShareStore.test.js index 59f7a98fa..bb0e18798 100644 --- a/src/backend/stores/share/ShareStore.test.js +++ b/src/backend/stores/share/ShareStore.test.js @@ -512,6 +512,83 @@ describe('ShareStore', () => { expect(await store.countByHolder(pageHolder.id)).toBe(5); }); + it('lists what a user issued alongside what was issued on their node', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const recipient = await makeUser(); + const ownNode = await makeEntry(owner); + const foreignNode = await makeEntry(otherIssuer); + + const mine = await store.upsertActive({ + issuerUserId: owner.id, + holderUserId: recipient.id, + fsentryId: ownNode.id, + mode: 'read', + }); + const delegated = await store.upsertActive({ + issuerUserId: delegate.id, + holderUserId: recipient.id, + fsentryId: ownNode.id, + mode: 'read', + }); + const asDelegate = await store.upsertActive({ + issuerUserId: owner.id, + holderUserId: recipient.id, + fsentryId: foreignNode.id, + mode: 'read', + }); + // Neither issued by the owner nor on anything they own. + await store.upsertActive({ + issuerUserId: delegate.id, + holderUserId: recipient.id, + fsentryId: foreignNode.id, + mode: 'read', + }); + // A legacy invite row names no node, so it is not a share of one. + await store.create({ + issuerUserId: owner.id, + recipientEmail: 'legacy@test.local', + }); + + const page = await store.listOutbound(owner.id); + expect(page.items.map((r) => r.uid).sort()).toEqual( + [mine.uid, delegated.uid, asDelegate.uid].sort(), + ); + expect(await store.countOutbound(owner.id)).toBe(3); + }); + + it('pages the outbound listing in id order across both halves', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const recipient = await makeUser(); + const uids = []; + for (let i = 0; i < 4; i++) { + const entry = await makeEntry(owner); + const row = await store.upsertActive({ + issuerUserId: i % 2 === 0 ? owner.id : delegate.id, + holderUserId: recipient.id, + fsentryId: entry.id, + mode: 'read', + }); + uids.push(row.uid); + } + + const seen = []; + let cursor; + for (let guard = 0; guard < 10; guard++) { + const page = await store.listOutbound(owner.id, { + limit: 1, + cursor, + }); + seen.push(...page.items.map((r) => r.uid)); + cursor = page.cursor; + if (!cursor) break; + } + + expect(seen).toEqual(uids); + expect(cursor).toBeUndefined(); + }); + it('never returns another holder rows', async () => { const stranger = await makeUser(); const entry = await makeEntry(issuer); @@ -574,5 +651,68 @@ describe('ShareStore', () => { }), ).rejects.toThrow('are required'); }); + + it('refuses a cursor that decodes but names no id', async () => { + const foreign = Buffer.from( + JSON.stringify({ appUid: 'not-a-share-cursor' }), + ).toString('base64'); + await expect( + store.listByHolder(holder.id, { cursor: foreign }), + ).rejects.toThrow('invalid share cursor'); + await expect( + store.listOutbound(issuer.id, { + cursor: Buffer.from(JSON.stringify({ id: 'abc' })).toString( + 'base64', + ), + }), + ).rejects.toThrow('invalid share cursor'); + }); + + it('re-inviting refreshes the invite data, not just the mode', async () => { + const entry = await makeEntry(issuer); + const email = `refresh-${uuidv4()}@test.local`; + const first = await store.upsertPending({ + issuerUserId: issuer.id, + recipientEmail: email, + fsentryId: entry.id, + mode: 'read', + issuerAppUid: 'app-first', + }); + expect(first.row.data.issuedByApp).toBe('app-first'); + + // Re-invited by hand: the row now records the latest issuance. + const second = await store.upsertPending({ + issuerUserId: issuer.id, + recipientEmail: email, + fsentryId: entry.id, + mode: 'write', + }); + expect(second.created).toBe(false); + expect(second.row.uid).toBe(first.row.uid); + expect(second.row.mode).toBe('write'); + expect(second.row.data.issuedByApp).toBeUndefined(); + }); + + it('drops only one issuer unclaimed invites under a subtree', async () => { + const entry = await makeEntry(issuer); + const mine = await store.upsertPending({ + issuerUserId: issuer.id, + recipientEmail: `sub-${uuidv4()}@test.local`, + fsentryId: entry.id, + mode: 'read', + }); + const theirs = await store.upsertPending({ + issuerUserId: otherIssuer.id, + recipientEmail: `sub-${uuidv4()}@test.local`, + fsentryId: entry.id, + mode: 'read', + }); + + expect( + await store.deletePendingByIssuerSubtree(issuer.id, entry.id), + ).toBe(1); + expect(await store.getByUid(mine.row.uid)).toBeNull(); + expect(await store.getByUid(theirs.row.uid)).not.toBeNull(); + }); }); }); diff --git a/src/docs/src/FS.md b/src/docs/src/FS.md index 031b875fb..7e53a310f 100644 --- a/src/docs/src/FS.md +++ b/src/docs/src/FS.md @@ -310,6 +310,7 @@ These cloud storage features are supported out of the box when using Puter.js: - **[`puter.fs.share()`](/FS/share/)** - Give another user access to a file or directory - **[`puter.fs.unshare()`](/FS/unshare/)** - Withdraw a user's access - **[`puter.fs.listShared()`](/FS/listShared/)** - List what others have shared with you +- **[`puter.fs.listSharedByMe()`](/FS/listSharedByMe/)** - List everything you have shared out - **[`puter.fs.getShares()`](/FS/getShares/)** - List who has access to an item ## Examples diff --git a/src/docs/src/FS/listSharedByMe.md b/src/docs/src/FS/listSharedByMe.md new file mode 100644 index 000000000..cecc2ebaf --- /dev/null +++ b/src/docs/src/FS/listSharedByMe.md @@ -0,0 +1,93 @@ +--- +title: puter.fs.listSharedByMe() +description: List everything you have shared with other users, across all items. +platforms: [websites, apps, nodejs, workers] +--- + +This method lists everything you have shared out, a page at a time, without naming an item first. [`getShares()`](/FS/getShares/) answers the same question for one item you can already point at; this is what answers it when you can't. The listing includes invites to addresses that have no account yet (marked `pending`), and — for items you own — shares that a delegate with `manage` access issued on your behalf. + +> **What an app sees.** An app never gets more reach than it was given: this +> listing shows an app only the shares on items it can reach in its own right. +> Shares an app creates are attributed to the user and carry `issuedByApp`, so +> the owner can tell them apart. + +## Syntax + +```js +puter.fs.listSharedByMe() +puter.fs.listSharedByMe(options) +``` + +## Parameters + +#### `options` (Object) (optional) + +An object with the following properties: + +- `limit` (Number) - Maximum shares per page. +- `cursor` (String) - Continuation token from a previous page. +- `includeTotal` (Boolean) - Include the total count in the response. Defaults to `false`. + +## Return value + +A `Promise` that resolves to an object with: + +- `items` (Array) - The shares on this page. Each has `uid`, `mode`, `path`, `entryUid`, `isDir`, `name`, `type`, `thumbnail`, `owner`, `issuer`, `holder`, `issuedByApp`, `modified` and `size`. An unclaimed invite additionally carries `pending: true` and `recipientEmail`, with a `holder` of `null`. +- `cursor` (String) - Pass to the next call to get the following page. **Present only while more pages remain.** +- `total` (Number) - Present only when `includeTotal` was set. An approximation: it counts the shares recorded, before per-grant filtering, so it can be higher than the number of items paging actually yields. + +Iterate until `cursor` is absent rather than comparing `items.length` to `limit`. A page can come back short — rows whose grant has since been withdrawn are filtered out after the page is read — while more pages still remain. + +Items you own appear at their real path. An item you shared as a delegate (from someone else's folder you hold `manage` on) appears at the same masked path you reach it by. + +## Examples + +See everything you have shared + +```html;fs-listSharedByMe + + + + + + +``` + +Page through every share you made + +```js +let cursor; +const all = []; +do { + const page = await puter.fs.listSharedByMe({ limit: 50, cursor }); + all.push(...page.items); + cursor = page.cursor; +} while (cursor); +``` + +Withdraw everything shared on one item + +```js +const page = await puter.fs.listSharedByMe(); +const target = page.items.find((share) => share.name === 'report.txt'); +if (target && !target.pending) { + await puter.fs.unshare(target.path, target.holder); +} +``` + +## Related + +- [`puter.fs.share()`](/FS/share/) - Grant access +- [`puter.fs.listShared()`](/FS/listShared/) - List what others shared with you +- [`puter.fs.getShares()`](/FS/getShares/) - See who can reach one item you manage diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md index edc5747a7..2c14817b7 100644 --- a/src/docs/src/rate-limits-and-quotas.md +++ b/src/docs/src/rate-limits-and-quotas.md @@ -135,11 +135,13 @@ Sharing is bounded twice: on the calls, and on how many people one account can r | -------------------------------------------- | ------------ | | `share` / `revoke` calls per minute | 60 | | `share` / `revoke` calls per day | 500 | -| Reads (`getShares`, `listShared`) per minute | 600 | +| Reads (`getShares`, `listShared`, `listSharedByMe`) per minute | 600 | | New shares per day | 200 | | Recipients per request | 10 | | Items per request | 50 | +The read limit is one bucket shared by every share-listing call, so polling one of them spends budget the others need. + A "new share" is one that gives someone access they didn't already have. Changing the mode on an existing share, or re-sharing an item the recipient already has, costs nothing. Over the daily limit, `share` fails with `share_daily_limit_reached`. Separately, the notification and email that tell a recipient about a share are budgeted — being told is not the same as being interrupted about it: diff --git a/src/puter-js/src/modules/FileSystem/index.js b/src/puter-js/src/modules/FileSystem/index.js index 278e551f6..d2b613473 100644 --- a/src/puter-js/src/modules/FileSystem/index.js +++ b/src/puter-js/src/modules/FileSystem/index.js @@ -15,6 +15,7 @@ import deleteFSEntry from './operations/deleteFSEntry.js'; import getReadURL from './operations/getReadUrl.js'; import getShares from './operations/getShares.js'; import listShared from './operations/listShared.js'; +import listSharedByMe from './operations/listSharedByMe.js'; import mkdir from './operations/mkdir.js'; import move from './operations/move.js'; import read from './operations/read.js'; @@ -63,6 +64,7 @@ export class PuterJSFileSystemModule extends PuterModule { share = share; unshare = unshare; listShared = listShared; + listSharedByMe = listSharedByMe; getShares = getShares; FSItem = FSItem; diff --git a/src/puter-js/src/modules/FileSystem/operations/listSharedByMe.js b/src/puter-js/src/modules/FileSystem/operations/listSharedByMe.js new file mode 100644 index 000000000..fc9547cbb --- /dev/null +++ b/src/puter-js/src/modules/FileSystem/operations/listSharedByMe.js @@ -0,0 +1,46 @@ +import { defineOperation, firstDefined } from './scaffold.js'; +import { toShare } from './shareUtil.js'; + +/** @typedef {import('../types.js').ListSharedOptions} ListSharedOptions */ +/** @typedef {import('../types.js').SharePage} SharePage */ + +/** + * Lists everything you have shared out, a page at a time — across every item, + * without naming one. Includes invites nobody has claimed yet (`pending`), + * and, for items you own, shares a delegate with `manage` access issued. + * + * `cursor` comes back only while more pages remain, so iterate until it is + * absent rather than comparing `items.length` to `limit` — a page can be short + * once items the caller can no longer see are filtered out. + * + * @type {{ + * (options?: ListSharedOptions): Promise, + * ( + * success?: (value: SharePage) => void, + * error?: (reason: unknown) => void, + * ): Promise, + * }} + */ +const listSharedByMe = defineOperation({ + request (options) { + const query = new URLSearchParams(); + if ( options.limit !== undefined ) query.set('limit', String(options.limit)); + if ( options.cursor !== undefined ) query.set('cursor', String(options.cursor)); + if ( firstDefined(options, 'includeTotal', 'include_total') ) { + query.set('includeTotal', 'true'); + } + const suffix = query.toString(); + + return { + endpoint: `/share/shared-by-me${suffix ? `?${suffix}` : ''}`, + method: 'get', + transform: (/** @type {{ items?: Record[], cursor?: string, total?: number }} */ response) => ({ + items: (response.items ?? []).map(toShare), + ...(response.cursor === undefined ? {} : { cursor: response.cursor }), + ...(response.total === undefined ? {} : { total: response.total }), + }), + }; + }, +}); + +export default listSharedByMe; diff --git a/src/puter-js/src/modules/FileSystem/types.js b/src/puter-js/src/modules/FileSystem/types.js index 1b7f271db..5dc69aa52 100644 --- a/src/puter-js/src/modules/FileSystem/types.js +++ b/src/puter-js/src/modules/FileSystem/types.js @@ -312,11 +312,11 @@ * @property {string | null} name The item's name. Not set by `getShares()`, * which describes access to an item the caller already named. * @property {string | null} type The item's content type, or `'folder'`. Only - * set by `listShared()`. + * set by the listings, `listShared()` and `listSharedByMe()`. * @property {string | null} thumbnail URL of the item's thumbnail, if it has - * one. Only set by `listShared()`. + * one. Only set by the listings, `listShared()` and `listSharedByMe()`. * @property {string | null} owner Username of the item's owner. Only set by - * `listShared()`. + * the listings, `listShared()` and `listSharedByMe()`. * @property {string | null} issuer Username of whoever granted it. * @property {string | null} holder Username of whoever received it. * @property {string | null} [inheritedFrom] Shared ancestor this access comes from, if any. diff --git a/src/puter-js/tests/api/suites/sharing.suite.ts b/src/puter-js/tests/api/suites/sharing.suite.ts index d6ee18c0e..49be6b988 100644 --- a/src/puter-js/tests/api/suites/sharing.suite.ts +++ b/src/puter-js/tests/api/suites/sharing.suite.ts @@ -152,6 +152,54 @@ export default suite('sharing', { ); }, + 'listSharedByMe lists what you shared out, invites included': async (t) => { + const path = scratch(t, 'outbound'); + await t.puter.fs.write(path, 'x'); + await t.puter.fs.share(path, t.env.users.other.username, 'read'); + const email = `pending-${Math.random().toString(36).slice(2, 8)}@test.local`; + await t.puter.fs.share(path, email); + + const page = await t.puter.fs.listSharedByMe({ includeTotal: true }); + t.assert.ok(Array.isArray(page.items), 'items should be an array'); + t.assert.equal(typeof page.total, 'number'); + + // Own items appear at their real path, with the holder named. + const mine = page.items.filter((share) => share.path === path); + t.assert.ok( + mine.some((share) => share.holder === t.env.users.other.username), + 'the claimed share should be listed', + ); + const invite = mine.find((share) => share.pending); + t.assert.ok(invite, 'the unclaimed invite should be listed'); + t.assert.equal(invite!.recipientEmail, email); + t.assert.equal(invite!.holder, null); + }, + + 'listSharedByMe pages through the cursor': async (t) => { + const paths = [scratch(t, 'page-a'), scratch(t, 'page-b')]; + for (const path of paths) { + await t.puter.fs.write(path, 'x'); + await t.puter.fs.share(path, t.env.users.other.username, 'read'); + } + + const seen: string[] = []; + let cursor: string | undefined; + // Bounded: the account accumulates shares across the suite, but far + // fewer than this many pages. + for (let page = 0; page < 100; page++) { + const listed = await t.puter.fs.listSharedByMe({ limit: 1, cursor }); + t.assert.ok(listed.items.length <= 1, 'limit should bound the page'); + seen.push(...listed.items.map((share) => share.path)); + cursor = listed.cursor; + if (!cursor) break; + } + + t.assert.equal(cursor, undefined); + for (const path of paths) { + t.assert.ok(seen.includes(path), `${path} should be paged through`); + } + }, + 'sharing an unknown recipient rejects': async (t) => { const path = scratch(t, 'nobody'); await t.puter.fs.write(path, 'x');