feat(share): add ShareService with a per-day share limit

A share is two writes that belong together: the permission grant, which
authorizes access, and a share row, which makes it listable and ties it to an
fsentry so it dies with the file. Nothing else grants fs:* to a user.

Authorization reuses canManagePermission — an owner satisfies it through the
is-owner implicator, a delegate through an explicit manage:fs:<uid> grant. An
owner may clear any issuer's share of their node; anyone else only the ones
they issued, or their own access. Self-revoke skips the manage gate but still
requires `see`, so it cannot be used to probe for files.

The per-day limit counts shares created rather than live rows, so revoking and
re-sharing cannot recycle a slot, and changing an existing share's mode is not
new reach and does not spend budget. Tunable via share_daily_limit.
This commit is contained in:
Juan Castro
2026-08-12 17:38:49 -04:00
parent ba48efd281
commit 33fb7c4b68
5 changed files with 1173 additions and 0 deletions
+5
View File
@@ -36,6 +36,7 @@ import { MeteringService } from './metering/MeteringService';
import { NotificationService } from './notification/NotificationService';
import { PermissionService } from './permission/PermissionService';
import { DefaultUserService } from './selfhosted/DefaultUserService';
import { ShareService } from './share/ShareService';
import { SocketService } from './socket/SocketService';
import { SubdomainPermissionService } from './subdomain/SubdomainPermissionService';
import type { IPuterServiceRegistry } from './types';
@@ -53,6 +54,7 @@ declare module './types' {
appOriginBlocklist: AppOriginBlocklistService;
permission: PermissionService;
acl: ACLService;
share: ShareService;
token: TokenService;
auth: AuthService;
fs: FSService;
@@ -91,6 +93,9 @@ export const puterServices = {
token: TokenService,
auth: AuthService,
fs: FSService,
// Needs acl (setUserUser), permission (canManagePermission) and fs
// (ancestor chains), so it follows all three.
share: ShareService,
// AppPermissionService + SubdomainPermissionService register permission
// rewriters/implicators only; no runtime state. Placed after fsEntry so
// the FS rewriter runs first for `fs:/path` → `fs:<uuid>` before any
@@ -0,0 +1,539 @@
/*
* 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/>.
*/
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { Actor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { PuterServer } from '../../server.js';
import { createTestUser, setupTestServer } from '../../testUtil.js';
describe('ShareService', () => {
let server: PuterServer;
beforeAll(async () => {
server = await setupTestServer();
});
afterAll(async () => {
await server?.shutdown();
});
const makeUser = async () => {
const username = `sh${Math.random().toString(36).slice(2, 9)}`;
await createTestUser(server, { username, password: 'pw-test-1234' });
const user = await server.stores.user.getByUsername(username);
if (!user) throw new Error('test user missing');
const email = `${username}@test.local`;
await server.stores.user.update(user.id, { email });
const fresh = await server.stores.user.getById(user.id, {
force: true,
});
const actor: Actor = {
user: fresh as Actor['user'],
effectiveApp: null,
};
return { user: fresh!, actor, email };
};
/** A real fsentry under the user's home, so ancestor chains resolve. */
const makeFile = async (owner: { id: number; username: string }) => {
const uuid = uuidv4();
const name = `f-${uuid.slice(0, 8)}.txt`;
const path = `/${owner.username}/${name}`;
await server.clients.db.write(
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, 0, ?)',
[uuid, name, path, owner.id, Math.floor(Date.now() / 1000)],
);
const entry = await server.stores.fsEntry.getEntryByPath(path);
if (!entry) throw new Error('fsentry not created');
return entry;
};
const canRead = async (actor: Actor, path: string) =>
server.services.acl.check(
actor,
{
path,
resolveAncestors: () => server.services.fs.getAncestorChain(path),
},
'read',
);
const share = (actor: Actor, input: Record<string, unknown>) =>
runWithContext({ actor }, () =>
server.services.share.share(actor, input as never),
);
const unshare = (actor: Actor, input: Record<string, unknown>) =>
runWithContext({ actor }, () =>
server.services.share.unshare(actor, input as never),
);
it('grants access and indexes the share', async () => {
const owner = await makeUser();
const recipient = await makeUser();
const file = await makeFile(owner.user);
expect(await canRead(recipient.actor, file.path)).toBe(false);
const result = await share(owner.actor, {
uid: file.uuid,
recipient: { email: recipient.email },
mode: 'read',
});
expect(result.mode).toBe('read');
expect(result.path).toBe(file.path);
expect(await canRead(recipient.actor, file.path)).toBe(true);
const listed = await server.services.share.listSharedWithMe(
recipient.actor,
);
expect(listed.items.map((i) => i.entryUid)).toContain(file.uuid);
});
it('resolves a recipient by username as well as email', async () => {
const owner = await makeUser();
const recipient = await makeUser();
const file = await makeFile(owner.user);
await share(owner.actor, {
uid: file.uuid,
recipient: { username: recipient.user.username },
mode: 'read',
});
expect(await canRead(recipient.actor, file.path)).toBe(true);
});
it('refuses to share with yourself or with the owner', async () => {
const owner = await makeUser();
const file = await makeFile(owner.user);
await expect(
share(owner.actor, {
uid: file.uuid,
recipient: { email: owner.email },
mode: 'read',
}),
).rejects.toMatchObject({ statusCode: 400 });
});
it('refuses an unknown mode and an unknown recipient', async () => {
const owner = await makeUser();
const file = await makeFile(owner.user);
await expect(
share(owner.actor, {
uid: file.uuid,
recipient: { email: 'nobody@nowhere.test' },
mode: 'read',
}),
).rejects.toMatchObject({ statusCode: 404 });
await expect(
share(owner.actor, {
uid: file.uuid,
recipient: { email: 'nobody@nowhere.test' },
mode: 'wizard',
}),
).rejects.toMatchObject({ statusCode: 400 });
});
it('hides a file from a stranger trying to share it', async () => {
const owner = await makeUser();
const stranger = await makeUser();
const recipient = await makeUser();
const file = await makeFile(owner.user);
// 404 rather than 403 — a failed share must not confirm the file
// exists to someone who cannot even see it.
await expect(
share(stranger.actor, {
uid: file.uuid,
recipient: { email: recipient.email },
mode: 'read',
}),
).rejects.toMatchObject({ statusCode: 404 });
});
it('revokes access and drops the index row', 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 canRead(recipient.actor, file.path)).toBe(true);
const result = await unshare(owner.actor, {
uid: file.uuid,
recipient: { email: recipient.email },
});
expect(result.revoked).toBe(1);
expect(await canRead(recipient.actor, file.path)).toBe(false);
const listed = await server.services.share.listSharedWithMe(
recipient.actor,
);
expect(listed.items.map((i) => i.entryUid)).not.toContain(file.uuid);
});
it('refuses to revoke the owner', async () => {
const owner = await makeUser();
const file = await makeFile(owner.user);
await expect(
unshare(owner.actor, {
uid: file.uuid,
recipient: { email: owner.email },
}),
).rejects.toMatchObject({ statusCode: 400 });
});
it('shows the owner a share a manage delegate issued', 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',
});
// The owner cannot see this through the permission tables, which are
// keyed issuer→holder; the index is what answers it.
const rows = await server.services.share.listSharesOf(owner.actor, {
uid: file.uuid,
});
const holders = rows.map((r) => r.holder.username);
expect(holders).toContain(delegate.user.username);
expect(holders).toContain(third.user.username);
expect(
rows.find((r) => r.holder.username === third.user.username)?.issuer
.username,
).toBe(delegate.user.username);
});
it('lets a delegate clear only what it issued', async () => {
const owner = await makeUser();
const delegate = await makeUser();
const third = await makeUser();
const fourth = 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',
});
await share(owner.actor, {
uid: file.uuid,
recipient: { email: fourth.email },
mode: 'read',
});
// Its own grant: cleared.
expect(
(
await unshare(delegate.actor, {
uid: file.uuid,
recipient: { email: third.email },
})
).revoked,
).toBe(1);
expect(await canRead(third.actor, file.path)).toBe(false);
// The owner's grant to someone else: untouched.
expect(
(
await unshare(delegate.actor, {
uid: file.uuid,
recipient: { email: fourth.email },
})
).revoked,
).toBe(0);
expect(await canRead(fourth.actor, file.path)).toBe(true);
});
it('lets the owner clear a grant a delegate issued', 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 byOwner = await unshare(owner.actor, {
uid: file.uuid,
recipient: { email: third.email },
});
expect(byOwner.revoked).toBe(1);
expect(await canRead(third.actor, file.path)).toBe(false);
});
it('lets a recipient leave a share they did not issue', 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',
});
// Dropping your own access is always allowed, whoever granted it.
const left = await unshare(recipient.actor, {
uid: file.uuid,
recipient: { email: recipient.email },
});
expect(left.revoked).toBe(1);
expect(await canRead(recipient.actor, file.path)).toBe(false);
});
it('will not let leaving a share reveal a file you cannot see', async () => {
const owner = await makeUser();
const stranger = await makeUser();
const file = await makeFile(owner.user);
// Self-revoke skips the manage gate, so it still has to 404 here or it
// becomes an existence oracle for any uid a stranger cares to guess.
await expect(
unshare(stranger.actor, {
uid: file.uuid,
recipient: { email: stranger.email },
}),
).rejects.toMatchObject({ statusCode: 404 });
});
describe('daily quota', () => {
const withLimit = async (limit: number, fn: () => Promise<void>) => {
const cfg = (
server.services.share as unknown as {
config: { share_daily_limit?: number };
}
).config;
const previous = cfg.share_daily_limit;
cfg.share_daily_limit = limit;
try {
await fn();
} finally {
cfg.share_daily_limit = previous;
}
};
it('refuses a new share once the day budget is spent', async () => {
const owner = await makeUser();
const first = await makeUser();
const second = await makeUser();
const file = await makeFile(owner.user);
await withLimit(1, async () => {
await share(owner.actor, {
uid: file.uuid,
recipient: { email: first.email },
mode: 'read',
});
await expect(
share(owner.actor, {
uid: file.uuid,
recipient: { email: second.email },
mode: 'read',
}),
).rejects.toMatchObject({ statusCode: 429 });
});
});
it('does not spend budget on changing an existing share mode', async () => {
const owner = await makeUser();
const recipient = await makeUser();
const file = await makeFile(owner.user);
await withLimit(1, async () => {
await share(owner.actor, {
uid: file.uuid,
recipient: { email: recipient.email },
mode: 'read',
});
// Same pair, new mode — reach is unchanged, so it must not
// count against the budget the first share already spent.
const upgraded = await share(owner.actor, {
uid: file.uuid,
recipient: { email: recipient.email },
mode: 'write',
});
expect(upgraded.mode).toBe('write');
});
});
it('counts creations, so revoking does not refund the slot', async () => {
const owner = await makeUser();
const recipient = await makeUser();
const other = await makeUser();
const file = await makeFile(owner.user);
await withLimit(1, async () => {
await share(owner.actor, {
uid: file.uuid,
recipient: { email: recipient.email },
mode: 'read',
});
await unshare(owner.actor, {
uid: file.uuid,
recipient: { email: recipient.email },
});
await expect(
share(owner.actor, {
uid: file.uuid,
recipient: { email: other.email },
mode: 'read',
}),
).rejects.toMatchObject({ statusCode: 429 });
});
});
it('treats a non-positive limit as unlimited', async () => {
const owner = await makeUser();
const recipient = await makeUser();
const file = await makeFile(owner.user);
await withLimit(0, async () => {
const result = await share(owner.actor, {
uid: file.uuid,
recipient: { email: recipient.email },
mode: 'read',
});
expect(result.mode).toBe('read');
});
});
});
it('moves an existing share to a new mode rather than stacking one', 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',
});
await share(owner.actor, {
uid: file.uuid,
recipient: { email: recipient.email },
mode: 'write',
});
const rows = await server.services.share.listSharesOf(owner.actor, {
uid: file.uuid,
});
expect(rows).toHaveLength(1);
expect(rows[0].mode).toBe('write');
});
it('retires grants when the entry is deleted', 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',
});
await server.services.share.onEntryDeleted(file.uuid);
await server.clients.db.write(
'DELETE FROM `fsentries` WHERE `uuid` = ?',
[file.uuid],
);
const listed = await server.services.share.listSharedWithMe(
recipient.actor,
);
expect(listed.items.map((i) => i.entryUid)).not.toContain(file.uuid);
const rows = await server.stores.permission.readLinkedUserUserPerms(
recipient.user.id,
[`fs:${file.uuid}:read`],
);
expect(rows).toEqual([]);
});
it('paginates what has been shared with me', async () => {
const owner = await makeUser();
const recipient = await makeUser();
const uids: string[] = [];
for (let i = 0; i < 3; i++) {
const file = await makeFile(owner.user);
uids.push(file.uuid);
await share(owner.actor, {
uid: file.uuid,
recipient: { email: recipient.email },
mode: 'read',
});
}
const seen: string[] = [];
let cursor: string | undefined;
for (let guard = 0; guard < 6; guard++) {
const page = await server.services.share.listSharedWithMe(
recipient.actor,
{ limit: 2, cursor },
);
seen.push(...page.items.map((i) => i.entryUid));
cursor = page.cursor;
if (!cursor) break;
}
expect(seen).toEqual(uids);
const withTotal = await server.services.share.listSharedWithMe(
recipient.actor,
{ includeTotal: true },
);
expect(withTotal.total).toBe(3);
});
});
+555
View File
@@ -0,0 +1,555 @@
/*
* 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/>.
*/
import type { Actor } from '../../core/actor';
import { HttpError } from '../../core/http/HttpError.js';
import type { FSEntry } from '../../stores/fs/FSEntry';
import type { LayerInstances } from '../../types';
import type { AclMode } from '../acl/ACLService';
import type { puterServices } from '../index';
import { PuterService } from '../types';
// -- Types ------------------------------------------------------------
/** A recipient named by whichever identifier the caller had. */
export interface ShareRecipient {
email?: string;
username?: string;
}
export interface ShareTarget {
path?: string;
uid?: string;
}
export interface ShareInput extends ShareTarget {
recipient: ShareRecipient;
mode: AclMode;
}
/** One live share, resolved for a response. */
export interface ResolvedShare {
uid: string;
mode: string;
path: string;
entryUid: string;
isDir: boolean;
issuer: { username: string | null };
holder: { username: string | null };
createdAt: unknown;
}
const SHAREABLE_MODES: ReadonlySet<string> = new Set([
'see',
'list',
'read',
'write',
'manage',
]);
/** Shares one user may create per UTC day, absent a config override. */
export const DEFAULT_DAILY_SHARE_LIMIT = 200;
// -- ShareService -----------------------------------------------------
/**
* Sharing a filesystem node with another user.
*
* A share is two writes that belong together: the permission grant, which is
* what actually authorizes access, and a `share` row, which is what makes the
* share listable and ties it to an fsentry so it dies with the file. This
* service owns that pairing — nothing else should grant `fs:*` to a user.
*
* Authorization reuses `PermissionService.canManagePermission`: an owner
* satisfies it through the `is-owner` implicator, a delegate through an
* explicit `manage:fs:<uid>` grant.
*/
export class ShareService extends PuterService {
declare protected services: LayerInstances<typeof puterServices>;
// -- Writes -------------------------------------------------------
/**
* Grant `mode` on a node to a recipient and index it.
*
* The permission is written first: if the index write then fails because
* the entry died mid-flight, the grant is rolled back rather than left
* standing invisibly.
*/
async share(actor: Actor, input: ShareInput): Promise<ResolvedShare> {
const issuerId = this.#requireUserId(actor);
const mode = this.#requireMode(input.mode);
// Independent reads; the authorization check needs only the entry.
const [entry, holder] = await Promise.all([
this.#resolveEntry(input),
this.#resolveRecipient(input.recipient),
]);
await this.#assertCanManage(actor, entry);
if (holder.id === issuerId) {
throw new HttpError(400, 'cannot share with yourself', {
legacyCode: 'cannot_share_with_self',
});
}
if (holder.id === entry.userId) {
throw new HttpError(400, 'recipient already owns this item', {
legacyCode: 'cannot_share_with_owner',
});
}
// Changing the mode on an existing share isn't new reach, so it
// shouldn't spend budget — only a share to someone who doesn't already
// have one on this node counts.
const existing = await this.stores.share.listByFsentry(entry.id);
const isNewShare = !existing.some(
(row: { holder_user_id: number; issuer_user_id: number }) =>
row.holder_user_id === holder.id &&
row.issuer_user_id === issuerId,
);
if (isNewShare) await this.#assertDailyQuota(issuerId);
const holderActor: Actor = {
user: {
id: holder.id,
uuid: holder.uuid,
username: holder.username,
} as Actor['user'],
effectiveApp: null,
};
await this.services.acl.setUserUser(
actor,
holderActor,
this.#descriptorFor(entry),
mode,
);
try {
const row = await this.stores.share.upsertActive({
issuerUserId: issuerId,
holderUserId: holder.id,
fsentryId: entry.id,
mode,
recipientEmail: holder.email ?? null,
});
if (isNewShare) {
await this.stores.share
.incrementDailyShareCount(issuerId)
.catch(() => {
// A share that already landed must not fail over its
// own bookkeeping.
});
}
return this.#resolve(row, entry, actor, holder);
} catch (err) {
// The entry went away between the grant and the index write, so
// the grant now points at nothing. Undo it rather than leave an
// invisible permission behind.
await this.#revokeQuietly(actor, entry, holder.username, issuerId);
throw err;
}
}
/**
* Withdraw a recipient's access. An owner may clear any issuer's share of
* their node; anyone else may only clear the ones they issued.
*/
async unshare(
actor: Actor,
input: ShareTarget & { recipient: ShareRecipient },
): Promise<{ revoked: number }> {
const issuerId = this.#requireUserId(actor);
const [entry, holder] = await Promise.all([
this.#resolveEntry(input),
this.#resolveRecipient(input.recipient),
]);
// Dropping your own access needs no authority over the node — only
// enough visibility that the call can't be used to probe for one.
const isLeaving = holder.id === issuerId;
if (isLeaving) {
await this.#assertCanSee(actor, entry);
} else {
await this.#assertCanManage(actor, entry);
}
if (holder.id === entry.userId) {
throw new HttpError(400, 'cannot revoke the owner of an item', {
legacyCode: 'cannot_revoke_owner',
});
}
// An owner may clear any issuer's share of their node; anyone else may
// clear the ones they issued, or their own access.
const isOwner = entry.userId === issuerId;
const rows = (await this.stores.share.listByFsentry(entry.id)).filter(
(row: { holder_user_id: number; issuer_user_id: number }) =>
row.holder_user_id === holder.id &&
(isOwner || isLeaving || row.issuer_user_id === issuerId),
);
// Fall back to the issuer's own grant when no index row exists — the
// grant may predate the index, and a revoke must still work.
const issuers =
rows.length > 0
? [
...new Set(
rows.map(
(row: { issuer_user_id: number }) =>
row.issuer_user_id,
),
),
]
: [issuerId];
let revoked = 0;
for (const issuer of issuers) {
const didRevoke = await this.#revokeFor(
actor,
entry,
holder.username,
issuer as number,
);
if (didRevoke) revoked++;
await this.stores.share.deleteActive({
holderUserId: holder.id,
fsentryId: entry.id,
issuerUserId: issuer as number,
});
}
return { revoked };
}
/** Retire the grants pointing at a node that no longer exists. */
async onEntryDeleted(entryUid: string): Promise<void> {
await this.stores.permission.deleteUserUserPermsByPermissionPrefix(
`fs:${entryUid}`,
);
}
// -- Reads --------------------------------------------------------
/**
* What has been shared with `actor`, newest page first by id. Entries are
* hydrated in one batch; rows whose entry is gone, or which resolve into
* the owner's trash, are dropped — the share survives a trashing so a
* restore is lossless, it just shouldn't be listed.
*/
async listSharedWithMe(
actor: Actor,
opts: { limit?: number; cursor?: string; includeTotal?: boolean } = {},
): Promise<{
items: ResolvedShare[];
cursor?: string;
total?: number;
}> {
const holderId = this.#requireUserId(actor);
const page = await this.stores.share.listByHolder(holderId, {
limit: opts.limit,
cursor: opts.cursor,
});
const entries = await this.stores.fsEntry.getEntriesByIds(
page.items.map((row: { fsentry_id: number }) => row.fsentry_id),
);
const issuers = await this.stores.user.getByIds(
page.items.map((row: { issuer_user_id: number }) =>
Number(row.issuer_user_id),
),
);
const items: ResolvedShare[] = [];
for (const row of page.items) {
const entry = entries.get(Number(row.fsentry_id));
if (!entry || this.#isTrashed(entry)) continue;
const issuer = issuers.get(Number(row.issuer_user_id));
items.push({
uid: row.uid,
mode: row.mode,
path: entry.path,
entryUid: entry.uuid,
isDir: Boolean(entry.isDir),
issuer: { username: issuer?.username ?? null },
holder: { username: actor.user.username ?? null },
createdAt: row.created_at,
});
}
return {
items,
...(page.cursor ? { cursor: page.cursor } : {}),
...(opts.includeTotal
? { total: await this.stores.share.countByHolder(holderId) }
: {}),
};
}
/**
* Who can reach one node. Includes shares a `manage` delegate issued, which
* the permission tables alone can't show the owner.
*/
async listSharesOf(
actor: Actor,
target: ShareTarget,
): Promise<ResolvedShare[]> {
const entry = await this.#resolveEntry(target);
await this.#assertCanManage(actor, entry);
const rows = await this.stores.share.listByFsentry(entry.id);
const userIds = rows.flatMap(
(row: { issuer_user_id: number; holder_user_id: number }) => [
Number(row.issuer_user_id),
Number(row.holder_user_id),
],
);
const users = await this.stores.user.getByIds(userIds);
return rows.map(
(row: {
uid: string;
mode: string;
issuer_user_id: number;
holder_user_id: number;
created_at: unknown;
}) => ({
uid: row.uid,
mode: row.mode,
path: entry.path,
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,
}),
);
}
// -- Internals ----------------------------------------------------
#resolve(
row: { uid: string; mode: string; created_at?: unknown },
entry: FSEntry,
issuer: Actor,
holder: { username: string | null },
): ResolvedShare {
return {
uid: row.uid,
mode: row.mode,
path: entry.path,
entryUid: entry.uuid,
isDir: Boolean(entry.isDir),
issuer: { username: issuer.user.username ?? null },
holder: { username: holder.username ?? null },
createdAt: row.created_at,
};
}
#requireUserId(actor: Actor): number {
const id = actor?.user?.id;
if (typeof id !== 'number') {
throw new HttpError(403, 'actor must be a user', {
legacyCode: 'forbidden',
});
}
return id;
}
#requireMode(mode: string): AclMode {
if (!SHAREABLE_MODES.has(mode)) {
throw new HttpError(400, `unknown share mode: ${mode}`, {
legacyCode: 'invalid_mode',
});
}
return mode as AclMode;
}
async #resolveEntry(target: ShareTarget): Promise<FSEntry> {
const entry = target.uid
? await this.stores.fsEntry.getEntryByUuid(target.uid)
: target.path
? await this.stores.fsEntry.getEntryByPath(target.path)
: null;
if (!entry) {
throw new HttpError(404, 'Subject does not exist', {
legacyCode: 'subject_does_not_exist',
});
}
return entry;
}
async #resolveRecipient(recipient: ShareRecipient) {
const email = recipient?.email?.trim();
const username = recipient?.username?.trim();
const user = email
? await this.stores.user.getByEmail(email)
: username
? await this.stores.user.getByUsername(username)
: null;
if (!user?.username) {
throw new HttpError(404, 'Recipient does not exist', {
legacyCode: 'user_does_not_exist',
});
}
return user;
}
/**
* Gate every share operation on the same question the permission layer
* already answers. Reported as the ACL's own safe error so a caller who
* can't even see the node learns nothing from the difference.
*/
async #assertCanManage(actor: Actor, entry: FSEntry): Promise<void> {
const allowed = await this.services.permission.canManagePermission(
actor,
`fs:${entry.uuid}:read`,
);
if (allowed) return;
const safe = await this.services.acl.getSafeAclError(
actor,
this.#descriptorFor(entry),
'manage',
);
throw new HttpError(safe.status, safe.message, {
legacyCode: safe.fields.code,
});
}
/**
* Counted per creation rather than against live rows, so revoking and
* re-sharing can't recycle the same slot. Checked before the write and
* incremented after, so concurrent shares can overshoot by at most the
* request's fan-out — fine for an abuse bound.
*/
async #assertDailyQuota(userId: number): Promise<void> {
const limit =
this.config.share_daily_limit ?? DEFAULT_DAILY_SHARE_LIMIT;
if (limit <= 0) return;
const used = await this.stores.share.getDailyShareCount(userId);
if (used < limit) return;
throw new HttpError(
429,
`daily share limit reached (${limit}); try again tomorrow`,
{ legacyCode: 'share_daily_limit_reached' },
);
}
async #assertCanSee(actor: Actor, entry: FSEntry): Promise<void> {
const descriptor = this.#descriptorFor(entry);
if (await this.services.acl.check(actor, descriptor, 'see')) return;
const safe = await this.services.acl.getSafeAclError(
actor,
descriptor,
'see',
);
throw new HttpError(safe.status, safe.message, {
legacyCode: safe.fields.code,
});
}
#descriptorFor(entry: FSEntry) {
const fsService = this.services.fs;
let cache: Promise<
ReadonlyArray<{ uid: string; path: string }>
> | null = null;
return {
path: entry.path,
resolveAncestors: () => {
if (!cache) cache = fsService.getAncestorChain(entry.path);
return cache;
},
};
}
#isTrashed(entry: FSEntry): boolean {
return /^\/[^/]+\/Trash(\/|$)/u.test(entry.path);
}
/**
* Clear whichever modes the recipient holds on this node.
*
* Skips any the actor can't manage rather than aborting: stripping a
* `manage` grant needs `manage:manage:fs:<uid>`, which only the owner
* holds, so a delegate withdrawing a plain `read` would otherwise fail on
* reaching the manage form.
*/
async #revokeFor(
actor: Actor,
entry: FSEntry,
username: string,
issuerUserId: number,
): Promise<boolean> {
const permissions = [
`fs:${entry.uuid}:see`,
`fs:${entry.uuid}:list`,
`fs:${entry.uuid}:read`,
`fs:${entry.uuid}:write`,
`manage:fs:${entry.uuid}`,
];
const isSelf = username === actor.user.username;
const manageable = isSelf
? permissions.map(() => true)
: await Promise.all(
permissions.map((permission) =>
this.services.permission.canManagePermission(
actor,
permission,
),
),
);
let revoked = false;
for (let i = 0; i < permissions.length; i++) {
if (!manageable[i]) continue;
const didRevoke =
await this.services.permission.revokeUserUserPermission(
actor,
username,
permissions[i],
{ reason: 'unshared' },
{ issuerUserId },
);
if (didRevoke) revoked = true;
}
return revoked;
}
async #revokeQuietly(
actor: Actor,
entry: FSEntry,
username: string,
issuerUserId: number,
): Promise<void> {
try {
await this.#revokeFor(actor, entry, username, issuerUserId);
} catch {
// Already failing the request; don't mask the original error.
}
}
}
+59
View File
@@ -141,6 +141,14 @@ export class ShareStore extends PuterStore {
* (holder, fsentry, issuer) to match the table's unique index — two people
* with manage rights each keep their own row rather than overwriting.
*/
/**
* @param {object} input
* @param {number} input.issuerUserId
* @param {number} input.holderUserId
* @param {number} input.fsentryId
* @param {string} input.mode
* @param {string | null} [input.recipientEmail]
*/
async upsertActive({
issuerUserId,
holderUserId,
@@ -188,6 +196,12 @@ export class ShareStore extends PuterStore {
* Drop one active share. Omit `issuerUserId` to clear every issuer's share
* of that node with that holder — what an owner revoking access wants.
*/
/**
* @param {object} input
* @param {number} input.holderUserId
* @param {number} input.fsentryId
* @param {number | null} [input.issuerUserId]
*/
async deleteActive({ holderUserId, fsentryId, issuerUserId = null }) {
const scoped = issuerUserId !== null && issuerUserId !== undefined;
const result = await this.clients.db.write(
@@ -204,6 +218,13 @@ export class ShareStore extends PuterStore {
* Claim a pending invite for the user who signed up. Updates rather than
* deletes, so the share survives as an index row.
*/
/**
* @param {object} input
* @param {string} input.uid
* @param {number} input.holderUserId
* @param {number | null} [input.fsentryId]
* @param {string | null} [input.mode]
*/
async applyPending({ uid, holderUserId, fsentryId = null, mode = null }) {
if (!uid || !holderUserId) {
throw new Error('applyPending: uid and holderUserId are required');
@@ -240,6 +261,44 @@ export class ShareStore extends PuterStore {
return (result?.affectedRows ?? result?.changes ?? 0) > 0;
}
// -- Daily quota --------------------------------------------------
//
// Counted in KV rather than by querying `share`, because the ceiling is on
// shares *created* — rows the user later revoked still spent their budget,
// so a COUNT of live rows would let a script recycle the same slot forever.
/** @param {number} userId */
async getDailyShareCount(userId) {
const { res } = await this.stores.kv.get({
key: this.#dailyQuotaKey(userId),
});
const count = /** @type {{ count?: unknown } | null} */ (res)?.count;
return typeof count === 'number' ? count : 0;
}
/**
* @param {number} userId
* @param {number} [amount]
* @returns {Promise<number>} The count after incrementing
*/
async incrementDailyShareCount(userId, amount = 1) {
const { res } = await this.stores.kv.incr({
key: this.#dailyQuotaKey(userId),
pathAndAmountMap: { count: amount },
// Two days, so a counter written just before midnight still ages
// out on its own rather than lingering for the next reader.
expireAt: Math.floor(Date.now() / 1000) + 2 * 24 * 60 * 60,
});
const count = /** @type {{ count?: unknown } | null} */ (res)?.count;
return typeof count === 'number' ? count : amount;
}
/** @param {number} userId */
#dailyQuotaKey(userId) {
const day = new Date().toISOString().slice(0, 10);
return `share:quota:${userId}:${day}`;
}
// -- Internals ----------------------------------------------------
#normalizeRow(row) {
+15
View File
@@ -817,6 +817,21 @@ interface IConfigOptional {
/** When true, ACL grants read/list/see on `/<user>/Public` to any actor. */
enable_public_folders: boolean;
/**
* Ceiling on how many shares one user may create per UTC day. An abuse
* bound, not an accounting one — it exists so a script can't blanket other
* accounts with unwanted items and the notifications that follow. Omit to
* use the built-in default.
*/
share_daily_limit?: number;
/**
* Ceiling on recipients, and on items, in a single share request. Bounds
* the fan-out one call can trigger; the daily limit bounds the total.
*/
share_max_recipients?: number;
share_max_items?: number;
// -- Storage / S3 ------------------------------------------------
/** S3 storage config (local fauxqs or remote). */