From 33fb7c4b686895b92f7207f536fa5665b890c341 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Wed, 12 Aug 2026 17:38:49 -0400 Subject: [PATCH] feat(share): add ShareService with a per-day share limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 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. --- src/backend/services/index.ts | 5 + .../services/share/ShareService.test.ts | 539 +++++++++++++++++ src/backend/services/share/ShareService.ts | 555 ++++++++++++++++++ src/backend/stores/share/ShareStore.js | 59 ++ src/backend/types.ts | 15 + 5 files changed, 1173 insertions(+) create mode 100644 src/backend/services/share/ShareService.test.ts create mode 100644 src/backend/services/share/ShareService.ts diff --git a/src/backend/services/index.ts b/src/backend/services/index.ts index 15d3714c0..07557f96f 100644 --- a/src/backend/services/index.ts +++ b/src/backend/services/index.ts @@ -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:` before any diff --git a/src/backend/services/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts new file mode 100644 index 000000000..8816a610c --- /dev/null +++ b/src/backend/services/share/ShareService.test.ts @@ -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 . + */ + +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) => + runWithContext({ actor }, () => + server.services.share.share(actor, input as never), + ); + + const unshare = (actor: Actor, input: Record) => + 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) => { + 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); + }); +}); diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts new file mode 100644 index 000000000..e3d8b3809 --- /dev/null +++ b/src/backend/services/share/ShareService.ts @@ -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 . + */ + +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 = 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:` grant. + */ +export class ShareService extends PuterService { + declare protected services: LayerInstances; + + // -- 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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:`, 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 { + 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 { + try { + await this.#revokeFor(actor, entry, username, issuerUserId); + } catch { + // Already failing the request; don't mask the original error. + } + } +} diff --git a/src/backend/stores/share/ShareStore.js b/src/backend/stores/share/ShareStore.js index b3c80ef06..d2987d193 100644 --- a/src/backend/stores/share/ShareStore.js +++ b/src/backend/stores/share/ShareStore.js @@ -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} 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) { diff --git a/src/backend/types.ts b/src/backend/types.ts index 518f1209c..33c7d7888 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -817,6 +817,21 @@ interface IConfigOptional { /** When true, ACL grants read/list/see on `//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). */