feat: global outbound share listing (PUT-1664) (#3694)

* feat: global outbound share listing (PUT-1664)

* fix: address review on outbound share listing

- Check share-row liveness per (holder, entry, issuer) so a grant
  withdrawn outside unshare doesn't stay listed while another issuer
  still reaches the same holder; batch the permission reads across the
  whole page instead of per holder.
- Retire a revoked issuer's unclaimed invites in the revoke cascade,
  and hide invites whose issuer lost their authority at read time.
- Unify the pending/active app-attribution key on `issuedByApp` and
  dual-read the legacy `issuerAppUid` spelling.
- Add the missing share issuer index (sqlite, postgres) and correct
  the listOutbound plan comment.
- Refuse cursors that decode but name no id instead of silently
  restarting from page one.
- Consolidate the five hand-built ResolvedShare literals and the two
  listing endpoints' parse/shape code.
- Ship the SDK surface: puter.fs.listSharedByMe() with docs, types,
  suite coverage, and the rate-limit page entry.
This commit is contained in:
Daniel Salazar
2026-09-01 13:52:37 -07:00
committed by GitHub
parent 6478a8f47d
commit 66a975f659
18 changed files with 1633 additions and 182 deletions
@@ -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
@@ -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 {
@@ -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 <https://www.gnu.org/licenses/>.
-- "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);
@@ -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 <https://www.gnu.org/licenses/>.
-- "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`);
@@ -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<Record<string, unknown>>;
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<Record<string, unknown>> };
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<string, string> = { 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<string, unknown>;
}>;
};
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;
@@ -265,10 +265,48 @@ export class ShareController extends PuterController {
rateLimit: SHARE_LIST_LIMIT,
})
async listSharedWithMe(req: Request, res: Response): Promise<void> {
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<void> {
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<void> {
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',
@@ -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();
+421 -151
View File
@@ -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<ShareIndexRow, 'holder_user_id'> {
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<number>;
/** 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<string> = 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:<uuid>` 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<Map<string, GrantEvidence>> {
const evidence = new Map<string, GrantEvidence>();
const unique = new Map<string, { holderId: number; entry: FSEntry }>();
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<number>; unattributed: boolean }
>();
const record = (
holderId: number,
permission: string,
issuer: unknown,
) => {
const key = `${holderId}:${permission}`;
const found = byHolderPerm.get(key) ?? {
issuers: new Set<number>(),
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<Set<string>> {
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<string>();
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 `<holderId>:<fsentryId>` 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<number, FSEntry>,
): 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 `<holderId>:<fsentryId>` 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<number, FSEntry>,
): Promise<Set<string>> {
const nodesByHolder = new Map<number, Map<number, FSEntry>>();
const evidence = await this.#grantEvidence(
this.#rowPairs(rows, nodeById),
);
const live = new Set<string>();
for (const [key, found] of evidence) {
if (found.owned || found.unattributed || found.issuers.size > 0) {
live.add(key);
}
}
return live;
}
/**
* The `<holderId>:<fsentryId>:<issuerId>` 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<number, FSEntry>,
): Promise<Set<string>> {
const evidence = await this.#grantEvidence(
this.#rowPairs(rows, nodeById),
);
const live = new Set<string>();
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<string>();
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<number, FSEntry>(
[...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<number, UserRow>,
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<number, FSEntry>,
users: Map<number, UserRow>,
): Promise<Set<string>> {
const allowed = new Set<string>();
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,
@@ -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<LinkedUserUserPermRow[]> {
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<LinkedUserUserPermRow>(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<Array<{ ref: FlatPermRef; value: FlatPermValue }>> {
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,
+149 -24
View File
@@ -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') {
+140
View File
@@ -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();
});
});
});
+1
View File
@@ -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
+93
View File
@@ -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
<strong class="example-title">See everything you have shared</strong>
```html;fs-listSharedByMe
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const page = await puter.fs.listSharedByMe({ includeTotal: true });
puter.print(`About ${page.total} share(s) you made<br>`);
for (const share of page.items) {
const who = share.pending
? `${share.recipientEmail} (invited)`
: share.holder;
puter.print(`${share.path} — ${share.mode} to ${who}<br>`);
}
})()
</script>
</body>
</html>
```
<strong class="example-title">Page through every share you made</strong>
```js
let cursor;
const all = [];
do {
const page = await puter.fs.listSharedByMe({ limit: 50, cursor });
all.push(...page.items);
cursor = page.cursor;
} while (cursor);
```
<strong class="example-title">Withdraw everything shared on one item</strong>
```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
+3 -1
View File
@@ -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:
@@ -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;
@@ -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<SharePage>,
* (
* success?: (value: SharePage) => void,
* error?: (reason: unknown) => void,
* ): Promise<SharePage>,
* }}
*/
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<string, unknown>[], 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;
+3 -3
View File
@@ -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.
@@ -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');