feat(intake): count submissions across every link, and alert on abnormal volume (#227)
The count comes from item_drafts rather than a tally. Every submission creates exactly one 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. windowStart is the part worth testing on its own and the part most likely to be quietly wrong. A reset older than the window must not widen it, which would make the ceiling stricter over time rather than rolling; a reset in the future must not disable it; and a malformed value must not produce an Invalid Date, which compares false against everything and would silently switch off the limit it was set to impose. Each is a test. Alerts go through sendMail directly rather than 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. The throttle is in memory, so a restart during an incident can send one extra alert. That beats writing to admin_settings from the request path on every refused submission; a replicated deployment would have to move it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, number>();
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
await send(
|
||||
'ceiling',
|
||||
'Intake submissions are being refused',
|
||||
`<p>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.</p>` +
|
||||
`<p>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.</p>`
|
||||
);
|
||||
}
|
||||
|
||||
export async function alertLinkThreshold(
|
||||
linkId: number,
|
||||
label: string,
|
||||
used: number,
|
||||
threshold: number
|
||||
): Promise<void> {
|
||||
await send(
|
||||
`link:${linkId}`,
|
||||
`An upload link is being used heavily: ${label}`,
|
||||
`<p>The link <strong>${label}</strong> has taken ${used} submissions in the last 24 hours, ` +
|
||||
`past the alert threshold of ${threshold}.</p>` +
|
||||
`<p>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.</p>`
|
||||
);
|
||||
}
|
||||
@@ -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<number> {
|
||||
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<number> {
|
||||
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<CapacityVerdict> {
|
||||
const { intakeDailyCeiling, intakeCeilingResetAt } = await getSettings();
|
||||
const start = windowStart(now, intakeCeilingResetAt);
|
||||
const used = await countSince(start);
|
||||
|
||||
return { allowed: used < intakeDailyCeiling, used, ceiling: intakeDailyCeiling, start };
|
||||
}
|
||||
Reference in New Issue
Block a user