import { env } from "cloudflare:workers"; import { all, first, run } from "./db"; import { repoDb } from "./scoped-db"; import type { ScopedDb } from "./scoped-db"; import { locate } from "./scoped-db/locators"; import type { LocatorKind } from "./scoped-db/locators"; import { captureServerEvent } from "./analytics"; import { hmacSha256Hex, randomId } from "./crypto"; import { badRequest, notFoundError } from "./errors"; import { cookieSecret, publishMeta, SCRATCH_LIMITS } from "./sites/config"; import { loadSiteById } from "./sites/deploy"; import { resolveAbuseTarget } from "./abuse-targets"; import type { AbuseTarget } from "./abuse-targets"; import { recordAdminAction } from "./admin-audit"; import { isHideable, setModerationHidden } from "./moderation"; import { notifyAdmins } from "./account-disable-actions"; import { ABUSE_TARGET_LABELS } from "~/lib/abuse"; import type { AbuseAction, AbuseCategory, AbuseStatus, AbuseTargetKind } from "~/lib/abuse"; export { siteIdForUrl } from "./abuse-targets"; export type AbuseSource = "web" | "email" | "site"; /** * How much one reporter counts toward auto-pause. Signed-in users and mail whose sender domain passed DMARC are * accountable; an IP is cheap to rotate; unauthenticated mail can forge any sender, so it never pauses anything. */ export type ReporterKind = "user" | "ip" | "email-verified" | "email-unverified"; const REPORTER_WEIGHT: Record = { user: 2, "email-verified": 2, ip: 1, "email-unverified": 0, }; /** Phishing and malware hurt visitors immediately, so fewer distinct reporters are enough to hold the site. */ const SEVERE: ReadonlySet = new Set(["phishing", "malware"]); /** * Weighted reporter totals that pause a site (an IP weighs 1, an accountable reporter 2). Scratch sites are * anonymous and short-lived, so a few reports hold them. Owned sites count accountable reporters only and need * many of them; below that an admin decides. */ const PAUSE_THRESHOLDS = { scratch: { severe: 2, normal: SCRATCH_LIMITS.reportsToDisable, minWeight: 1 }, owned: { severe: 8, normal: 20, minWeight: 2 }, } 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, account id or sender domain; only its HMAC is stored. */ reporterKey: string; reporterKind: ReporterKind; /** 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; target_kind: AbuseTargetKind | null; target_id: string | null; category: string; details: string; reporter_email: string | null; reporter_weight: number; 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()}`); } /** Sites live in their repository's object; the locator directory says which. */ async function siteDb(siteId: string): Promise { const repoId = await locate("site", siteId); return repoId ? repoDb(repoId) : 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 db = await siteDb(siteId); if (!db) return false; const paused = await db.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 db = await siteDb(siteId); if (!db) return false; const restored = await db.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 db = await siteDb(siteId); const site = await db?.first<{ scratch: number; disabled_reason: string | null; label: string; }>("SELECT scratch, disabled_reason, label FROM sites WHERE id = ?", siteId); if (!site || site.disabled_reason) return false; const tier = site.scratch ? PAUSE_THRESHOLDS.scratch : PAUSE_THRESHOLDS.owned; const counts = await first<{ total: number | null; severe: number | null }>( `SELECT SUM(weight) AS total, SUM(CASE WHEN severe THEN weight ELSE 0 END) AS severe FROM ( SELECT reporter_hash, MAX(reporter_weight) AS weight, MAX(category IN ('phishing', 'malware')) AS severe FROM abuse_reports WHERE site_id = ? AND status = 'open' AND created_at > ? AND reporter_weight >= ? GROUP BY reporter_hash)`, siteId, Date.now() - PAUSE_WINDOW_MS, tier.minWeight, ); const total = counts?.total ?? 0; const severe = counts?.severe ?? 0; const trip = total >= tier.normal || (SEVERE.has(category) && severe >= tier.severe); if (!trip || !(await pauseSite(siteId))) return false; await notifyAdmins( `Site ${site.label} was auto-paused after abuse reports`, [`Weighted reporters: ${total}`, `Category: ${category}`, site.scratch ? "Scratch site" : "Owned site"], `abuse-paused:${siteId}:${Date.now()}`, "/admin/abuse", ); return true; } const NOTIFY_WINDOW_MS = 60 * 60 * 1000; /** * Admins hear about a target at most once an hour; reports that can never pause anything (weight 0, e.g. * unauthenticated mail) don't notify at all, so a flood of forged reports can't flood the admins either. */ async function notifiedRecently(target: AbuseTarget | null, targetUrl: string): Promise { const since = Date.now() - NOTIFY_WINDOW_MS; const recent = target ? await first( `SELECT 1 AS ok FROM abuse_reports WHERE target_kind = ? AND target_id = ? AND reporter_weight > 0 AND created_at > ? LIMIT 1`, target.kind, target.id, since, ) : await first( "SELECT 1 AS ok FROM abuse_reports WHERE target_kind IS NULL AND target_url = ? AND reporter_weight > 0 AND created_at > ? LIMIT 1", targetUrl, since, ); return recent !== null; } async function linkTarget(input: AbuseReportInput, targetUrl: string): Promise { if (input.siteId) return { kind: "site", id: input.siteId }; return resolveAbuseTarget(targetUrl); } /** * Records a report (one per reporter per target while open), tells the admins, and pauses a hosted site once * enough weighted reporters agree. */ export async function recordAbuseReport(input: AbuseReportInput): Promise<{ siteId: string | null; target: AbuseTarget | null; paused: boolean; }> { const targetUrl = input.targetUrl.trim().slice(0, MAX_URL); if (!targetUrl) throw badRequest("Tell us which page you are reporting"); const target = await linkTarget(input, targetUrl); const siteId = target?.kind === "site" ? target.id : null; const hash = await reporterHash(input.reporterKey); const duplicate = target ? await first<{ id: string }>( `SELECT id FROM abuse_reports WHERE target_kind = ? AND target_id = ? AND reporter_hash = ? AND status = 'open'`, target.kind, target.id, hash, ) : null; if (!duplicate) { const id = randomId(); const weight = REPORTER_WEIGHT[input.reporterKind]; const notify = weight > 0 && !(await notifiedRecently(target, targetUrl)); await run( `INSERT INTO abuse_reports (id, source, target_url, site_id, target_kind, target_id, category, details, reporter_email, reporter_hash, reporter_weight, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, input.source, targetUrl, siteId, target?.kind ?? null, target?.id ?? null, input.category, input.details.trim().slice(0, MAX_DETAILS), input.reporterEmail?.trim().slice(0, 320) || null, hash, weight, Date.now(), ); if (notify) { await notifyAdmins( `New abuse report: ${input.category}`, [ `Target: ${target ? ABUSE_TARGET_LABELS[target.kind] : "Unlinked URL"}`, `URL: ${targetUrl}`, `Source: ${input.source} (${input.reporterKind})`, ], `abuse-report:${id}`, "/admin/abuse", ); } } const paused = siteId ? await maybeAutoPause(siteId, input.category) : false; captureServerEvent("abuse_report_created", null, { source: input.source, category: input.category, duplicate: duplicate !== null, site: siteId !== null, target_kind: target?.kind ?? "unlinked", paused, }); return { siteId, target, paused }; } export type AbuseQueueEntry = AbuseReportRow & { site_label: string | null; site_paused_at: number | null; site_disabled_reason: string | null; owner_name: string | null; /** Hidden repo, issue or comment. */ target_hidden: number; }; const HIDDEN_TABLES: Partial> = { issue: { table: "issues", kind: "issue" }, comment: { table: "comments", kind: "comment" }, review_comment: { table: "review_comments", kind: "review_comment" }, discussion_comment: { table: "discussion_comments", kind: "discussion_comment", }, }; async function targetHidden(kind: string | null, id: string | null): Promise { if (!kind || !id) return 0; if (kind === "repo") { const repo = await first<{ hidden: number }>("SELECT hidden_at IS NOT NULL AS hidden FROM repos WHERE id = ?", id); return repo?.hidden ?? 0; } const target = HIDDEN_TABLES[kind]; const repoId = target ? await locate(target.kind, id) : null; if (!target || !repoId) return 0; const row = await repoDb(repoId).first<{ hidden: number }>( `SELECT hidden_at IS NOT NULL AS hidden FROM ${target.table} WHERE id = ?`, id, ); return row?.hidden ?? 0; } type SiteSummary = { label: string; paused_at: number | null; disabled_reason: string | null; owner_id: string | null; }; export async function listAbuseReports(status: AbuseStatus, limit = 100): Promise { const reports = await all( `SELECT r.id, r.source, r.target_url, r.site_id, r.target_kind, r.target_id, r.category, r.details, r.reporter_email, r.reporter_weight, r.status, r.resolution, r.resolved_by, r.resolved_at, r.created_at FROM abuse_reports r WHERE r.status = ? ORDER BY r.created_at DESC LIMIT ?`, status, limit, ); return Promise.all( reports.map(async (report) => { const site = report.site_id ? await ( await siteDb(report.site_id) )?.first( "SELECT label, paused_at, disabled_reason, owner_id FROM sites WHERE id = ?", report.site_id, ) : null; const ownerId = site?.owner_id ?? null; const owner = ownerId ? await first<{ name: string }>("SELECT name FROM accounts WHERE id = ?", ownerId) : report.target_kind === "repo" && report.target_id ? await first<{ name: string }>( "SELECT a.name FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.id = ?", report.target_id, ) : null; return { ...report, site_label: site?.label ?? null, site_paused_at: site?.paused_at ?? null, site_disabled_reason: site?.disabled_reason ?? null, owner_name: owner?.name ?? null, target_hidden: await targetHidden(report.target_kind, report.target_id), }; }), ); } const RESOLUTIONS: Record = { pause: "Site paused", restore: "Site restored", hide: "Target hidden", unhide: "Target restored", dismiss: "Dismissed", resolve: "Resolved", }; /** Pausing, hiding or resolving action the report; restoring or unhiding dismisses it as unfounded. */ const ACTION_STATUS: Record = { pause: "actioned", hide: "actioned", resolve: "actioned", restore: "dismissed", unhide: "dismissed", dismiss: "dismissed", }; /** * Admin decision on a report. Every action except a plain dismiss closes all open reports against the same * target, and each one is written to the admin audit log. */ export async function actOnAbuseReport( reportId: string, action: AbuseAction, adminId: string, reason: string | null = null, ): Promise { const report = await first<{ site_id: string | null; target_kind: string | null; target_id: string | null; }>("SELECT site_id, target_kind, target_id 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 === "hide" || action === "unhide") && !(report.target_id && isHideable(report.target_kind ?? ""))) { throw badRequest("This report is not about a repository, issue or comment"); } if (action === "pause" && report.site_id) await pauseSite(report.site_id); if (action === "restore" && report.site_id) await restoreSite(report.site_id); if ((action === "hide" || action === "unhide") && report.target_kind && report.target_id) { await setModerationHidden(report.target_kind, report.target_id, action === "hide", adminId, reason); } const now = Date.now(); const params = [ACTION_STATUS[action], RESOLUTIONS[action], adminId, now] as const; if (action !== "dismiss" && report.target_kind && report.target_id) { await run( `UPDATE abuse_reports SET status = ?, resolution = ?, resolved_by = ?, resolved_at = ? WHERE (target_kind = ? AND target_id = ? AND status = 'open') OR id = ?`, ...params, report.target_kind, report.target_id, reportId, ); } else { await run( "UPDATE abuse_reports SET status = ?, resolution = ?, resolved_by = ?, resolved_at = ? WHERE id = ?", ...params, reportId, ); } await recordAdminAction({ actorId: adminId, action: `abuse.${action}`, targetKind: "abuse_report", targetId: reportId, reason, }); } export function abuseForwardAddress(): string | null { const address = env.ABUSE_FORWARD_TO?.trim(); return address ? address : null; }