mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 15:07:17 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user