Files
redefined-designs/backend/src/routes/intake.ts
T
bermudalambandClaude Opus 5 885a78c572 feat(intake): record whether the submitter asked for a cut-out (#281)
The public intake POST now reads a `removeBackground` multipart field and stores it on the new `item_drafts.remove_background` column. The checkbox on the submission page is ticked by default, so a client that sends nothing gets `true` — only the exact string `'false'` opts out, so a stray or unexpected value is treated as consent rather than a silent refusal.

The GET now also reports `backgroundRemoval: isRembgConfigured()` alongside the label, so the submission page knows up front whether the feature exists in this environment at all. Neither handler calls the sidecar or the AI — this task only records intent for the drafting worker to act on later, and the existing ordering of `requireUsableLink` and `requireCapacity` ahead of `uploadImages` is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:44:20 -05:00

243 lines
9.4 KiB
TypeScript

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<LinkRow | null> {
const { rows } = await pool.query<LinkRow>(
`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<void> {
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;