mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-05 11:28:26 +00:00
* fix: charge stat's return_shares against the share-listing budget `return_shares` on /fs/stat and the legacy /stat runs the same work as GET /share/shares, but was only metered under fs:stat's far more generous limit — and the two scopes stacked instead of sharing one counter. Adds consumeRouteRateLimit(req, spec), an imperative charge that resolves the key and per-subscription limit exactly as rateLimitGate does, so a handler can conditionally spend a second scope when a request flag makes the route expensive. Both stat handlers now charge share:list before doing the listing work; SHARE_LIST_LIMIT moves to a shared share/limits.ts so all callers pin the same spec. Closes PUT-1597. * feat: consumeRouteRateLimit takes the array spec form too Review follow-up on #3870: a multi-window spec passed whole would have read an undefined window and silently never pruned. Charge each window in order instead, refusing on the first refusal, matching the gate.
763 lines
28 KiB
TypeScript
763 lines
28 KiB
TypeScript
/*
|
|
* Copyright (C) 2024-present Puter Technologies Inc.
|
|
*
|
|
* This file is part of Puter.
|
|
*
|
|
* Puter is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import type { Request, Response } from 'express';
|
|
import type { Actor } from '../../core/actor.js';
|
|
import { Controller, Delete, Get, Post } from '../../core/http/decorators.js';
|
|
import { HttpError, isHttpError } from '../../core/http/HttpError.js';
|
|
import type {
|
|
ResolvedShare,
|
|
ShareRecipient,
|
|
ShareTarget,
|
|
} from '../../services/share/ShareService.js';
|
|
import { expandTildePath } from '../../services/fs/resolveNode.js';
|
|
import { toClientShare } from './clientShare.js';
|
|
import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
|
|
import { normalizeLimit } from '../../util/pagination.js';
|
|
import { PuterController } from '../types.js';
|
|
import { SHARE_LIMIT, SHARE_LIST_LIMIT } from './limits.js';
|
|
|
|
/** Distinct (holder, item) pairs run together; see the note on grouping below. */
|
|
const SHARE_CONCURRENCY = 8;
|
|
const LIST_LIMIT_CAP = 200;
|
|
|
|
/** `appUid` value asking for the grants no app issued. App uids are uuids. */
|
|
const NO_APP = 'none';
|
|
|
|
/**
|
|
* 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;
|
|
|
|
/**
|
|
* A success carries the created share; a failure carries why. `pending` is an
|
|
* invite: recorded, but not access anyone holds yet.
|
|
*/
|
|
interface ShareOutcome {
|
|
recipient: string;
|
|
status: 'success' | 'error' | 'pending';
|
|
path?: string;
|
|
uid?: string;
|
|
mode?: string;
|
|
uid_entry?: string;
|
|
is_dir?: boolean;
|
|
issuer?: string | null;
|
|
holder?: string | null;
|
|
created_at?: unknown;
|
|
message?: string;
|
|
code?: string;
|
|
}
|
|
|
|
/**
|
|
* Sharing endpoints. `ShareService` owns the semantics; this layer parses
|
|
* input, bounds fan-out, and shapes responses.
|
|
*
|
|
* Apps and tokens are admitted rather than gated out, because `ShareService`
|
|
* bounds them properly: authority to share comes from the user behind the
|
|
* actor, and the actor must additionally reach the node in its own right. An
|
|
* app therefore shares its own AppData and the files it was given, and nothing
|
|
* else its user happens to own.
|
|
*/
|
|
@Controller('/share')
|
|
export class ShareController extends PuterController {
|
|
/**
|
|
* 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.
|
|
*
|
|
* Handing out access is the one share surface that reaches other people, so
|
|
* it asks for a verified phone or card first; withdrawing and listing never
|
|
* do — a caller must always be able to see and undo what it shared.
|
|
*/
|
|
@Post('', {
|
|
subdomain: 'api',
|
|
requireVerified: true,
|
|
requireAnyVerified: ['phone', 'card'],
|
|
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, actor);
|
|
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, and a duplicated invite races
|
|
// itself into two pending rows — so duplicates in the request execute
|
|
// once and every original position reports that one outcome.
|
|
const pairs = recipients.flatMap((recipient) =>
|
|
items.map((item) => ({ recipient, item })),
|
|
);
|
|
const { unique, indexOf } = this.#dedupePairs(pairs);
|
|
|
|
const settledUnique = await runWithConcurrencyLimitSettled(
|
|
unique,
|
|
SHARE_CONCURRENCY,
|
|
async ({ recipient, item }) => {
|
|
const share = await this.services.share.share(actor, {
|
|
...item,
|
|
recipient,
|
|
mode: mode as never,
|
|
});
|
|
return share;
|
|
},
|
|
);
|
|
const settled = indexOf.map((i) => settledUnique[i]);
|
|
|
|
const results: ShareOutcome[] = await Promise.all(
|
|
settled.map(async (outcome, index) => {
|
|
const { recipient, item } = pairs[index];
|
|
const label = this.#recipientLabel(recipient);
|
|
if (outcome.status === 'fulfilled') {
|
|
// The whole share, not just an acknowledgement, so a caller
|
|
// needn't re-read to learn what it created.
|
|
const share = outcome.value as ResolvedShare;
|
|
return {
|
|
...(await this.#toClientShare(share)),
|
|
recipient: label,
|
|
status: share.pending ? 'pending' : 'success',
|
|
};
|
|
}
|
|
return {
|
|
recipient: label,
|
|
...(item.path ? { path: item.path } : {}),
|
|
status: 'error',
|
|
...this.#errorShape(outcome.reason),
|
|
};
|
|
}),
|
|
);
|
|
|
|
// Off the response path: a share that landed must not be reported as
|
|
// failed because telling the recipient didn't. Fanned out from the
|
|
// unique outcomes, so a duplicated pair is not counted twice.
|
|
void this.services.shareNotification.notifyShared(
|
|
actor,
|
|
settledUnique
|
|
.filter((o) => o.status === 'fulfilled')
|
|
.map((o) => (o as PromiseFulfilledResult<ResolvedShare>).value),
|
|
);
|
|
|
|
const succeeded = results.filter((r) => r.status !== 'error').length;
|
|
res.json({
|
|
status:
|
|
succeeded === results.length
|
|
? 'success'
|
|
: succeeded > 0
|
|
? 'mixed'
|
|
: 'aborted',
|
|
results,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* POST /share/revoke — withdraw recipients' access to items. Same fan-out
|
|
* contract as POST /share: every (recipient, item) pair is its own revoke
|
|
* with its own outcome. Silently dropping pairs after the first would leave
|
|
* access standing that the caller believes is gone.
|
|
*/
|
|
@Post('/revoke', {
|
|
subdomain: 'api',
|
|
requireVerified: true,
|
|
rateLimit: SHARE_LIMIT,
|
|
})
|
|
async revokeShare(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, actor);
|
|
|
|
const pairs = recipients.flatMap((recipient) =>
|
|
items.map((item) => ({ recipient, item })),
|
|
);
|
|
const { unique, indexOf } = this.#dedupePairs(pairs);
|
|
|
|
const settledUnique = await runWithConcurrencyLimitSettled(
|
|
unique,
|
|
SHARE_CONCURRENCY,
|
|
({ recipient, item }) =>
|
|
this.services.share.unshare(actor, { ...item, recipient }),
|
|
);
|
|
const settled = indexOf.map((i) => settledUnique[i]);
|
|
|
|
// Totalled over the unique outcomes: a pair listed twice revoked its
|
|
// grants once.
|
|
const revoked = settledUnique.reduce(
|
|
(total, outcome) =>
|
|
outcome.status === 'fulfilled'
|
|
? total + outcome.value.revoked
|
|
: total,
|
|
0,
|
|
);
|
|
const results: ShareOutcome[] = settled.map((outcome, index) => {
|
|
const { recipient, item } = pairs[index];
|
|
const label = this.#recipientLabel(recipient);
|
|
if (outcome.status === 'fulfilled') {
|
|
return {
|
|
recipient: label,
|
|
...(item.path ? { path: item.path } : {}),
|
|
...(item.uid ? { uid: item.uid } : {}),
|
|
status: 'success',
|
|
};
|
|
}
|
|
return {
|
|
recipient: label,
|
|
...(item.path ? { path: item.path } : {}),
|
|
...(item.uid ? { uid: item.uid } : {}),
|
|
status: 'error',
|
|
...this.#errorShape(outcome.reason),
|
|
};
|
|
});
|
|
|
|
const succeeded = results.filter((r) => r.status === 'success').length;
|
|
res.json({
|
|
status:
|
|
succeeded === results.length
|
|
? 'success'
|
|
: succeeded > 0
|
|
? 'mixed'
|
|
: 'aborted',
|
|
revoked,
|
|
results,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* GET /share/shared-with-me — paginated listing of what others have shared
|
|
* with the caller.
|
|
*/
|
|
@Get('/shared-with-me', {
|
|
subdomain: 'api',
|
|
requireVerified: true,
|
|
rateLimit: SHARE_LIST_LIMIT,
|
|
})
|
|
async listSharedWithMe(req: Request, res: Response): Promise<void> {
|
|
await this.#listSharePage(req, res, (actor, opts) =>
|
|
this.services.share.listSharedWithMe(actor, opts),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* GET /share/shared-by-me — paginated listing of everything the caller has
|
|
* shared out. `GET /share/shares` answers this for one item at a time,
|
|
* which cannot answer it at all for a caller who doesn't know what to ask
|
|
* about.
|
|
*
|
|
* `appUid` narrows to one app's grants, or to `none` for the ones the user
|
|
* made themselves. An app token is bound to its own app regardless, so it
|
|
* only ever sees what it issued.
|
|
*/
|
|
@Get('/shared-by-me', {
|
|
subdomain: 'api',
|
|
requireVerified: true,
|
|
rateLimit: SHARE_LIST_LIMIT,
|
|
})
|
|
async listSharedByMe(req: Request, res: Response): Promise<void> {
|
|
const appUid = this.#appUidFilter(this.#query(req));
|
|
await this.#listSharePage(req, res, (actor, opts) =>
|
|
this.services.share.listSharedByMe(actor, { ...opts, appUid }),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Parse-and-shape shared by the paginated share listings, so the two cannot
|
|
* drift: same query contract in, same envelope out.
|
|
*/
|
|
async #listSharePage(
|
|
req: Request,
|
|
res: Response,
|
|
list: (
|
|
actor: Actor,
|
|
opts: { limit?: number; cursor?: string; includeTotal?: boolean },
|
|
) => Promise<{
|
|
items: ResolvedShare[];
|
|
cursor?: string;
|
|
total?: number;
|
|
}>,
|
|
): Promise<void> {
|
|
const actor = this.#requireActor(req);
|
|
const query = this.#query(req);
|
|
|
|
const page = await list(actor, {
|
|
limit: normalizeLimit(query.limit, { cap: LIST_LIMIT_CAP }),
|
|
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
|
|
includeTotal: query.includeTotal === 'true',
|
|
});
|
|
|
|
res.json({
|
|
items: await Promise.all(
|
|
page.items.map((share) => this.#toClientShare(share)),
|
|
),
|
|
...(page.cursor ? { cursor: page.cursor } : {}),
|
|
...(page.total !== undefined ? { total: page.total } : {}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* GET /share/shared-by-me/apps — which apps hold shares the caller made,
|
|
* with a count each. The way into `appUid` for someone who doesn't know
|
|
* which apps to ask about; the group with a null `appUid` is what they
|
|
* shared themselves.
|
|
*/
|
|
@Get('/shared-by-me/apps', {
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
requireVerified: true,
|
|
rateLimit: SHARE_LIST_LIMIT,
|
|
})
|
|
async listSharedByMeApps(req: Request, res: Response): Promise<void> {
|
|
const actor = this.#requireActor(req);
|
|
const query = this.#query(req);
|
|
|
|
const page = await this.services.share.listSharedByMeApps(actor, {
|
|
limit: normalizeLimit(query.limit, { cap: LIST_LIMIT_CAP }),
|
|
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
|
|
includeTotal: query.includeTotal === 'true',
|
|
});
|
|
|
|
res.json({
|
|
items: page.items,
|
|
...(page.cursor ? { cursor: page.cursor } : {}),
|
|
...(page.total !== undefined ? { total: page.total } : {}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* DELETE /share/shared-by-me/:uid — withdraw one listed share, scoped to
|
|
* that row's issuer.
|
|
*
|
|
* An uid outside the caller's view — another account's, or another app's to
|
|
* an app — answers 404 like one that names nothing, so this can't be used
|
|
* to learn which. Within their view, the revoke's own rules answer: lapsed
|
|
* authority gets the ACL's error, a grant already withdrawn elsewhere
|
|
* reports `revoked: 0`.
|
|
*/
|
|
@Delete('/shared-by-me/:uid', {
|
|
subdomain: 'api',
|
|
requireVerified: true,
|
|
rateLimit: SHARE_LIMIT,
|
|
})
|
|
async revokeSharedByMe(req: Request, res: Response): Promise<void> {
|
|
const actor = this.#requireActor(req);
|
|
const uid = String(req.params.uid ?? '');
|
|
const { revoked } = await this.services.share.revokeSharedByMe(
|
|
actor,
|
|
uid,
|
|
);
|
|
res.json({ uid, revoked });
|
|
}
|
|
|
|
/** GET /share/shares — who can reach one item. */
|
|
@Get('/shares', {
|
|
subdomain: 'api',
|
|
requireVerified: 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 = expandTildePath(query.path, actor.user?.username);
|
|
if (!target.uid && !target.path) {
|
|
throw new HttpError(400, 'one of `uid` or `path` is required', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
|
|
const shares = await this.services.share.listSharesOf(actor, target);
|
|
res.json({
|
|
items: await Promise.all(
|
|
shares.map((share) => this.#toClientShare(share)),
|
|
),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* GET /share/audit — when a grant was made, by whom, and under which app.
|
|
*
|
|
* With `uid` or `path`, the trail of everything granted on that item, for
|
|
* whoever may manage it; with neither, the trail of what the caller
|
|
* granted. Rows outlive the grants they describe, so this still answers
|
|
* after a revoke.
|
|
*/
|
|
@Get('/audit', {
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
requireVerified: true,
|
|
rateLimit: SHARE_LIST_LIMIT,
|
|
})
|
|
async listGrantAudit(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 = expandTildePath(query.path, actor.user?.username);
|
|
|
|
const page = await this.services.share.listGrantAudit(actor, target, {
|
|
limit: normalizeLimit(query.limit, { cap: LIST_LIMIT_CAP }),
|
|
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
|
|
includeTotal: query.includeTotal === 'true',
|
|
});
|
|
|
|
res.json({
|
|
items: page.items.map((entry) => ({
|
|
action: entry.action,
|
|
permission: entry.permission,
|
|
entryUid: entry.entryUid,
|
|
mode: entry.mode,
|
|
issuer: entry.issuer.username,
|
|
holder: entry.holder.username,
|
|
appUid: entry.appUid,
|
|
createdAt: entry.createdAt,
|
|
})),
|
|
...(page.cursor ? { cursor: page.cursor } : {}),
|
|
...(page.total !== undefined ? { total: page.total } : {}),
|
|
});
|
|
}
|
|
|
|
// -- Blocking -----------------------------------------------------
|
|
// User sessions only: a block list is a safety control, not an app's to touch.
|
|
|
|
/**
|
|
* GET /share/blocks — who the caller is refusing shares from, and whether
|
|
* they are refusing everyone.
|
|
*/
|
|
@Get('/blocks', {
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
requireVerified: true,
|
|
rateLimit: SHARE_LIST_LIMIT,
|
|
})
|
|
async listBlocks(req: Request, res: Response): Promise<void> {
|
|
const actor = this.#requireActor(req);
|
|
const { all, items } =
|
|
await this.services.share.listBlockedSenders(actor);
|
|
res.json({
|
|
all,
|
|
items: items.map((item) => ({
|
|
username: item.username,
|
|
created_at: item.createdAt,
|
|
})),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* POST /share/blocks — stop accepting shares. `{ all: true }` refuses
|
|
* everyone; `{ username }` refuses one person. Idempotent either way:
|
|
* blocking twice is the state the caller asked for.
|
|
*
|
|
* Access already granted is untouched — `POST /share/revoke` is what
|
|
* withdraws that.
|
|
*/
|
|
@Post('/blocks', {
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
requireVerified: true,
|
|
rateLimit: SHARE_LIMIT,
|
|
})
|
|
async createBlock(req: Request, res: Response): Promise<void> {
|
|
const actor = this.#requireActor(req);
|
|
const body = this.#body(req);
|
|
if (this.#isBlockAll(body)) {
|
|
const { all } = await this.services.share.setBlockAllSenders(
|
|
actor,
|
|
true,
|
|
);
|
|
res.json({ all, blocked: true });
|
|
return;
|
|
}
|
|
const { username, created } = await this.services.share.blockSender(
|
|
actor,
|
|
this.#username(body),
|
|
);
|
|
res.json({ username, blocked: true, created });
|
|
}
|
|
|
|
/**
|
|
* DELETE /share/blocks — accept shares again. `{ all: true }` lifts the
|
|
* blanket refusal, leaving the per-sender list as it was.
|
|
*/
|
|
@Delete('/blocks', {
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
requireVerified: true,
|
|
rateLimit: SHARE_LIMIT,
|
|
})
|
|
async deleteBlock(req: Request, res: Response): Promise<void> {
|
|
const actor = this.#requireActor(req);
|
|
const body = this.#body(req);
|
|
if (this.#isBlockAll(body)) {
|
|
const { all } = await this.services.share.setBlockAllSenders(
|
|
actor,
|
|
false,
|
|
);
|
|
res.json({ all, blocked: false });
|
|
return;
|
|
}
|
|
const { username, unblocked } = await this.services.share.unblockSender(
|
|
actor,
|
|
this.#username(body),
|
|
);
|
|
res.json({ username, blocked: false, unblocked });
|
|
}
|
|
|
|
// -- Helpers ------------------------------------------------------
|
|
|
|
/**
|
|
* Collapse repeated (recipient, item) pairs to one execution. `indexOf`
|
|
* maps every original position onto its unique pair, so a request that
|
|
* names the same pair twice still gets a result in both positions — the
|
|
* same result, which is the truth of what happened.
|
|
*/
|
|
#dedupePairs<T extends { recipient: ShareRecipient; item: ShareTarget }>(
|
|
pairs: T[],
|
|
): { unique: T[]; indexOf: number[] } {
|
|
const unique: T[] = [];
|
|
const seen = new Map<string, number>();
|
|
const indexOf = pairs.map((pair) => {
|
|
// Every recipient field, or two teams key alike and collapse
|
|
// into one -- the second reporting the first's outcome as its own.
|
|
const key = JSON.stringify([
|
|
pair.recipient.email ?? null,
|
|
pair.recipient.username ?? null,
|
|
pair.recipient.team ?? null,
|
|
pair.recipient.teamHandle ?? null,
|
|
pair.item.uid ?? null,
|
|
pair.item.path ?? null,
|
|
]);
|
|
let at = seen.get(key);
|
|
if (at === undefined) {
|
|
at = unique.length;
|
|
seen.set(key, at);
|
|
unique.push(pair);
|
|
}
|
|
return at;
|
|
});
|
|
return { unique, indexOf };
|
|
}
|
|
|
|
/**
|
|
* Whether this call is about the blanket switch rather than one person.
|
|
* Only the literal `true` counts, so a client sending `all: false`
|
|
* alongside a username still means the username.
|
|
*/
|
|
#isBlockAll(body: Record<string, unknown>): boolean {
|
|
return body.all === true;
|
|
}
|
|
|
|
#username(body: Record<string, unknown>): string {
|
|
const username =
|
|
typeof body.username === 'string' ? body.username.trim() : '';
|
|
if (!username) {
|
|
throw new HttpError(400, '`username` is required', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
return username;
|
|
}
|
|
|
|
#toClientShare(share: ResolvedShare) {
|
|
return toClientShare(this.clients.event, share);
|
|
}
|
|
|
|
#requireActor(req: Request): Actor {
|
|
const actor = req.actor;
|
|
if (!actor?.user)
|
|
throw new HttpError(401, 'Unauthorized', {
|
|
legacyCode: 'unauthorized',
|
|
});
|
|
return actor;
|
|
}
|
|
|
|
#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>;
|
|
}
|
|
|
|
#query(req: Request): Record<string, unknown> {
|
|
return (req.query ?? {}) as Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* The app a listing is narrowed to: an uid, `null` for the grants no app
|
|
* issued, or undefined for all of them. `NO_APP` is the drill-in for the
|
|
* group the summary reports with a null `appUid`.
|
|
*
|
|
* Malformed input is refused rather than read as "unscoped": a duplicated
|
|
* query param arrives as an array and an empty string names nothing, and a
|
|
* caller who believes they filtered must not silently receive everything.
|
|
*/
|
|
#appUidFilter(query: Record<string, unknown>): string | null | undefined {
|
|
const value = query.appUid;
|
|
if (value === undefined) return undefined;
|
|
if (typeof value !== 'string' || value === '') {
|
|
throw new HttpError(400, '`appUid` must be a single app uid', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
return value === NO_APP ? null : value;
|
|
}
|
|
|
|
/** Echoes back the identifier the caller named, so results are matchable. */
|
|
#recipientLabel(recipient: ShareRecipient): string {
|
|
if (recipient.anyone) return 'anyone';
|
|
return (
|
|
recipient.email ??
|
|
recipient.username ??
|
|
recipient.teamHandle ??
|
|
recipient.team ??
|
|
''
|
|
);
|
|
}
|
|
|
|
#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>;
|
|
// Object form only, and the literal `true`: nothing typed into
|
|
// a people field can become "everyone".
|
|
if (rec.anyone === true) {
|
|
out.push({ anyone: true });
|
|
continue;
|
|
}
|
|
const email =
|
|
typeof rec.email === 'string' ? rec.email.trim() : '';
|
|
const username =
|
|
typeof rec.username === 'string' ? rec.username.trim() : '';
|
|
const team =
|
|
typeof rec.team === 'string' ? rec.team.trim() : '';
|
|
const teamHandle =
|
|
typeof rec.teamHandle === 'string'
|
|
? rec.teamHandle.trim()
|
|
: '';
|
|
// Ambiguous rather than a precedence rule to memorise.
|
|
if (team && teamHandle) {
|
|
throw new HttpError(
|
|
400,
|
|
'pass `team` or `teamHandle`, not both',
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
if (team || teamHandle) {
|
|
out.push(team ? { team } : { teamHandle });
|
|
continue;
|
|
}
|
|
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;
|
|
}
|
|
|
|
#items(body: Record<string, unknown>, actor: Actor): ShareTarget[] {
|
|
const username = actor.user?.username;
|
|
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;
|
|
// Tilde-rooted strings are paths, as the legacy FS routes treat them.
|
|
const isPath = value.startsWith('/') || value.startsWith('~');
|
|
out.push(
|
|
isPath
|
|
? { path: expandTildePath(value, username) }
|
|
: { uid: value },
|
|
);
|
|
continue;
|
|
}
|
|
if (entry && typeof entry === 'object') {
|
|
const item = entry as Record<string, unknown>;
|
|
const path =
|
|
typeof item.path === 'string'
|
|
? expandTildePath(item.path, username)
|
|
: '';
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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 } {
|
|
if (!isHttpError(reason) || reason.statusCode >= 500) {
|
|
return { message: 'Request failed' };
|
|
}
|
|
const code = reason.legacyCode ?? reason.code;
|
|
return {
|
|
message: reason.message || 'Request failed',
|
|
...(code ? { code } : {}),
|
|
};
|
|
}
|
|
}
|