mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-22 05:06:10 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user