this repo has no description
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}