import { env } from "cloudflare:workers"; import { all, first, run } from "./db"; import { hmacSha256Hex, randomId } from "./crypto"; import { badRequest, notFoundError } from "./errors"; import { cookieSecret, publishMeta, SCRATCH_LIMITS, sitesDomain, } from "./sites/config"; import { loadSiteById } from "./sites/deploy"; import { hostLabel } from "./sites/naming"; import type { AbuseAction, AbuseCategory, AbuseStatus } from "~/lib/abuse"; export type AbuseSource = "web" | "email" | "site"; /** Phishing and malware hurt visitors immediately, so fewer distinct reporters are enough to hold the site. */ const SEVERE: ReadonlySet = new Set(["phishing", "malware"]); const PAUSE_THRESHOLDS = { scratch: { severe: 2, normal: SCRATCH_LIMITS.reportsToDisable }, owned: { severe: 3, normal: 5 }, } as const; const PAUSE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; export const PAUSE_REASON = "Paused pending abuse review"; const MAX_DETAILS = 4000; const MAX_URL = 2000; export type AbuseReportInput = { source: AbuseSource; targetUrl: string; category: AbuseCategory; details: string; reporterEmail: string | null; /** Raw IP or sender address; only its HMAC is stored. */ reporterKey: string; /** Known when the report came from the site itself, so the URL need not be resolved. */ siteId?: string; }; export type AbuseReportRow = { id: string; source: AbuseSource; target_url: string; site_id: string | null; category: string; details: string; reporter_email: string | null; status: AbuseStatus; resolution: string | null; resolved_by: string | null; resolved_at: number | null; created_at: number; }; const CATEGORY_HINTS: [AbuseCategory, RegExp][] = [ ["phishing", /phish|credential|password|login page|impersonat|scam|fraud/i], [ "malware", /malware|virus|trojan|ransom|miner|malicious (file|script|download)/i, ], ["copyright", /copyright|dmca|infring|trademark/i], ["spam", /\bspam|seo|unsolicited/i], ["harassment", /harass|threat|doxx|abus(e|ive) (toward|against)/i], ["illegal", /csam|child|illegal|terror/i], ]; /** Best-effort category for free-text reports (site endpoint, email); admins can still read the details. */ export function guessAbuseCategory(text: string): AbuseCategory { return ( CATEGORY_HINTS.find(([, pattern]) => pattern.test(text))?.[0] ?? "other" ); } function reporterHash(key: string): Promise { return hmacSha256Hex( cookieSecret(), `abuse-reporter:${key.trim().toLowerCase()}`, ); } function parseHost(targetUrl: string): string | null { try { return new URL(targetUrl).hostname.toLowerCase(); } catch { return null; } } /** Maps a reported URL to the hosted site behind it: site labels, preview labels, then verified custom domains. */ export async function siteIdForUrl(targetUrl: string): Promise { const host = parseHost(targetUrl); if (!host) return null; const domain = sitesDomain(); const label = domain ? hostLabel(host, domain) : null; if (label) { const site = await first<{ id: string }>( "SELECT id FROM sites WHERE label = ?", label, ); if (site) return site.id; const preview = await first<{ site_id: string }>( "SELECT site_id FROM site_deployments WHERE label = ? ORDER BY created_at DESC LIMIT 1", label, ); return preview?.site_id ?? null; } const custom = await first<{ id: string }>( "SELECT id FROM sites WHERE custom_domain = ? COLLATE NOCASE", host, ); return custom?.id ?? null; } /** Holds a site for review. Sites already disabled for another reason keep that reason. */ export async function pauseSite(siteId: string): Promise { const now = Date.now(); const paused = await run( "UPDATE sites SET disabled_reason = ?, paused_at = ?, updated_at = ? WHERE id = ? AND disabled_reason IS NULL", PAUSE_REASON, now, now, siteId, ); if (!paused.meta.changes) return false; const site = await loadSiteById(siteId); if (site) await publishMeta(site); return true; } /** Lifts an abuse pause only; disables from expiry, sync or the scratch reaper stay in place. */ export async function restoreSite(siteId: string): Promise { const restored = await run( "UPDATE sites SET disabled_reason = NULL, paused_at = NULL, updated_at = ? WHERE id = ? AND paused_at IS NOT NULL", Date.now(), siteId, ); if (!restored.meta.changes) return false; const site = await loadSiteById(siteId); if (site) await publishMeta(site); return true; } async function maybeAutoPause( siteId: string, category: AbuseCategory, ): Promise { const site = await first<{ scratch: number; disabled_reason: string | null }>( "SELECT scratch, disabled_reason FROM sites WHERE id = ?", siteId, ); if (!site || site.disabled_reason) return false; const tier = site.scratch ? PAUSE_THRESHOLDS.scratch : PAUSE_THRESHOLDS.owned; const since = Date.now() - PAUSE_WINDOW_MS; const counts = await first<{ reporters: number; severe: number }>( `SELECT COUNT(DISTINCT reporter_hash) AS reporters, COUNT(DISTINCT CASE WHEN category IN ('phishing', 'malware') THEN reporter_hash END) AS severe FROM abuse_reports WHERE site_id = ? AND status = 'open' AND created_at > ?`, siteId, since, ); const reporters = counts?.reporters ?? 0; const severe = counts?.severe ?? 0; const trip = reporters >= tier.normal || (SEVERE.has(category) && severe >= tier.severe); return trip ? pauseSite(siteId) : false; } /** Records a report (one per reporter per site while open) and pauses the site once enough reporters agree. */ export async function recordAbuseReport( input: AbuseReportInput, ): Promise<{ siteId: string | null; paused: boolean }> { const targetUrl = input.targetUrl.trim().slice(0, MAX_URL); if (!targetUrl) throw badRequest("Tell us which page you are reporting"); const siteId = input.siteId ?? (await siteIdForUrl(targetUrl)); const hash = await reporterHash(input.reporterKey); const duplicate = siteId ? await first<{ id: string }>( "SELECT id FROM abuse_reports WHERE site_id = ? AND reporter_hash = ? AND status = 'open'", siteId, hash, ) : null; if (!duplicate) { await run( `INSERT INTO abuse_reports (id, source, target_url, site_id, category, details, reporter_email, reporter_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, randomId(), input.source, targetUrl, siteId, input.category, input.details.trim().slice(0, MAX_DETAILS), input.reporterEmail?.trim().slice(0, 320) || null, hash, Date.now(), ); } const paused = siteId ? await maybeAutoPause(siteId, input.category) : false; return { siteId, paused }; } export type AbuseQueueEntry = AbuseReportRow & { site_label: string | null; site_paused_at: number | null; site_disabled_reason: string | null; owner_name: string | null; }; export async function listAbuseReports( status: AbuseStatus, limit = 100, ): Promise { return all( `SELECT r.id, r.source, r.target_url, r.site_id, r.category, r.details, r.reporter_email, r.status, r.resolution, r.resolved_by, r.resolved_at, r.created_at, s.label AS site_label, s.paused_at AS site_paused_at, s.disabled_reason AS site_disabled_reason, a.name AS owner_name FROM abuse_reports r LEFT JOIN sites s ON s.id = r.site_id LEFT JOIN accounts a ON a.id = s.owner_id WHERE r.status = ? ORDER BY r.created_at DESC LIMIT ?`, status, limit, ); } /** * Admin decision on a report. Pausing or resolving marks every open report for that site actioned; restoring the * site dismisses them, since the reports were judged unfounded. */ export async function actOnAbuseReport( reportId: string, action: AbuseAction, adminId: string, ): Promise { const report = await first<{ site_id: string | null; status: AbuseStatus }>( "SELECT site_id, status FROM abuse_reports WHERE id = ?", reportId, ); if (!report) throw notFoundError("Report not found"); if ((action === "pause" || action === "restore") && !report.site_id) { throw badRequest("This report is not linked to a hosted site"); } if (action === "pause" && report.site_id) await pauseSite(report.site_id); if (action === "restore" && report.site_id) await restoreSite(report.site_id); const status: AbuseStatus = action === "pause" || action === "resolve" ? "actioned" : "dismissed"; const resolution = { pause: "Site paused", restore: "Site restored", dismiss: "Dismissed", resolve: "Resolved", }[action]; const now = Date.now(); if (report.site_id) { await run( `UPDATE abuse_reports SET status = ?, resolution = ?, resolved_by = ?, resolved_at = ? WHERE site_id = ? AND (status = 'open' OR id = ?)`, status, resolution, adminId, now, report.site_id, reportId, ); return; } await run( "UPDATE abuse_reports SET status = ?, resolution = ?, resolved_by = ?, resolved_at = ? WHERE id = ?", status, resolution, adminId, now, reportId, ); } export function abuseForwardAddress(): string | null { const address = env.ABUSE_FORWARD_TO?.trim(); return address ? address : null; }