feat(share): expose sharing over HTTP

POST /share, POST /share/revoke, GET /share/shared-with-me, GET /share/shares.
The controller was registered but entirely commented out.

Recipients × items fan out concurrently — every pair is a distinct
(holder, entry) key, so none of them contend — bounded by
runWithConcurrencyLimitSettled, which returns results index-aligned with the
input for the per-pair outcome list. Responses carry usernames only, never
internal ids, and the 404-not-403 rule is preserved so a failed call cannot
confirm a file the caller could not otherwise see. Notifications are fired off
the response path; a share must not fail over its own notification.

Per-request caps on recipients and items bound one call's fan-out; the daily
limit bounds the total.
This commit is contained in:
Juan Castro
2026-08-12 17:39:20 -04:00
parent 33fb7c4b68
commit f9c9daea87
2 changed files with 587 additions and 353 deletions
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<string, string>) => {
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<Record<string, unknown>>;
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<Record<string, unknown>>;
};
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);
});
});
+355 -353
View File
@@ -17,381 +17,383 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// 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<void> {
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<void> {
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<void> => {
// 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<void> {
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<void> {
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<void> => {
// 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<string, unknown> {
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<string, unknown>;
}
// // 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<string, unknown> {
return (req.query ?? {}) as Record<string, unknown>;
}
// // 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<string, unknown>;
// }>;
// };
// 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<string, unknown>): 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<string, unknown>;
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<string, unknown>): 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<string, unknown>;
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<unknown>[],
pairs: Array<{ recipient: ShareRecipient; item: ShareTarget }>,
): void {
const byRecipient = new Map<string, number>();
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<void> => {
// 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<string, unknown>)?.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<void> => {
// 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: `<p>${actor.user.username} shared items with you.</p><p><a href="${origin}?share_token=${encodeURIComponent(token)}">Click here to accept</a></p>`,
// });
// } 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<string, unknown>).status === 'success',
// );
// const anyOk = recipientResults.some(
// (r: unknown) => (r as Record<string, unknown>).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<string, unknown> }> {
// const perms: Array<{
// permission: string;
// extra?: Record<string, unknown>;
// }> = [];
// for (const share of shares) {
// if (!share || typeof share !== 'object') continue;
// const s = share as Record<string, unknown>;
// 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.
}
})();
}
}