import { Router, Request, Response, NextFunction } from 'express'; import { pool, requireRow } from '../db'; import { asyncRoute } from '../asyncRoute'; import { hashToken } from '../uploadLinks'; import { uploadImages, verifyUploadedImages, insertItemImages } from '../imageUpload'; import { intakeViewLimiter, intakeSubmitLimiter } from '../rateLimit'; import { draftQueued } from '../intake/draftingWorker'; import { checkCapacity, countForLinkSince, windowStart } from '../intake/capacity'; import { alertCeilingReached, alertLinkThreshold } from '../intake/abuseAlert'; import { getSettings } from '../adminSettings'; import { isRembgConfigured } from '../intake/rembgClient'; const router = Router(); /** * The public way in: photos of one item, from someone with no account (#222). * * Everything here is reachable by a stranger holding a URL, so the shape of * every refusal matters. Unknown, revoked and exhausted links are all 404 and * indistinguishable from outside — whether a link exists is not something a * stranger needs to be able to learn, which is the same reasoning `uploads.ts` * applies to files. * * The AI is deliberately not called here. A slow or failing model request must * not turn into a failed upload for someone who did nothing wrong, and the * photos may be the only copy — the item is often no longer in the sender's * hands. The row is left at `state='queued'` for the worker in #223. */ interface LinkRow { id: number; label: string; } /** The link resolved by `requireUsableLink`, carried through to the handler. */ interface IntakeRequest extends Request { uploadLink?: LinkRow; } /** * The link a token opens, or null. * * The cap is applied in SQL rather than in a later branch, so that "usable" is * one concept with one definition used identically by the GET and the POST. */ async function usableLink(token: string): Promise { const { rows } = await pool.query( `SELECT id, label FROM upload_links WHERE token_hash = $1 AND revoked_at IS NULL AND (max_submissions IS NULL OR submission_count < max_submissions)`, [hashToken(token)] ); return rows[0] ?? null; } /** * Resolves the link *before* multer runs, so a stranger holding a bad token * cannot cause a single byte to be written to the uploads volume. * * `discardUnlessAccepted` would delete those files afterwards, but "written * then deleted" is a materially worse position than "never written" on an * endpoint the whole internet can reach: it is disk churn an unauthenticated * caller controls, and it leans on a cleanup that a crash between the write * and the unlink would skip. Ordering this ahead of `uploadImages` is the * whole mitigation, and a test asserts it. */ /** * Refuses when the intake surface as a whole is over its ceiling. * * Ordered ahead of `uploadImages` for the same reason `requireUsableLink` is: a * refused submission must write zero bytes to disk. Ordering it after would * accept the upload, store the files and then throw them away, which is the * expensive half of the work this exists to prevent. * * 503 rather than 403. The sender has done nothing wrong, their link is fine, * and the condition clears by itself as the window rolls. */ /** * Alerts when one link crosses its threshold. * * A named function rather than an inline IIFE in the handler. The wrapper guard * flags any `async` inside a route registration that is not directly preceded * by `asyncRoute(`, and it cannot tell an inner IIFE from an unwrapped handler — * nor should it have to. */ async function alertIfLinkIsBusy(link: LinkRow): Promise { const { intakeLinkAlertThreshold, intakeCeilingResetAt } = await getSettings(); const used = await countForLinkSince(link.id, windowStart(new Date(), intakeCeilingResetAt)); if (used >= intakeLinkAlertThreshold) { await alertLinkThreshold(link.id, link.label, used, intakeLinkAlertThreshold); } } const requireCapacity = asyncRoute( async (_req: Request, res: Response, next: NextFunction) => { const verdict = await checkCapacity(); if (verdict.allowed) { next(); return; } // Not awaited: an alert that fails must not become a failed request for // somebody who has done nothing wrong, and the refusal is already decided. void alertCeilingReached(verdict.used, verdict.ceiling).catch((err) => console.error('[intake] ceiling alert failed:', err) ); res.status(503).json({ error: 'we are not able to accept submissions right now — please try again later' }); } ); const requireUsableLink = asyncRoute( async (req: Request, res: Response, next: NextFunction) => { const link = await usableLink(req.params.token as string); if (!link) { res.status(404).json({ error: 'not found' }); return; } (req as IntakeRequest).uploadLink = link; next(); } ); router.get('/:token', intakeViewLimiter, asyncRoute(async (req: Request, res: Response) => { const link = await usableLink(req.params.token as string); if (!link) { return res.status(404).json({ error: 'not found' }); } // The label, and whether the background-removal control has anything behind // it. Still nothing about the catalogue, the admin, or other links. res.json({ label: link.label, backgroundRemoval: isRembgConfigured() }); })); router.post( '/:token', intakeSubmitLimiter, requireUsableLink, requireCapacity, uploadImages, asyncRoute(async (req: Request, res: Response) => { // Set by requireUsableLink above. Re-checked rather than asserted non-null, // so a future reordering of the middleware fails as a 404 rather than as a // crash on undefined. const link = (req as IntakeRequest).uploadLink; if (!link) { return res.status(404).json({ error: 'not found' }); } const files = (req.files as Express.Multer.File[]) || []; if (files.length === 0) { return res.status(400).json({ error: 'at least one photo is required' }); } const refusal = await verifyUploadedImages(req); if (refusal) { return res.status(400).json({ error: refusal }); } const note = typeof req.body?.note === 'string' ? req.body.note.trim() : ''; // Absent means yes: the checkbox on the page is ticked by default, so a // client that does not send the field — an older build, or a script — gets // what every other submission gets rather than silently opting out. // // Only the exact string opts out. Multipart fields arrive as strings, and // reading a stray value as "no" would quietly deny somebody something they // asked for. const removeBackground = req.body?.removeBackground !== 'false'; const client = await pool.connect(); try { await client.query('BEGIN'); // A placeholder name. `items.name` is NOT NULL and nobody has named this // yet — the drafting worker or the admin replaces it. A timestamp rather // than "Untitled" so several waiting submissions stay tellable apart in // the inventory list. const { rows } = await client.query<{ id: number }>( `INSERT INTO items (name, description, status) VALUES ($1, $2, 'pending') RETURNING id`, [`Submission ${new Date().toISOString()}`, null] ); const itemId = requireRow(rows, 'the intake item INSERT').id; await insertItemImages(client, itemId, files, 0); await client.query( `INSERT INTO item_drafts (item_id, upload_link_id, submitter_note, remove_background) VALUES ($1, $2, $3, $4)`, [itemId, link.id, note === '' ? null : note, removeBackground] ); // Counted inside the transaction and guarded on the same conditions as // the lookup, so two submissions racing for the last slot of a capped // link cannot both succeed. const counted = await client.query( `UPDATE upload_links SET submission_count = submission_count + 1, last_used_at = now() WHERE id = $1 AND revoked_at IS NULL AND (max_submissions IS NULL OR submission_count < max_submissions)`, [link.id] ); if (counted.rowCount === 0) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not found' }); } await client.query('COMMIT'); // Deliberately not awaited, and catching for itself. A slow or failing // model must not become a failed upload for someone who did nothing // wrong, which is the whole reason drafting does not happen inline. The // sweeper picks up anything this misses, so the cost of it failing here // is a few minutes' delay rather than a lost submission. void draftQueued(1).catch((err) => console.error('[drafting] after submission:', err)); // The signal that a link has been shared further than intended, which is // the case the revoke mechanism exists for and which otherwise depends on // somebody happening to look. Not awaited, for the same reason as above. void alertIfLinkIsBusy(link).catch((err) => console.error('[intake] link threshold alert failed:', err) ); // No item id in the response: the sender has no business knowing about // the catalogue, and nothing they could do with it. res.status(201).json({ ok: true }); } catch (err) { await client.query('ROLLBACK'); console.error(err); res.status(500).json({ error: 'internal error' }); } finally { client.release(); } }) ); export default router;