this repo has no description
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 216 lines 6.0 kB view raw
1/** 2 * User-submitted reports against registry profiles. Backed by the 3 * `report` table; admin UI lives at /admin/reports, public submission 4 * at POST /api/registry/profile/:id/report. 5 * 6 * IPs are hashed with `REPORT_IP_SECRET` so we can dedup repeated 7 * submissions from the same source within 24h without ever storing the 8 * raw address. Authenticated reports additionally record the 9 * reporter's DID. 10 */ 11import { withDb } from "./db.ts"; 12import { REPORT_IP_SECRET } from "./env.ts"; 13 14export const REPORT_REASONS = [ 15 "not_a_project", 16 "harmful", 17 "impersonation", 18 "spam", 19 "other", 20] as const; 21export type ReportReason = typeof REPORT_REASONS[number]; 22 23export type ReportStatus = "open" | "actioned" | "dismissed"; 24 25export interface ReportRow { 26 id: number; 27 targetDid: string; 28 reporterDid: string | null; 29 reason: ReportReason; 30 details: string | null; 31 status: ReportStatus; 32 adminNotes: string | null; 33 createdAt: number; 34 resolvedAt: number | null; 35 resolvedBy: string | null; 36} 37 38interface RawReportRow { 39 id: number; 40 target_did: string; 41 reporter_did: string | null; 42 reason: string; 43 details: string | null; 44 status: string; 45 admin_notes: string | null; 46 created_at: number; 47 resolved_at: number | null; 48 resolved_by: string | null; 49} 50 51function rowToReport(r: RawReportRow): ReportRow { 52 const reason = (REPORT_REASONS as readonly string[]).includes(r.reason) 53 ? r.reason as ReportReason 54 : "other"; 55 const status = r.status === "actioned" || r.status === "dismissed" 56 ? r.status 57 : "open"; 58 return { 59 id: Number(r.id), 60 targetDid: r.target_did, 61 reporterDid: r.reporter_did, 62 reason, 63 details: r.details, 64 status, 65 adminNotes: r.admin_notes, 66 createdAt: Number(r.created_at), 67 resolvedAt: r.resolved_at != null ? Number(r.resolved_at) : null, 68 resolvedBy: r.resolved_by, 69 }; 70} 71 72const DEDUP_WINDOW_MS = 24 * 60 * 60 * 1000; 73 74/** Best-effort caller IP — same approach as `lib/rate-limit.ts`. */ 75export function callerIp(req: Request): string { 76 const xff = req.headers.get("x-forwarded-for"); 77 if (xff) { 78 const first = xff.split(",")[0]?.trim(); 79 if (first) return first; 80 } 81 const real = req.headers.get("x-real-ip"); 82 if (real) return real.trim(); 83 return "anonymous"; 84} 85 86export async function hashIp(ip: string): Promise<string> { 87 const data = new TextEncoder().encode(`${ip}|${REPORT_IP_SECRET}`); 88 const buf = await crypto.subtle.digest("SHA-256", data); 89 const bytes = new Uint8Array(buf); 90 let hex = ""; 91 for (let i = 0; i < bytes.length; i++) { 92 hex += bytes[i].toString(16).padStart(2, "0"); 93 } 94 return hex; 95} 96 97export interface CreateReportInput { 98 targetDid: string; 99 reporterDid: string | null; 100 ipHash: string | null; 101 reason: ReportReason; 102 details?: string | null; 103} 104 105export type CreateReportResult = 106 | { ok: true; id: number } 107 | { ok: false; reason: "duplicate" }; 108 109export async function createReport( 110 input: CreateReportInput, 111): Promise<CreateReportResult> { 112 return await withDb(async (c) => { 113 if (input.ipHash) { 114 const since = Date.now() - DEDUP_WINDOW_MS; 115 const dup = await c.execute({ 116 sql: ` 117 SELECT id FROM report 118 WHERE target_did = ? AND reporter_ip_hash = ? AND reason = ? AND created_at >= ? 119 LIMIT 1 120 `, 121 args: [input.targetDid, input.ipHash, input.reason, since], 122 }); 123 if (dup.rows.length > 0) { 124 return { ok: false, reason: "duplicate" }; 125 } 126 } 127 const r = await c.execute({ 128 sql: ` 129 INSERT INTO report ( 130 target_did, reporter_did, reporter_ip_hash, reason, details, 131 status, created_at 132 ) VALUES (?, ?, ?, ?, ?, 'open', ?) 133 `, 134 args: [ 135 input.targetDid, 136 input.reporterDid, 137 input.ipHash, 138 input.reason, 139 input.details ?? null, 140 Date.now(), 141 ], 142 }); 143 const id = Number(r.lastInsertRowid ?? 0); 144 return { ok: true, id }; 145 }); 146} 147 148export async function listOpenReports(): Promise<ReportRow[]> { 149 return await withDb(async (c) => { 150 const r = await c.execute(` 151 SELECT id, target_did, reporter_did, reason, details, status, 152 admin_notes, created_at, resolved_at, resolved_by 153 FROM report 154 WHERE status = 'open' 155 ORDER BY created_at ASC 156 `); 157 return r.rows.map((row) => rowToReport(row as unknown as RawReportRow)); 158 }); 159} 160 161export async function countOpenReports(): Promise<number> { 162 return await withDb(async (c) => { 163 const r = await c.execute( 164 `SELECT COUNT(*) AS n FROM report WHERE status = 'open'`, 165 ); 166 return Number((r.rows[0] as Record<string, unknown>).n ?? 0); 167 }); 168} 169 170export async function resolveReport( 171 id: number, 172 resolver: string, 173 status: "actioned" | "dismissed", 174 notes?: string | null, 175): Promise<void> { 176 await withDb(async (c) => { 177 await c.execute({ 178 sql: ` 179 UPDATE report SET 180 status = ?, 181 admin_notes = ?, 182 resolved_at = ?, 183 resolved_by = ? 184 WHERE id = ? 185 `, 186 args: [status, notes ?? null, Date.now(), resolver, id], 187 }); 188 }); 189} 190 191/** 192 * Bulk-resolve every open report against a single target. Used when an 193 * admin takes a profile down — reports are then "actioned" implicitly 194 * by the takedown itself, so leaving them in the inbox would be noise. 195 * Returns the number of rows updated for logging / UI feedback. 196 */ 197export async function resolveOpenReportsForTarget( 198 targetDid: string, 199 resolver: string, 200 notes?: string | null, 201): Promise<number> { 202 return await withDb(async (c) => { 203 const r = await c.execute({ 204 sql: ` 205 UPDATE report SET 206 status = 'actioned', 207 admin_notes = ?, 208 resolved_at = ?, 209 resolved_by = ? 210 WHERE target_did = ? AND status = 'open' 211 `, 212 args: [notes ?? null, Date.now(), resolver, targetDid], 213 }); 214 return Number(r.rowsAffected ?? 0); 215 }); 216}