Repository Public
1// Deposit portals ("file requests"). A portal points at one folder of its owner. A depositor gets a submission token2// that can do exactly two things: send the files it declared, and read the state of its own submission. Nothing on3// the public side ever lists, reads or names anything in the target folder or in other submissions.4import { randomInt } from 'node:crypto';5import { rm } from 'node:fs/promises';6import type { Readable } from 'node:stream';7import type { FastifyInstance, FastifyRequest } from 'fastify';8import { BLOCKED_EXT, DEFAULT_FIELDS, extOf, MAX_CHUNK_BYTES, type RequestFields, sanitizeName } from '@spd/shared';9import { z } from 'zod';10import { actorOf, requireUser, userOf } from './auth.ts';11import { config, notFound, UserError } from './config.ts';12import { hashPassword, newToken, TOKEN_RE, tokenHash, verifyPassword } from './crypto.ts';13import { audit, type Db, lockOwner, sql, tx } from './db.ts';14import { enqueue } from './jobs.ts';15import { breadcrumbs, getFolder } from './tree.ts';16import { appendChunk, assertQuota, sealCheck, uploadJson, uploadPath, type UploadRow } from './uploads.ts';1718const GIB = 1024 ** 3, MIB = 1024 ** 2;19/** Largest file a portal may accept: what the antivirus can scan in one stream. */20export const PORTAL_MAX_FILE = 2 * GIB;21export { BLOCKED_EXT };2223export interface RequestRow {24 id: string; owner_id: string; folder_id: string; token_hash: string; title: string; description: string; instructions: string; confirmation_message: string; brand_name: string; brand_color: string;25 password_hash: string | null; expires_at: Date | null; active: boolean; revoked_at: Date | null; max_file_bytes: string; max_total_bytes: string; max_files_per_submission: number; max_submissions: number | null;26 submissions_per_hour: number; allowed_extensions: string[]; fields: RequestFields; require_consent: boolean; consent_text: string; retention_note: string; subfolder_per_submission: boolean; notify_owner: boolean;27 bytes_received: string; created_at: Date; updated_at: Date; folder_live?: boolean; submissions?: number;28}29type State = 'open' | 'paused' | 'revoked' | 'expired' | 'full' | 'target_missing';30const stateOf = (r: RequestRow): State =>31 r.revoked_at ? 'revoked' : !r.active ? 'paused' : r.expires_at && r.expires_at < new Date() ? 'expired' : r.folder_live === false ? 'target_missing'32 : Number(r.bytes_received) >= Number(r.max_total_bytes) || (r.max_submissions !== null && (r.submissions ?? 0) >= r.max_submissions) ? 'full' : 'open';33const requestJson = (r: RequestRow) => ({34 id: r.id, folderId: r.folder_id, title: r.title, description: r.description, instructions: r.instructions, confirmationMessage: r.confirmation_message, brandName: r.brand_name, brandColor: r.brand_color,35 hasPassword: !!r.password_hash, expiresAt: r.expires_at, active: r.active, revokedAt: r.revoked_at, maxFileBytes: Number(r.max_file_bytes), maxTotalBytes: Number(r.max_total_bytes),36 maxFilesPerSubmission: r.max_files_per_submission, maxSubmissions: r.max_submissions, submissionsPerHour: r.submissions_per_hour, allowedExtensions: r.allowed_extensions, fields: r.fields,37 requireConsent: r.require_consent, consentText: r.consent_text, retentionNote: r.retention_note, subfolderPerSubmission: r.subfolder_per_submission, notifyOwner: r.notify_owner,38 bytesReceived: Number(r.bytes_received), submissions: r.submissions ?? 0, createdAt: r.created_at, state: stateOf(r),39});40const withState = (t: Db) => t`r.*, r.max_file_bytes::text, r.max_total_bytes::text, r.bytes_received::text, (d.trashed_at is null) as folder_live,41 (select count(*)::int from upload_submissions s where s.request_id = r.id and s.status in ('open', 'processing', 'completed')) as submissions42 from upload_requests r join folders d on d.id = r.folder_id`;4344const Ext = z.string().trim().toLowerCase().regex(/^[a-z0-9]{1,10}$/);45const Fields = z.object({46 name: z.enum(['off', 'optional', 'required']), email: z.enum(['off', 'optional', 'required']), reference: z.enum(['off', 'optional', 'required']),47 custom: z.array(z.object({ key: z.string().regex(/^[a-z0-9_]{1,30}$/), label: z.string().trim().min(1).max(80), required: z.boolean() })).max(10),48});49const Body = z.object({50 title: z.string().trim().min(1).max(120), description: z.string().trim().max(2000), instructions: z.string().trim().max(4000), confirmationMessage: z.string().trim().max(1000),51 brandName: z.string().trim().max(80), brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/), password: z.string().min(4).max(200).nullable(), expiresAt: z.iso.datetime({ offset: true }).nullable(), active: z.boolean(),52 maxFileBytes: z.number().int().min(1024).max(PORTAL_MAX_FILE), maxTotalBytes: z.number().int().min(1024).max(1e15), maxFilesPerSubmission: z.number().int().min(1).max(200),53 maxSubmissions: z.number().int().min(1).max(1_000_000).nullable(), submissionsPerHour: z.number().int().min(1).max(1000), allowedExtensions: z.array(Ext).max(60), fields: Fields,54 requireConsent: z.boolean(), consentText: z.string().trim().max(2000), retentionNote: z.string().trim().max(1000), subfolderPerSubmission: z.boolean(), notifyOwner: z.boolean(),55}).partial();56const Id = z.uuid();57/** Reference shown to the depositor: no ambiguous characters, not guessable enough to matter (it grants nothing). */58const publicRef = () => Array.from({ length: 10 }, () => 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'[randomInt(32)]).join('').replace(/^(.{5})/, '$1-');5960export function requestRoutes(app: FastifyInstance): void {61 const authed = { preHandler: requireUser };62 const own = async (t: Db, userId: string, id: string) => {63 const [r] = await t<RequestRow[]>`select ${withState(t)} where r.id = ${id} and r.owner_id = ${userId}`;64 if (!r) throw notFound('portal not found');65 return r;66 };6768 // ============================ owner side ============================69 app.get('/api/v1/requests', authed, async (req) => {70 const rows = await sql<(RequestRow & { folder_name: string })[]>`select d.name as folder_name, ${withState(sql)} where r.owner_id = ${userOf(req).id} order by r.created_at desc limit 1000`;71 return rows.map((r) => ({ ...requestJson(r), folderName: r.folder_name }));72 });73 app.post('/api/v1/requests', authed, async (req) => {74 const b = Body.and(z.object({ folderId: z.union([Id, z.literal('root')]), title: z.string().trim().min(1).max(120) })).parse(req.body);75 const u = userOf(req);76 const folder = await getFolder(sql, u.id, b.folderId);77 if (b.expiresAt && new Date(b.expiresAt) <= new Date()) throw new UserError('bad_expiry', 'the expiry must be in the future', 400);78 const token = newToken();79 const [r] = await sql<{ id: string }[]>`insert into upload_requests (owner_id, folder_id, token_hash, title, description, instructions, confirmation_message, brand_name, brand_color, password_hash, expires_at, active,80 max_file_bytes, max_total_bytes, max_files_per_submission, max_submissions, submissions_per_hour, allowed_extensions, fields, require_consent, consent_text, retention_note, subfolder_per_submission, notify_owner)81 values (${u.id}, ${folder.id}, ${tokenHash(token)}, ${b.title}, ${b.description ?? ''}, ${b.instructions ?? ''}, ${b.confirmationMessage ?? ''}, ${b.brandName ?? ''}, ${b.brandColor ?? '#2563eb'},82 ${b.password ? await hashPassword(b.password) : null}, ${b.expiresAt ?? null}, ${b.active ?? true}, ${b.maxFileBytes ?? 200 * MIB}, ${b.maxTotalBytes ?? 5 * GIB}, ${b.maxFilesPerSubmission ?? 20},83 ${b.maxSubmissions ?? null}, ${b.submissionsPerHour ?? 20}, ${b.allowedExtensions ?? []}, ${sql.json((b.fields ?? DEFAULT_FIELDS) as never)}, ${b.requireConsent ?? false}, ${b.consentText ?? ''},84 ${b.retentionNote ?? ''}, ${b.subfolderPerSubmission ?? true}, ${b.notifyOwner ?? true}) returning id`;85 await audit(sql, actorOf(req), 'request.created', { targetType: 'request', targetId: r!.id });86 return { request: requestJson(await own(sql, u.id, r!.id)), url: `${config.origin}/request/${token}` }; // the only time the link is available87 });88 app.get('/api/v1/requests/:id', authed, async (req) => {89 const { id } = z.object({ id: Id }).parse(req.params);90 const r = await own(sql, userOf(req).id, id);91 const [folder] = await sql<Parameters<typeof breadcrumbs>[1][]>`select * from folders where id = ${r.folder_id}`;92 return { request: requestJson(r), folderPath: await breadcrumbs(sql, folder!) };93 });94 app.patch('/api/v1/requests/:id', authed, async (req) => {95 const { id } = z.object({ id: Id }).parse(req.params);96 const b = Body.and(z.object({ folderId: Id.optional() })).parse(req.body);97 const u = userOf(req);98 await own(sql, u.id, id);99 const folderId = b.folderId ? (await getFolder(sql, u.id, b.folderId)).id : undefined;100 const cols: Record<string, unknown> = {101 title: b.title, description: b.description, instructions: b.instructions, confirmation_message: b.confirmationMessage, brand_name: b.brandName, brand_color: b.brandColor, expires_at: b.expiresAt, active: b.active,102 max_file_bytes: b.maxFileBytes, max_total_bytes: b.maxTotalBytes, max_files_per_submission: b.maxFilesPerSubmission, max_submissions: b.maxSubmissions, submissions_per_hour: b.submissionsPerHour,103 allowed_extensions: b.allowedExtensions, require_consent: b.requireConsent, consent_text: b.consentText, retention_note: b.retentionNote, subfolder_per_submission: b.subfolderPerSubmission, notify_owner: b.notifyOwner, folder_id: folderId,104 };105 if (b.password !== undefined) cols.password_hash = b.password === null ? null : await hashPassword(b.password);106 await tx(async (t) => {107 for (const [k, v] of Object.entries(cols)) if (v !== undefined) await t`update upload_requests set ${t(k)} = ${v as never}, updated_at = now() where id = ${id}`;108 if (b.fields) await t`update upload_requests set fields = ${t.json(b.fields as never)}, updated_at = now() where id = ${id}`;109 await audit(t, actorOf(req), 'request.updated', { targetType: 'request', targetId: id, meta: { changed: Object.keys(b).filter((k) => k !== 'password') } });110 });111 return requestJson(await own(sql, u.id, id));112 });113 /** Revoke: the link is dead for good, uploads in flight are refused at their next chunk. `?purge=1` also deletes the portal and its journal (deposited files stay). */114 app.delete('/api/v1/requests/:id', authed, async (req) => {115 const { id } = z.object({ id: Id }).parse(req.params);116 const q = z.object({ purge: z.string().optional() }).parse(req.query);117 const u = userOf(req);118 await own(sql, u.id, id);119 const pending = await sql<UploadRow[]>`select u.* from uploads u join upload_items i on i.id = u.item_id join upload_submissions s on s.id = i.submission_id where s.request_id = ${id} and s.status = 'open'`;120 await tx(async (t) => {121 await t`update upload_submissions set status = 'expired' where request_id = ${id} and status = 'open'`;122 if (q.purge) await t`delete from upload_requests where id = ${id}`;123 else await t`update upload_requests set revoked_at = coalesce(revoked_at, now()), active = false, updated_at = now() where id = ${id}`;124 await audit(t, actorOf(req), q.purge ? 'request.deleted' : 'request.revoked', { targetType: 'request', targetId: id });125 });126 for (const p of pending) await rm(uploadPath(p), { force: true });127 return { ok: true };128 });129 /** New link for the same portal; the previous link stops working at once. */130 app.post('/api/v1/requests/:id/rotate', authed, async (req) => {131 const { id } = z.object({ id: Id }).parse(req.params);132 const token = newToken();133 const r = await sql`update upload_requests set token_hash = ${tokenHash(token)}, updated_at = now() where id = ${id} and owner_id = ${userOf(req).id} and revoked_at is null returning id`;134 if (!r.length) throw notFound('portal not found');135 await audit(sql, actorOf(req), 'request.rotated', { targetType: 'request', targetId: id });136 return { url: `${config.origin}/request/${token}` };137 });138 app.get('/api/v1/requests/:id/submissions', authed, async (req) => {139 const { id } = z.object({ id: Id }).parse(req.params);140 await own(sql, userOf(req).id, id);141 const rows = await sql<{ id: string; public_ref: string; status: string; submitter_name: string; submitter_email: string; reference: string; created_at: Date; completed_at: Date | null; folder_id: string | null; files: number; placed: number; rejected: number; bytes: string }[]>`142 select s.id, s.public_ref, s.status, s.submitter_name, s.submitter_email, s.reference, s.created_at, s.completed_at, s.folder_id,143 count(i.id)::int as files, count(i.id) filter (where i.status = 'placed')::int as placed, count(i.id) filter (where i.status = 'rejected')::int as rejected, coalesce(sum(i.size) filter (where i.status = 'placed'), 0)::text as bytes144 from upload_submissions s left join upload_items i on i.submission_id = s.id where s.request_id = ${id} group by s.id order by s.created_at desc limit 1000`;145 return rows.map((s) => ({ id: s.id, ref: s.public_ref, status: s.status, name: s.submitter_name, email: s.submitter_email, reference: s.reference, createdAt: s.created_at, completedAt: s.completed_at, folderId: s.folder_id, files: s.files, placed: s.placed, rejected: s.rejected, bytes: Number(s.bytes) }));146 });147 app.get('/api/v1/submissions/:id', authed, async (req) => {148 const { id } = z.object({ id: Id }).parse(req.params);149 const [s] = await sql<{ id: string; request_id: string; public_ref: string; status: string; submitter_name: string; submitter_email: string; reference: string; meta: unknown; consent_at: Date | null; ip: string; user_agent: string; created_at: Date; completed_at: Date | null; folder_id: string | null }[]>`150 select s.* from upload_submissions s join upload_requests r on r.id = s.request_id where s.id = ${id} and r.owner_id = ${userOf(req).id}`;151 if (!s) throw notFound('submission not found');152 const [items, events] = await Promise.all([153 sql<{ id: string; original_name: string; safe_name: string; size: string | null; declared_size: string; sha256: string | null; detected_mime: string | null; status: string; reject_code: string | null; file_id: string | null }[]>`154 select id, original_name, safe_name, size::text, declared_size::text, sha256, detected_mime, status, reject_code, file_id from upload_items where submission_id = ${id} order by position, id`,155 sql<{ id: string; at: Date; action: string; meta: unknown }[]>`select id::text, at, action, meta from audit_events where target_type = 'submission' and target_id = ${id} order by at, id`,156 ]);157 return {158 id: s.id, requestId: s.request_id, ref: s.public_ref, status: s.status, name: s.submitter_name, email: s.submitter_email, reference: s.reference, meta: s.meta, consentAt: s.consent_at, ip: s.ip, userAgent: s.user_agent,159 createdAt: s.created_at, completedAt: s.completed_at, folderId: s.folder_id,160 items: items.map((i) => ({ id: i.id, name: i.safe_name, originalName: i.original_name, size: Number(i.size ?? i.declared_size), sha256: i.sha256, mime: i.detected_mime, status: i.status, rejectCode: i.reject_code, fileId: i.file_id })),161 events: events.map((e) => ({ id: e.id, at: e.at, action: e.action, meta: e.meta })),162 };163 });164165 // ============================ public side ============================166 const limited = (max: number, timeWindow = '1 minute') => ({ config: { rateLimit: { max, timeWindow } } });167 const unknown = () => new UserError('portal_unavailable', 'this deposit link is not available', 404);168 const closed = () => new UserError('portal_closed', 'this portal no longer accepts deposits', 410);169 /** The portal behind a link token, open for business right now. Called on every public request. */170 async function openPortal(t: Db, token: string | undefined): Promise<RequestRow> {171 if (!token || !TOKEN_RE.test(token)) throw unknown();172 const [r] = await t<RequestRow[]>`select ${withState(t)} join users u on u.id = r.owner_id and u.disabled_at is null where r.token_hash = ${tokenHash(token)}`;173 if (!r || r.revoked_at) throw unknown();174 if (stateOf(r) !== 'open') throw closed();175 return r;176 }177 const bearer = (req: FastifyRequest): string => {178 const m = /^Bearer ([A-Za-z0-9_-]{20,64})$/.exec(String(req.headers.authorization ?? ''));179 if (!m) throw new UserError('unauthenticated', 'submission token required', 401);180 return tokenHash(m[1]!);181 };182 interface Sub { id: string; request_id: string; status: string; public_ref: string }183 /** The submission a bearer token designates, with its portal re-validated when `mustBeOpen`. */184 async function submissionOf(t: Db, req: FastifyRequest, mustBeOpen: boolean): Promise<{ sub: Sub; portal: RequestRow }> {185 const [sub] = await t<Sub[]>`select id, request_id, status, public_ref from upload_submissions where token_hash = ${bearer(req)}`;186 if (!sub) throw new UserError('unauthenticated', 'submission token required', 401);187 const [portal] = await t<RequestRow[]>`select ${withState(t)} where r.id = ${sub.request_id}`;188 if (!portal) throw unknown();189 if (mustBeOpen) {190 // the totals of a portal that became "full" must not stop a submission that was admitted before it filled up191 const s = stateOf(portal);192 if (sub.status !== 'open' || (s !== 'open' && s !== 'full')) throw closed();193 }194 return { sub, portal };195 }196197 app.get('/api/v1/public/requests/:token', limited(60), async (req) => {198 const r = await openPortal(sql, (req.params as { token?: string }).token);199 return {200 title: r.title, description: r.description, instructions: r.instructions, brandName: r.brand_name, brandColor: r.brand_color, needsPassword: !!r.password_hash, expiresAt: r.expires_at,201 maxFileBytes: Number(r.max_file_bytes), maxFiles: r.max_files_per_submission, remainingBytes: Math.max(0, Number(r.max_total_bytes) - Number(r.bytes_received)),202 allowedExtensions: r.allowed_extensions, fields: r.fields, requireConsent: r.require_consent, consentText: r.consent_text, retentionNote: r.retention_note, chunkBytes: MAX_CHUNK_BYTES / 2,203 };204 });205206 const text = (max: number) => z.string().trim().max(max).default('');207 const Submit = z.object({208 password: z.string().max(200).optional(), name: text(120), email: z.union([z.email().max(200), z.literal('')]).default(''), reference: text(120),209 custom: z.record(z.string().regex(/^[a-z0-9_]{1,30}$/), z.string().trim().max(500)).default({}), consent: z.boolean().default(false),210 files: z.array(z.object({ name: z.string().min(1).max(1000), size: z.number().int().min(1) })).min(1).max(200),211 });212 app.post('/api/v1/public/requests/:token/submissions', limited(30), async (req) => {213 const b = Submit.parse(req.body);214 const token = (req.params as { token?: string }).token;215 const actor = actorOf(req);216 const pre = await openPortal(sql, token);217 if (pre.password_hash) {218 const [f] = await sql<{ n: number }[]>`select count(*)::int as n from audit_events where target_type = 'request' and target_id = ${pre.id} and action = 'request.password_failed' and at > now() - interval '15 minutes'`;219 if (f!.n >= 20) throw new UserError('too_many_attempts', 'too many attempts, try again later', 429);220 if (!b.password) throw new UserError('password_required', 'this portal is protected by a password', 401);221 if (!(await verifyPassword(b.password, pre.password_hash))) {222 await audit(sql, actor, 'request.password_failed', { ownerId: pre.owner_id, targetType: 'request', targetId: pre.id });223 throw new UserError('password_invalid', 'wrong password', 401);224 }225 }226 const submissionToken = newToken();227 const out = await tx(async (t) => {228 await lockOwner(t, pre.owner_id); // serialises admission: quotas and counters are read and reserved atomically229 const r = await openPortal(t, token);230 const bad = (code: string, message: string, extra: Record<string, unknown> = {}) => new UserError(code, message, 422, extra);231 // --- form ---232 const f = r.fields;233 for (const [k, v] of [['name', b.name], ['email', b.email], ['reference', b.reference]] as const) if (f[k] === 'required' && !v) throw bad('field_required', `${k} is required`, { field: k });234 const meta: Record<string, string> = {};235 for (const c of f.custom) { const v = b.custom[c.key] ?? ''; if (c.required && !v) throw bad('field_required', `${c.label} is required`, { field: c.key }); if (v) meta[c.key] = v; }236 if (r.require_consent && !b.consent) throw bad('consent_required', 'consent is required');237 // --- files ---238 if (b.files.length > r.max_files_per_submission) throw bad('too_many_files', 'too many files', { max: r.max_files_per_submission });239 const names = new Set<string>();240 const items = b.files.map((file) => {241 let safe = sanitizeName(file.name);242 const ext = extOf(safe);243 if (BLOCKED_EXT.has(ext) || (r.allowed_extensions.length && !r.allowed_extensions.includes(ext))) throw bad('type_not_allowed', 'this file type is not accepted', { name: safe });244 if (file.size > Number(r.max_file_bytes)) throw bad('file_too_large', 'a file exceeds the maximum size', { name: safe, max: Number(r.max_file_bytes) });245 for (let i = 2; names.has(safe.toLowerCase()); i++) safe = sanitizeName(file.name.replace(/(\.[^.]*)?$/, ` (${i})$1`));246 names.add(safe.toLowerCase());247 return { original: file.name.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 500), safe, size: file.size };248 });249 const declared = items.reduce((n, i) => n + i.size, 0);250 // --- limits: per address, per portal, per owner ---251 const [rate] = await t<{ n: number }[]>`select count(*)::int as n from upload_submissions where request_id = ${r.id} and ip = ${req.ip} and created_at > now() - interval '1 hour'`;252 if (rate!.n >= r.submissions_per_hour) throw new UserError('rate_limited', 'too many submissions, try again later', 429);253 const [open] = await t<{ n: number; bytes: string }[]>`select count(*)::int as n, coalesce(sum(declared_bytes), 0)::text as bytes from upload_submissions where request_id = ${r.id} and status in ('open', 'processing')`;254 if (open!.n >= 200) throw new UserError('rate_limited', 'this portal is busy, try again later', 429);255 if (Number(r.bytes_received) + Number(open!.bytes) + declared > Number(r.max_total_bytes)) throw bad('portal_full', 'this portal cannot accept that much data');256 await assertQuota(t, r.owner_id, declared).catch(() => { throw bad('portal_full', 'this portal cannot accept that much data'); }); // do not reveal the owner's account state257 // --- admit ---258 const [s] = await t<{ id: string; public_ref: string }[]>`insert into upload_submissions (request_id, public_ref, token_hash, status, submitter_name, submitter_email, reference, meta, consent_at, declared_bytes, ip, user_agent)259 values (${r.id}, ${publicRef()}, ${tokenHash(submissionToken)}, 'open', ${b.name}, ${b.email}, ${b.reference}, ${t.json(meta)}, ${r.require_consent ? new Date() : null}, ${declared}, ${req.ip}, ${actor.userAgent?.slice(0, 300) ?? ''})260 returning id, public_ref`;261 const outItems = [];262 for (const [position, i] of items.entries()) {263 const [item] = await t<{ id: string }[]>`insert into upload_items (submission_id, position, original_name, safe_name, declared_size, status) values (${s!.id}, ${position}, ${i.original}, ${i.safe}, ${i.size}, 'uploading') returning id`;264 const [up] = await t<{ id: string }[]>`insert into uploads (owner_id, item_id, name, size, expires_at) values (${r.owner_id}, ${item!.id}, ${i.safe}, ${i.size}, now() + interval '24 hours') returning id`;265 outItems.push({ uploadId: up!.id, name: i.safe, size: i.size });266 }267 await audit(t, actor, 'submission.started', { ownerId: r.owner_id, targetType: 'submission', targetId: s!.id, meta: { files: items.length, bytes: declared } });268 return { ref: s!.public_ref, items: outItems };269 });270 return { ...out, submissionToken };271 });272273 /** One of this submission's uploads (and nothing else). */274 const guard = (req: FastifyRequest) => async (t: Db, u: UploadRow) => {275 const { sub } = await submissionOf(t, req, true);276 const [mine] = await t`select 1 from upload_items where id = ${u.item_id} and submission_id = ${sub.id} and status = 'uploading'`;277 if (!u.item_id || !mine) throw notFound('upload not found');278 };279 app.get('/api/v1/public/uploads/:id', limited(300), async (req) => {280 const { id } = z.object({ id: Id }).parse(req.params);281 const [u] = await sql<UploadRow[]>`select *, size::text, received::text from uploads where id = ${id}`;282 if (!u) throw notFound('upload not found');283 await guard(req)(sql, u);284 const j = uploadJson(u);285 return { id: j.id, size: j.size, received: j.received, status: j.status };286 });287 app.patch('/api/v1/public/uploads/:id', { ...limited(600), bodyLimit: MAX_CHUNK_BYTES + 4096 }, async (req) => {288 const { id } = z.object({ id: Id }).parse(req.params);289 const q = z.object({ offset: z.coerce.number().int().min(0) }).parse(req.query);290 const sha = req.headers['x-chunk-sha256'];291 const u = await appendChunk(id, q.offset, req.body as Readable, typeof sha === 'string' && /^[0-9a-fA-F]{64}$/.test(sha) ? sha : undefined, guard(req));292 return { id: u.id, size: Number(u.size), received: Number(u.received) };293 });294 /** The depositor is done: complete files go to quarantine for inspection, incomplete ones are refused. */295 app.post('/api/v1/public/submissions/finalize', limited(30), async (req) => {296 const actor = actorOf(req);297 const { sub, portal } = await submissionOf(sql, req, true);298 const [claimed] = await sql`update upload_submissions set status = 'processing', finalized_at = now() where id = ${sub.id} and status = 'open' returning id`;299 if (!claimed) throw closed();300 const uploads = await sql<(UploadRow & { item: string })[]>`select u.*, u.size::text, u.received::text, i.id as item from uploads u join upload_items i on i.id = u.item_id where i.submission_id = ${sub.id}`;301 let kept = 0;302 for (const u of uploads) {303 try {304 const sha = await sealCheck(u);305 await sql`update upload_items set status = 'quarantined', size = ${u.size}, sha256 = ${sha}, quarantine_path = ${uploadPath(u)}, updated_at = now() where id = ${u.item}`;306 kept++;307 } catch {308 await rm(uploadPath(u), { force: true });309 await sql`update upload_items set status = 'rejected', reject_code = 'incomplete', updated_at = now() where id = ${u.item}`;310 }311 await sql`update uploads set status = 'done', updated_at = now() where id = ${u.id}`;312 }313 await tx(async (t) => {314 await audit(t, actor, 'submission.finalized', { ownerId: portal.owner_id, targetType: 'submission', targetId: sub.id, meta: { received: kept, incomplete: uploads.length - kept } });315 if (kept) await enqueue(t, 'process_submission', { submissionId: sub.id }, { dedupeKey: `submission:${sub.id}`, maxAttempts: 30 });316 else await t`update upload_submissions set status = 'rejected', completed_at = now() where id = ${sub.id}`;317 });318 return { ref: sub.public_ref, status: kept ? 'processing' : 'rejected' };319 });320 /** What a depositor may know afterwards: the state of their own submission, file by file. */321 app.get('/api/v1/public/submissions/status', limited(120), async (req) => {322 const { sub, portal } = await submissionOf(sql, req, false);323 const items = await sql<{ safe_name: string; status: string; reject_code: string | null }[]>`select safe_name, status, reject_code from upload_items where submission_id = ${sub.id} order by position, id`;324 return { ref: sub.public_ref, status: sub.status, confirmationMessage: sub.status === 'completed' ? portal.confirmation_message : '',325 items: items.map((i) => ({ name: i.safe_name, status: i.status === 'placed' ? 'accepted' : i.status === 'rejected' ? 'rejected' : 'pending', reason: i.reject_code })) };326 });327}328