diff --git a/backend/src/intake/abuseAlert.ts b/backend/src/intake/abuseAlert.ts new file mode 100644 index 0000000..5683123 --- /dev/null +++ b/backend/src/intake/abuseAlert.ts @@ -0,0 +1,74 @@ +import { sendMail } from '../mailer'; +import { getSettings } from '../adminSettings'; + +/** + * Abuse alerts, sent directly rather than through the editable templates. + * + * An abuse alert is not copy anyone will want to reword, and making it editable + * means it can be broken — a required placeholder removed from an alert nobody + * reads until an incident is a poor way to discover the validation. + */ + +/** At most one of each kind per hour. */ +const ALERT_INTERVAL_MS = 60 * 60 * 1000; + +/** + * Throttled in memory rather than in the database. + * + * A restart loses it, so a deploy during an incident can send one extra alert. + * That is a far better trade than writing to admin_settings from the request + * path on every refused submission. This is a single-container deployment; were + * it ever replicated, each replica would alert once per window and this would + * have to move. + */ +const lastSent = new Map(); + +function shouldSend(key: string, now: number): boolean { + const previous = lastSent.get(key); + if (previous !== undefined && now - previous < ALERT_INTERVAL_MS) return false; + lastSent.set(key, now); + return true; +} + +/** Exported for tests, which need each case to start from silence. */ +export function resetAlertThrottleForTests(): void { + lastSent.clear(); +} + +async function send(key: string, subject: string, html: string): Promise { + const { intakeNotifyEmail } = await getSettings(); + const to = intakeNotifyEmail?.trim(); + if (!to) return; + if (!shouldSend(key, Date.now())) return; + + await sendMail(to, subject, html); +} + +export async function alertCeilingReached(used: number, ceiling: number): Promise { + await send( + 'ceiling', + 'Intake submissions are being refused', + `

The intake surface has taken ${used} submissions in the last 24 hours, which is at or ` + + `over the ceiling of ${ceiling}. Further submissions are being refused until the window ` + + `rolls.

` + + `

The storefront, checkout and admin are unaffected. If this is legitimate, raise the ` + + `ceiling or reset the window. If it is not, revoke the link being used.

` + ); +} + +export async function alertLinkThreshold( + linkId: number, + label: string, + used: number, + threshold: number +): Promise { + await send( + `link:${linkId}`, + `An upload link is being used heavily: ${label}`, + `

The link ${label} has taken ${used} submissions in the last 24 hours, ` + + `past the alert threshold of ${threshold}.

` + + `

This is the signal that a link has been shared further than intended. If that is what ` + + `has happened, revoke it from the Upload links screen — the submissions already received ` + + `are kept, and are in the review queue.

` + ); +} diff --git a/backend/src/intake/capacity.ts b/backend/src/intake/capacity.ts new file mode 100644 index 0000000..bf57a70 --- /dev/null +++ b/backend/src/intake/capacity.ts @@ -0,0 +1,68 @@ +import { pool } from '../db'; +import { getSettings } from '../adminSettings'; + +/** A rolling day. */ +export const WINDOW_MS = 24 * 60 * 60 * 1000; + +/** + * Where the counting window starts. + * + * The later of "24 hours ago" and an explicit reset, so a reset forgives what + * came before it without deleting anything — those submissions are real and + * their items are sitting in the review queue either way. + * + * A reset older than the window, unparseable, or in the future falls back to + * the rolling day. The malformed case matters most: an Invalid Date compares + * false against everything, so a typo in this setting would silently disable + * the ceiling it was written to impose. + */ +export function windowStart(now: Date, resetAt: string): Date { + const rolling = new Date(now.getTime() - WINDOW_MS); + if (resetAt.trim() === '') return rolling; + + const reset = new Date(resetAt); + if (Number.isNaN(reset.getTime())) return rolling; + if (reset > now) return rolling; + + return reset > rolling ? reset : rolling; +} + +/** + * Counted from the draft rows themselves rather than from a tally. + * + * Every submission creates exactly one item_drafts row, in the same transaction + * that creates the item, so the rows are the truth. A separate counter would be + * a second thing that can disagree with them — and the one that disagrees + * silently is always the counter. + */ +export async function countSince(start: Date): Promise { + const { rows } = await pool.query<{ count: string }>( + `SELECT count(*)::text AS count FROM item_drafts WHERE created_at > $1`, + [start] + ); + return Number(rows[0]?.count ?? 0); +} + +export async function countForLinkSince(linkId: number, start: Date): Promise { + const { rows } = await pool.query<{ count: string }>( + `SELECT count(*)::text AS count FROM item_drafts WHERE upload_link_id = $1 AND created_at > $2`, + [linkId, start] + ); + return Number(rows[0]?.count ?? 0); +} + +export interface CapacityVerdict { + allowed: boolean; + used: number; + ceiling: number; + start: Date; +} + +/** Whether the intake surface as a whole has room for one more. */ +export async function checkCapacity(now: Date = new Date()): Promise { + const { intakeDailyCeiling, intakeCeilingResetAt } = await getSettings(); + const start = windowStart(now, intakeCeilingResetAt); + const used = await countSince(start); + + return { allowed: used < intakeDailyCeiling, used, ceiling: intakeDailyCeiling, start }; +} diff --git a/backend/tests/unit/capacity.test.ts b/backend/tests/unit/capacity.test.ts new file mode 100644 index 0000000..e83004f --- /dev/null +++ b/backend/tests/unit/capacity.test.ts @@ -0,0 +1,37 @@ +import { windowStart, WINDOW_MS } from '../../src/intake/capacity'; + +const NOW = new Date('2026-09-01T12:00:00.000Z'); +const DAY_AGO = new Date(NOW.getTime() - WINDOW_MS); + +describe('windowStart', () => { + it('is 24 hours ago when there has been no reset', () => { + expect(windowStart(NOW, '').toISOString()).toBe(DAY_AGO.toISOString()); + }); + + // A reset inside the window is the point of it: it forgives what came before + // without deleting anything. + it('is the reset when the reset is more recent', () => { + const reset = '2026-09-01T09:00:00.000Z'; + expect(windowStart(NOW, reset).toISOString()).toBe(reset); + }); + + // An old reset must not widen the window beyond 24 hours, which would make + // the ceiling stricter over time rather than rolling. + it('ignores a reset older than the window', () => { + expect(windowStart(NOW, '2026-08-01T00:00:00.000Z').toISOString()).toBe(DAY_AGO.toISOString()); + }); + + // A malformed stored value must not produce an Invalid Date, which compares + // false against everything and would silently disable the ceiling. + it('falls back to 24 hours ago for an unparseable reset', () => { + expect(windowStart(NOW, 'not-a-date').toISOString()).toBe(DAY_AGO.toISOString()); + }); + + it('falls back for a reset in the future', () => { + expect(windowStart(NOW, '2027-01-01T00:00:00.000Z').toISOString()).toBe(DAY_AGO.toISOString()); + }); + + it('tolerates whitespace around an empty setting', () => { + expect(windowStart(NOW, ' ').toISOString()).toBe(DAY_AGO.toISOString()); + }); +});