import { Router, Request, Response } from 'express'; import { pool, requireRow } from '../db'; import { asyncRoute } from '../asyncRoute'; import { generateToken, hashToken } from '../uploadLinks'; import { trimTrailingSlashes, isValidEmail } from '../utils'; import { sendMail, MailOutcome } from '../mailer'; import { renderTemplate } from '../emailTemplates'; import { loadStoredTemplate } from './adminEmailTemplates'; const router = Router(); /** * Issuing and retiring the links that open the public intake endpoint (#222). * * A link is named because provenance matters more than convenience here. When * one is shared further than intended the question is *which* one, and the * answer has to come from somewhere — so every submission records the link it * arrived through, and revoking kills that link rather than the feature. * * The token is returned by exactly one response in this file and is * unrecoverable afterwards. That is why the admin screen has to present it as * a one-time reveal rather than a field to come back to, and why losing it * means issuing a new link rather than looking the old one up. */ /** * Shaped so a `SELECT *` can never leak the digest into a response. * * Spelling the columns out is the point: `SELECT *` here would put * `token_hash` into every listing the moment somebody added a convenience. */ const LINK_SELECT = ` SELECT id, label, contact_email, revoked_at, submission_count, max_submissions, last_used_at, created_at FROM upload_links `; /** * The cap a link gets when nobody chose one. * * Not a tuned number — large enough that an ordinary contributor never meets * it, small enough that a link shared further than intended cannot be used * indefinitely before anyone notices. The point is that the default is finite * at all. */ const DEFAULT_MAX_SUBMISSIONS = 25; interface UploadLinkRow { id: number; label: string; contact_email: string | null; revoked_at: string | null; submission_count: number; max_submissions: number | null; last_used_at: string | null; created_at: string; } /** * What the mail tells the recipient about how much they may send. * * Words rather than a bare number for an uncapped link, so the sentence reads * as a sentence instead of showing an empty space where a figure should be. * An uncapped link is a deliberate choice the admin already had to make, so it * is emailable like any other. */ function submissionsAllowed(maxSubmissions: number | null): string { if (maxSubmissions === null) return 'as many items as you like'; return maxSubmissions === 1 ? '1 item' : `${maxSubmissions} items`; } router.get('/', asyncRoute(async (_req: Request, res: Response) => { const { rows } = await pool.query(`${LINK_SELECT} ORDER BY created_at DESC`); res.json(rows); })); router.post('/', asyncRoute(async (req: Request, res: Response) => { const label = typeof req.body?.label === 'string' ? req.body.label.trim() : ''; if (label === '') { return res.status(400).json({ error: 'a label is required' }); } const email = typeof req.body?.email === 'string' ? req.body.email.trim() : ''; if (email === '' || !isValidEmail(email)) { return res.status(400).json({ error: 'a valid email address is required' }); } // Three cases, deliberately distinct. Absent means nobody decided, which // gets the bounded default. An explicit null means unlimited — a decision // someone made, visible in the request. A number is itself. Reading absent // as unlimited is what would make every link unbounded by default. const rawCap = req.body?.maxSubmissions; let maxSubmissions: number | null = DEFAULT_MAX_SUBMISSIONS; if (rawCap === null) { maxSubmissions = null; } else if (rawCap !== undefined && rawCap !== '') { const parsed = Number(rawCap); if (!Number.isInteger(parsed) || parsed < 1) { return res.status(400).json({ error: 'maxSubmissions must be a positive whole number' }); } maxSubmissions = parsed; } const token = generateToken(); const { rows } = await pool.query( `INSERT INTO upload_links (label, token_hash, max_submissions, contact_email) VALUES ($1, $2, $3, $4) RETURNING id, label, contact_email, revoked_at, submission_count, max_submissions, last_used_at, created_at`, [label, hashToken(token), maxSubmissions, email] ); const link = requireRow(rows, 'the upload_links INSERT'); // PUBLIC_URL is already required alongside SMTP and is what every other // outbound link is built from. Absent in local development, which yields a // relative URL the admin screen can still show and copy usefully. const base = trimTrailingSlashes(process.env.PUBLIC_URL ?? ''); const url = `${base}/submit/${token}`; // Awaited, and its outcome reported rather than swallowed. Every other sender // in this codebase fires and forgets because nobody is waiting on the answer; // here somebody is — the admin is looking at the screen, and whether they // now have to send the link by hand is the thing they need to know. // // A failure does not roll the link back. The token is shown exactly once, so // a rollback would leave the admin retrying and holding a different link, // discarding work that succeeded for the sake of tidiness. let outcome: MailOutcome = 'skipped-unconfigured'; try { const template = renderTemplate('uploadLink', await loadStoredTemplate('uploadLink'), { submitUrl: url, label: link.label, submissionsAllowed: submissionsAllowed(link.max_submissions) }); outcome = await sendMail(email, template.subject, template.html); } catch (err) { // Reported, not thrown. The link exists and is usable; the admin needs to // be told the mail did not go, not handed a 500 for a link that was made. // // An SMTP rejection also lands here and is reported the same way as an // unconfigured environment, which is not strictly accurate — a fourth // outcome would be a real distinction, but nothing consumes it and the // admin's next action is identical either way: copy the link and send it // by hand. console.error(`[upload-links] could not email ${email}:`, err); outcome = 'skipped-unconfigured'; } res.status(201).json({ ...link, token, url, mail: { sent: outcome === 'sent', outcome } }); })); /** * Forgives the current ceiling window without deleting anything. * * The count is derived from item_drafts rows, which are real submissions with * real items in the review queue — so a reset moves the window's start rather * than removing anything. Recovery is automatic as the window rolls; this is * for the case where the ceiling was hit legitimately and waiting is not * acceptable. * * Declared above `/:id/revoke` deliberately: Express matches in order, and * `reset-ceiling` would otherwise be read as an id. */ router.post('/reset-ceiling', asyncRoute(async (_req: Request, res: Response) => { await pool.query( `INSERT INTO admin_settings (key, value, updated_at) VALUES ('intake_ceiling_reset_at', $1, now()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, [new Date().toISOString()] ); res.json({ reset: true }); })); router.post('/:id/revoke', asyncRoute(async (req: Request, res: Response) => { // COALESCE so revoking twice keeps the original timestamp. The useful fact // is when access ended, and a second click should neither rewrite that nor // fail — a button that errors on a double-click teaches people to distrust // it, which is the last thing wanted on the control that contains a leak. const { rows } = await pool.query( `UPDATE upload_links SET revoked_at = COALESCE(revoked_at, now()) WHERE id = $1 RETURNING id, label, contact_email, revoked_at, submission_count, max_submissions, last_used_at, created_at`, [req.params.id] ); const link = rows[0]; if (!link) { return res.status(404).json({ error: 'not found' }); } res.json(link); })); export default router;