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>
243 lines
9.4 KiB
TypeScript
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;
|