The revoke route's RETURNING clause omitted contact_email while its result was typed as UploadLinkRow, which declares the field as present. No user-visible effect since the response was never checked for it, but the type asserted something the query did not actually return. Added the column so the two agree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
197 lines
7.9 KiB
TypeScript
197 lines
7.9 KiB
TypeScript
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<UploadLinkRow>(`${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<UploadLinkRow>(
|
|
`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<UploadLinkRow>(
|
|
`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;
|