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