diff --git a/src/backend/controllers/share/ShareController.http.test.ts b/src/backend/controllers/share/ShareController.http.test.ts new file mode 100644 index 000000000..5bf58e7c9 --- /dev/null +++ b/src/backend/controllers/share/ShareController.http.test.ts @@ -0,0 +1,232 @@ +/* + * 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 { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; + +/** + * Route-level coverage for the sharing endpoints. The service unit tests drive + * the semantics; this suite exists to catch a route that was never registered, + * a gate that rejects a legitimate request, and anything the response shape + * leaks. + */ +describe('share endpoints over HTTP', () => { + let env: PuterTestEnv; + + beforeAll(async () => { + env = await setupPuterTestEnv(); + }, 120_000); + + afterAll(async () => { + await env?.shutdown(); + }); + + const post = (path: string, token: string, body: unknown) => + fetch(new URL(path, env.apiOrigin), { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + const get = (path: string, token: string, params: Record) => { + const url = new URL(path, env.apiOrigin); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + return fetch(url, { headers: { authorization: `Bearer ${token}` } }); + }; + + /** A file in the owner's home. Written directly — these tests are about + * the share routes, not the upload path. */ + const makeFile = async (owner: { username: string }) => { + const uid = crypto.randomUUID(); + const name = `share-http-${uid.slice(0, 8)}.txt`; + const path = `/${owner.username}/${name}`; + const user = await env.server.stores.user.getByUsername(owner.username); + await env.server.clients.db.write( + 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, 0, ?)', + [uid, name, path, user!.id, Math.floor(Date.now() / 1000)], + ); + return { uid, path }; + }; + + it('shares an item, lists it for the recipient, then revokes it', async () => { + const owner = env.users.user; + const recipient = env.users.other; + const file = await makeFile(owner); + + const shareRes = await post('/share', owner.token, { + recipients: [recipient.username], + items: [{ uid: file.uid }], + mode: 'read', + }); + expect(shareRes.status).toBe(200); + const shareBody = (await shareRes.json()) as { + status: string; + results: Array<{ status: string; mode?: string }>; + }; + expect(shareBody.status).toBe('success'); + expect(shareBody.results[0].mode).toBe('read'); + + const listRes = await get( + '/share/shared-with-me', + recipient.token, + { includeTotal: 'true' }, + ); + expect(listRes.status).toBe(200); + const listed = (await listRes.json()) as { + items: Array>; + total?: number; + }; + const row = listed.items.find((i) => i.uid_entry === file.uid); + expect(row).toBeDefined(); + expect(row?.issuer).toBe(owner.username); + expect(row?.mode).toBe('read'); + expect(typeof listed.total).toBe('number'); + + // Nothing internal rides along in the response. + for (const key of ['issuer_user_id', 'holder_user_id', 'fsentry_id']) { + expect(row).not.toHaveProperty(key); + } + + const revokeRes = await post('/share/revoke', owner.token, { + recipients: [recipient.username], + items: [{ uid: file.uid }], + }); + expect(revokeRes.status).toBe(200); + expect(await revokeRes.json()).toMatchObject({ revoked: 1 }); + + const afterRes = await get('/share/shared-with-me', recipient.token, {}); + const after = (await afterRes.json()) as { + items: Array>; + }; + expect(after.items.find((i) => i.uid_entry === file.uid)).toBeUndefined(); + }); + + it('reports per-pair outcomes when only some recipients resolve', async () => { + const owner = env.users.user; + const file = await makeFile(owner); + + const res = await post('/share', owner.token, { + recipients: [env.users.other.username, 'nosuchuser-zzz'], + items: [{ uid: file.uid }], + mode: 'read', + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + status: string; + results: Array<{ status: string; recipient: string }>; + }; + expect(body.status).toBe('mixed'); + expect(body.results).toHaveLength(2); + expect( + body.results.find((r) => r.recipient === 'nosuchuser-zzz')?.status, + ).toBe('error'); + }); + + it('lists who can reach an item for its owner', async () => { + const owner = env.users.user; + const recipient = env.users.other; + const file = await makeFile(owner); + + await post('/share', owner.token, { + recipients: [recipient.username], + items: [{ uid: file.uid }], + mode: 'write', + }); + + const res = await get('/share/shares', owner.token, { uid: file.uid }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + items: Array<{ holder: string; mode: string }>; + }; + expect(body.items).toHaveLength(1); + expect(body.items[0].holder).toBe(recipient.username); + expect(body.items[0].mode).toBe('write'); + }); + + it('hides an item from a stranger asking who can reach it', async () => { + const owner = env.users.user; + const file = await makeFile(owner); + + const res = await get('/share/shares', env.users.other.token, { + uid: file.uid, + }); + expect(res.status).toBe(404); + }); + + it('rejects an unauthenticated share', async () => { + const res = await fetch(new URL('/share', env.apiOrigin), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ recipients: ['x'], items: ['y'] }), + }); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); + + it('caps how many recipients one request can reach', async () => { + const owner = env.users.user; + const file = await makeFile(owner); + const many = Array.from({ length: 64 }, (_, i) => `user-${i}`); + + const res = await post('/share', owner.token, { + recipients: many, + items: [{ uid: file.uid }], + mode: 'read', + }); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + code: 'too_many_recipients', + }); + }); + + it('caps how many items one request can carry', async () => { + const owner = env.users.user; + const many = Array.from({ length: 128 }, () => ({ + uid: crypto.randomUUID(), + })); + + const res = await post('/share', owner.token, { + recipients: [env.users.other.username], + items: many, + mode: 'read', + }); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ code: 'too_many_items' }); + }); + + it('rejects a request with no recipients or no items', async () => { + const owner = env.users.user; + const file = await makeFile(owner); + + expect( + (await post('/share', owner.token, { items: [{ uid: file.uid }] })) + .status, + ).toBe(400); + expect( + ( + await post('/share', owner.token, { + recipients: [env.users.other.username], + }) + ).status, + ).toBe(400); + }); +}); diff --git a/src/backend/controllers/share/ShareController.ts b/src/backend/controllers/share/ShareController.ts index 03a8661fa..35220c61e 100644 --- a/src/backend/controllers/share/ShareController.ts +++ b/src/backend/controllers/share/ShareController.ts @@ -17,381 +17,383 @@ * along with this program. If not, see . */ -// import type { Request, Response } from 'express'; -// import { HttpError } from '../../core/http/HttpError.js'; -import type { PuterRouter } from '../../core/http/PuterRouter.js'; +import type { Request, Response } from 'express'; +import type { Actor } from '../../core/actor.js'; +import { Controller, Get, Post } from '../../core/http/decorators.js'; +import { HttpError } from '../../core/http/HttpError.js'; +import type { + ResolvedShare, + ShareRecipient, + ShareTarget, +} from '../../services/share/ShareService.js'; +import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js'; +import { normalizeLimit } from '../../util/pagination.js'; import { PuterController } from '../types.js'; -// const SHARE_TOKEN_TYPE = 'share'; -// const SHARE_TOKEN_EXPIRY = '14d'; +/** + * Two windows: a burst ceiling, and a daily one so a slow drip can't add up to + * a mail-merge. Neither bounds _shares_ — one request carries many — which is + * what `ShareService`'s per-day quota is for. + */ +const SHARE_LIMIT = [ + { scope: 'share:mutate', limit: 60, window: 60_000, key: 'user' as const }, + { + scope: 'share:mutate-daily', + limit: 500, + window: 24 * 60 * 60_000, + key: 'user' as const, + }, +]; + +const SHARE_LIST_LIMIT = { + scope: 'share:list', + limit: 600, + window: 60_000, + key: 'user' as const, +}; + +/** Distinct (holder, item) pairs run together; see the note on grouping below. */ +const SHARE_CONCURRENCY = 8; +const LIST_LIMIT_CAP = 200; /** - * Share link endpoints — check, apply, and request access to pending shares. - * The main `POST /share` creation endpoint is also here. - * - * Shares are permission grants addressed to an email. When the recipient - * doesn't have a Puter account yet, the share row lives in the `share` table - * until they sign up and apply it. When they DO have an account, permissions - * are granted immediately and no row is stored. + * Caps on one request's fan-out. Recipients matter most: that number is how + * many people a single call can reach, so it stays small by default and only + * moves by configuration. */ +export const DEFAULT_MAX_RECIPIENTS = 10; +export const DEFAULT_MAX_ITEMS = 50; + +interface ShareOutcome { + recipient: string; + path?: string; + status: 'success' | 'error'; + mode?: string; + message?: string; + code?: string; +} + +/** + * Sharing endpoints. `ShareService` owns the semantics; this layer parses + * input, bounds fan-out, and shapes responses. + */ +@Controller('/share') export class ShareController extends PuterController { - registerRoutes(_router: PuterRouter): void { - // const api = { subdomain: 'api' } as const; - // router.post('/sharelink/check', api, this.#check); - // router.post( - // '/sharelink/apply', - // { ...api, requireAuth: true }, - // this.#apply, - // ); - // router.post( - // '/sharelink/request', - // { ...api, requireAuth: true }, - // this.#request, - // ); - // router.post('/share', { ...api, requireAuth: true }, this.#share); + /** + * POST /share — grant `mode` on one or more items to one or more + * recipients. Partial success is the contract: each pair reports its own + * outcome and the envelope summarizes. + */ + @Post('', { + subdomain: 'api', + requireVerified: true, + requireUserActor: true, + rateLimit: SHARE_LIMIT, + }) + async createShares(req: Request, res: Response): Promise { + const actor = this.#requireActor(req); + const body = this.#body(req); + const recipients = this.#recipients(body); + const items = this.#items(body); + const mode = typeof body.mode === 'string' ? body.mode : 'read'; + + // Every (recipient, item) pair is a distinct (holder, entry) key, so + // they can run together. Two writes to the *same* pair could not — + // setUserUser is a read-modify-write. + const pairs = recipients.flatMap((recipient) => + items.map((item) => ({ recipient, item })), + ); + + const settled = await runWithConcurrencyLimitSettled( + pairs, + SHARE_CONCURRENCY, + async ({ recipient, item }) => { + const share = await this.services.share.share(actor, { + ...item, + recipient, + mode: mode as never, + }); + return share; + }, + ); + + const results: ShareOutcome[] = settled.map((outcome, index) => { + const { recipient, item } = pairs[index]; + const label = recipient.email ?? recipient.username ?? ''; + if (outcome.status === 'fulfilled') { + const share = outcome.value as ResolvedShare; + return { + recipient: label, + path: share.path, + status: 'success', + mode: share.mode, + }; + } + return { + recipient: label, + ...(item.path ? { path: item.path } : {}), + status: 'error', + ...this.#errorShape(outcome.reason), + }; + }); + + this.#notifyRecipients(actor, settled, pairs); + + const succeeded = results.filter((r) => r.status === 'success').length; + res.json({ + status: + succeeded === results.length + ? 'success' + : succeeded > 0 + ? 'mixed' + : 'aborted', + results, + }); } - // // -- POST /sharelink/check --------------------------------------- - // // Public — verify a share token from an email link. + /** POST /share/revoke — withdraw a recipient's access to an item. */ + @Post('/revoke', { + subdomain: 'api', + requireVerified: true, + requireUserActor: true, + rateLimit: SHARE_LIMIT, + }) + async revokeShare(req: Request, res: Response): Promise { + const actor = this.#requireActor(req); + const body = this.#body(req); + const [recipient] = this.#recipients(body); + const [item] = this.#items(body); - // #check = async (req: Request, res: Response): Promise => { - // const token = req.body?.token; - // if (typeof token !== 'string' || token.length === 0) { - // throw new HttpError(400, 'Missing `token`'); - // } + const result = await this.services.share.unshare(actor, { + ...item, + recipient, + }); + res.json({ status: 'success', revoked: result.revoked }); + } - // let decoded: { uid?: string; type?: string }; - // try { - // decoded = this.services.token.verify(SHARE_TOKEN_TYPE, token); - // } catch { - // throw new HttpError(400, 'Invalid or expired share token'); - // } - // if (decoded.type !== `token:${SHARE_TOKEN_TYPE}` || !decoded.uid) { - // throw new HttpError(400, 'Invalid share token'); - // } + /** + * GET /share/shared-with-me — paginated listing of what others have shared + * with the caller. + */ + @Get('/shared-with-me', { + subdomain: 'api', + requireVerified: true, + requireUserActor: true, + rateLimit: SHARE_LIST_LIMIT, + }) + async listSharedWithMe(req: Request, res: Response): Promise { + const actor = this.#requireActor(req); + const query = this.#query(req); - // const share = await this.stores.share.getByUid(decoded.uid); - // if (!share) throw new HttpError(404, 'Share not found or expired'); + const page = await this.services.share.listSharedWithMe(actor, { + limit: normalizeLimit(query.limit, { cap: LIST_LIMIT_CAP }), + cursor: typeof query.cursor === 'string' ? query.cursor : undefined, + includeTotal: query.includeTotal === 'true', + }); - // res.json({ - // $: 'api:share', - // uid: share.uid, - // email: share.recipient_email, - // }); - // }; + res.json({ + items: page.items.map((share) => this.#toClientShare(share)), + ...(page.cursor ? { cursor: page.cursor } : {}), + ...(page.total !== undefined ? { total: page.total } : {}), + }); + } - // // -- POST /sharelink/apply --------------------------------------- - // // Auth required — apply a pending share's permissions to the caller. + /** GET /share/shares — who can reach one item. */ + @Get('/shares', { + subdomain: 'api', + requireVerified: true, + requireUserActor: true, + rateLimit: SHARE_LIST_LIMIT, + }) + async listSharesOf(req: Request, res: Response): Promise { + const actor = this.#requireActor(req); + const query = this.#query(req); + const target: ShareTarget = {}; + if (typeof query.uid === 'string') target.uid = query.uid; + if (typeof query.path === 'string') target.path = query.path; + if (!target.uid && !target.path) { + throw new HttpError(400, 'one of `uid` or `path` is required', { + legacyCode: 'bad_request', + }); + } - // #apply = async (req: Request, res: Response): Promise => { - // const uid = req.body?.uid; - // if (typeof uid !== 'string') throw new HttpError(400, 'Missing `uid`'); + const shares = await this.services.share.listSharesOf(actor, target); + res.json({ items: shares.map((share) => this.#toClientShare(share)) }); + } - // const actor = req.actor; - // if (!actor?.user) throw new HttpError(401, 'Unauthorized'); + // -- Helpers ------------------------------------------------------ - // const share = await this.stores.share.getByUid(uid); - // if (!share) throw new HttpError(404, 'Share not found or expired'); + /** + * Only ever the username — never the internal id, and never an email the + * caller didn't already supply. + */ + #toClientShare(share: ResolvedShare) { + return { + uid: share.uid, + mode: share.mode, + path: share.path, + uid_entry: share.entryUid, + is_dir: share.isDir, + issuer: share.issuer.username, + holder: share.holder.username, + created_at: share.createdAt, + }; + } - // // Issuer must still exist - // const issuer = await this.stores.user.getById(share.issuer_user_id); - // if (!issuer) - // throw new HttpError(410, 'Share expired — issuer account gone'); + #requireActor(req: Request): Actor { + const actor = req.actor; + if (!actor?.user) + throw new HttpError(401, 'Unauthorized', { + legacyCode: 'unauthorized', + }); + return actor; + } - // // Email must be confirmed - // if ( - // actor.user.requires_email_confirmation && - // !actor.user.email_confirmed - // ) { - // throw new HttpError( - // 403, - // 'Please confirm your email before applying shares', - // ); - // } + #body(req: Request): Record { + const body = req.body; + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new HttpError(400, 'body must be an object', { + legacyCode: 'bad_request', + }); + } + return body as Record; + } - // // Recipient email must match - // if ( - // !actor.user.email || - // actor.user.email.toLowerCase() !== - // share.recipient_email.toLowerCase() - // ) { - // throw new HttpError( - // 403, - // 'This share was sent to a different email address', - // ); - // } + #query(req: Request): Record { + return (req.query ?? {}) as Record; + } - // // Grant each permission - // const issuerActor = { - // user: { - // id: issuer.id, - // uuid: issuer.uuid, - // username: issuer.username, - // email: issuer.email ?? null, - // suspended: false, - // email_confirmed: true, - // requires_email_confirmation: false, - // }, - // } as import('../../core/actor.js').Actor; - // const data = (share.data ?? {}) as { - // permissions?: Array<{ - // permission: string; - // extra?: Record; - // }>; - // }; - // for (const perm of data.permissions ?? []) { - // try { - // await this.services.permission.grantUserUserPermission( - // issuerActor, - // actor.user.username ?? '', - // perm.permission, - // perm.extra ?? {}, - // ); - // } catch (err) { - // console.warn('[share] grant failed for', perm.permission, err); - // } - // } + #recipients(body: Record): ShareRecipient[] { + const raw = body.recipients ?? body.recipient; + const list = Array.isArray(raw) ? raw : [raw]; + const out: ShareRecipient[] = []; + for (const entry of list) { + if (typeof entry === 'string') { + const value = entry.trim(); + if (!value) continue; + out.push( + value.includes('@') + ? { email: value } + : { username: value }, + ); + continue; + } + if (entry && typeof entry === 'object') { + const rec = entry as Record; + const email = + typeof rec.email === 'string' ? rec.email.trim() : ''; + const username = + typeof rec.username === 'string' ? rec.username.trim() : ''; + if (email || username) { + out.push(email ? { email } : { username }); + } + } + } + if (out.length === 0) { + throw new HttpError(400, '`recipients` is required', { + legacyCode: 'bad_request', + }); + } + const max = this.config.share_max_recipients ?? DEFAULT_MAX_RECIPIENTS; + if (out.length > max) { + throw new HttpError(400, `at most ${max} recipients per request`, { + legacyCode: 'too_many_recipients', + }); + } + return out; + } - // // Share consumed — delete it - // await this.stores.share.deleteByUid(uid); + #items(body: Record): ShareTarget[] { + const raw = body.items ?? body.item ?? body.path ?? body.uid; + const list = Array.isArray(raw) ? raw : [raw]; + const out: ShareTarget[] = []; + for (const entry of list) { + if (typeof entry === 'string') { + const value = entry.trim(); + if (!value) continue; + out.push( + value.startsWith('/') ? { path: value } : { uid: value }, + ); + continue; + } + if (entry && typeof entry === 'object') { + const item = entry as Record; + const path = typeof item.path === 'string' ? item.path : ''; + const uid = typeof item.uid === 'string' ? item.uid : ''; + if (path || uid) out.push(path ? { path } : { uid }); + } + } + if (out.length === 0) { + throw new HttpError(400, '`items` is required', { + legacyCode: 'bad_request', + }); + } + const max = this.config.share_max_items ?? DEFAULT_MAX_ITEMS; + if (out.length > max) { + throw new HttpError(400, `at most ${max} items per request`, { + legacyCode: 'too_many_items', + }); + } + return out; + } - // res.json({ $: 'api:status-report', status: 'success' }); - // }; + /** + * Report a failure without widening what the caller already knew. The + * service already decides 404-vs-403; anything unrecognized becomes a + * generic error rather than leaking an internal message. + */ + #errorShape(reason: unknown): { message: string; code?: string } { + const err = reason as { + statusCode?: number; + message?: string; + fields?: { code?: string }; + }; + if (typeof err?.statusCode === 'number' && err.statusCode < 500) { + return { + message: err.message ?? 'Request failed', + ...(err.fields?.code ? { code: err.fields.code } : {}), + }; + } + return { message: 'Request failed' }; + } - // // -- POST /sharelink/request ------------------------------------- - // // Auth required — notify the issuer that someone is requesting access. + /** + * One notification per recipient who gained access, off the response path — + * a share must not fail because its notification didn't land. + */ + #notifyRecipients( + actor: Actor, + settled: PromiseSettledResult[], + pairs: Array<{ recipient: ShareRecipient; item: ShareTarget }>, + ): void { + const byRecipient = new Map(); + settled.forEach((outcome, index) => { + if (outcome.status !== 'fulfilled') return; + const label = + pairs[index].recipient.email ?? + pairs[index].recipient.username ?? + ''; + byRecipient.set(label, (byRecipient.get(label) ?? 0) + 1); + }); + if (byRecipient.size === 0) return; - // #request = async (req: Request, res: Response): Promise => { - // const uid = req.body?.uid; - // if (typeof uid !== 'string') throw new HttpError(400, 'Missing `uid`'); - - // const actor = req.actor; - // if (!actor?.user) throw new HttpError(401, 'Unauthorized'); - - // const share = await this.stores.share.getByUid(uid); - // if (!share) throw new HttpError(404, 'Share not found or expired'); - - // const issuer = await this.stores.user.getById(share.issuer_user_id); - // if (!issuer) - // throw new HttpError(410, 'Share expired — issuer account gone'); - - // // If caller IS the intended recipient (confirmed email matches), - // // they should just /apply instead. - // if ( - // actor.user.email_confirmed && - // actor.user.email?.toLowerCase() === - // share.recipient_email.toLowerCase() - // ) { - // throw new HttpError( - // 400, - // 'You are the intended recipient — use /sharelink/apply instead', - // ); - // } - - // // Notify the issuer - // if (this.services.notification) { - // await this.services.notification.notify([issuer.id], { - // source: 'sharing', - // title: `User ${actor.user.username} is trying to open a share you sent to ${share.recipient_email}`, - // template: 'user-requesting-share', - // fields: { - // username: actor.user.username, - // intended_recipient: share.recipient_email, - // permissions: - // (share.data as Record)?.permissions ?? - // [], - // }, - // }); - // } - - // res.json({ $: 'api:status-report', status: 'success' }); - // }; - - // // -- POST /share ------------------------------------------------- - // // Auth required — create shares for recipients (users or emails). - - // #share = async (req: Request, res: Response): Promise => { - // const actor = req.actor; - // if (!actor?.user) throw new HttpError(401, 'Unauthorized'); - - // const body = req.body ?? {}; - // let recipients = body.recipients; - // let shares = body.shares; - // const dryRun = !!body.dry_run; - - // if (!recipients) throw new HttpError(400, 'Missing `recipients`'); - // if (!shares) throw new HttpError(400, 'Missing `shares`'); - // if (!Array.isArray(recipients)) recipients = [recipients]; - // if (!Array.isArray(shares)) shares = [shares]; - - // // Build the permissions list from share declarations. - // const permissions = this.#resolvePermissions(shares as unknown[]); - - // const recipientResults: unknown[] = []; - - // for (const recipient of recipients as unknown[]) { - // const recipientStr = - // typeof recipient === 'string' ? recipient.trim() : ''; - // if (!recipientStr) { - // recipientResults.push({ - // $: 'error', - // message: 'empty recipient', - // }); - // continue; - // } - - // try { - // // Try username first - // const targetUser = - // (await this.stores.user.getByUsername(recipientStr)) ?? - // (recipientStr.includes('@') - // ? await this.stores.user.getByEmail(recipientStr) - // : null); - - // if (targetUser) { - // // Direct grant — user exists - // if (!dryRun) { - // for (const perm of permissions) { - // try { - // await this.services.permission.grantUserUserPermission( - // actor, - // targetUser.username ?? '', - // perm.permission, - // perm.extra ?? {}, - // ); - // } catch (err) { - // console.warn( - // '[share] grant to user failed', - // perm.permission, - // err, - // ); - // } - // } - - // // Notify - // if (this.services.notification) { - // await this.services.notification.notify( - // [targetUser.id], - // { - // source: 'sharing', - // title: `${actor.user.username} shared items with you`, - // template: 'file-shared-with-you', - // fields: { - // username: actor.user.username, - // permissions: permissions.map( - // (p) => p.permission, - // ), - // }, - // }, - // ); - // } - // } - // recipientResults.push({ - // $: 'api:status-report', - // status: 'success', - // }); - // } else if (recipientStr.includes('@')) { - // // Email recipient — store pending share - // if (!dryRun) { - // const share = await this.stores.share.create({ - // issuerUserId: actor.user.id, - // recipientEmail: recipientStr.toLowerCase(), - // data: { - // permissions, - // metadata: body.metadata ?? {}, - // }, - // }); - - // // Sign a share token (14-day expiry) - // const token = this.services.token.sign( - // SHARE_TOKEN_TYPE, - // { - // type: `token:${SHARE_TOKEN_TYPE}`, - // uid: share.uid, - // }, - // { expiresIn: SHARE_TOKEN_EXPIRY }, - // ); - - // // Email the share link - // const origin = `https://${this.config.domain ?? 'puter.com'}`; - // try { - // await this.clients.email.sendRaw({ - // to: recipientStr, - // subject: `${actor.user.username} shared something with you on Puter`, - // html: `

${actor.user.username} shared items with you.

Click here to accept

`, - // }); - // } catch (err) { - // console.warn('[share] email send failed', err); - // } - // } - // recipientResults.push({ - // $: 'api:status-report', - // status: 'success', - // }); - // } else { - // recipientResults.push({ - // $: 'error', - // message: 'User not found', - // }); - // } - // } catch (err) { - // recipientResults.push({ $: 'error', message: String(err) }); - // } - // } - - // const allOk = recipientResults.every( - // (r: unknown) => (r as Record).status === 'success', - // ); - // const anyOk = recipientResults.some( - // (r: unknown) => (r as Record).status === 'success', - // ); - - // res.json({ - // $: 'api:share', - // $version: 'v0.0.0', - // status: allOk ? 'success' : anyOk ? 'mixed' : 'aborted', - // recipients: recipientResults, - // ...(dryRun ? { dry_run: true } : {}), - // }); - // }; - - // // -- Helpers ------------------------------------------------------ - - // /** - // * Convert share declarations into a flat permission list. - // * Supports `fs-share` ({ path, access }) and `app-share` ({ uid, name }). - // */ - // #resolvePermissions( - // shares: unknown[], - // ): Array<{ permission: string; extra?: Record }> { - // const perms: Array<{ - // permission: string; - // extra?: Record; - // }> = []; - - // for (const share of shares) { - // if (!share || typeof share !== 'object') continue; - // const s = share as Record; - - // if (s.$ === 'fs-share' || s.type === 'fs-share' || s.path) { - // const path = String(s.path ?? ''); - // const access = String(s.access ?? 'read'); - // if (path) { - // perms.push({ permission: `fs:${path}:${access}` }); - // } - // } else if ( - // s.$ === 'app-share' || - // s.type === 'app-share' || - // s.uid || - // s.name - // ) { - // const appUid = String(s.uid ?? s.name ?? ''); - // if (appUid) { - // perms.push({ permission: `app:uid#${appUid}:access` }); - // } - // } - // } - - // return perms; - // } + void (async () => { + try { + for (const [label, count] of byRecipient) { + const user = label.includes('@') + ? await this.stores.user.getByEmail(label) + : await this.stores.user.getByUsername(label); + if (!user) continue; + await this.services.notification.notify([user.id], { + source: 'sharing', + title: `${actor.user.username} shared ${count === 1 ? 'an item' : `${count} items`} with you`, + template: 'file-shared-with-you', + fields: { username: actor.user.username, count }, + }); + } + } catch { + // Never fail a completed share over its notification. + } + })(); + } }