report system

This commit is contained in:
k
2026-09-21 21:51:38 +02:00
parent 4cefd3e17f
commit aa33828215
15 changed files with 1379 additions and 75 deletions
+48
View File
@@ -0,0 +1,48 @@
import type { AccessMessage, AccessRequest } from "./access.ts";
/**
* Live access-request updates over WebSocket (replaces HTTP polling).
*
* Two channels, mirroring the HTTP auth model (no weakening):
* - ticket socket `/api/access/ws/:id` — capability auth by unguessable
* UUID, exactly like GET /api/access/ticket/:id. Receives this ticket's
* messages + status changes.
* - admin socket `/api/admin/ws?token=…` — admin Bearer (query param
* because browsers can't set WS headers). Receives every access event,
* driving the AdminCenter badge + triage list.
*/
export type AccessLiveEvent =
| { type: "access_message"; request_id: string; message: AccessMessage }
| { type: "access_status"; request_id: string; request: AccessRequest }
| { type: "access_created"; request_id: string; request: AccessRequest };
export interface AccessLiveSubscriber {
/** Ticket to follow, or null for the admin firehose. */
ticketId: string | null;
isAdmin: boolean;
/** Send a pre-serialized frame. Return false when the socket is dead. */
send: (data: string) => boolean;
}
const subscribers = new Set<AccessLiveSubscriber>();
export function subscribeAccessLive(s: AccessLiveSubscriber): () => void {
subscribers.add(s);
return () => {
subscribers.delete(s);
};
}
export function broadcastAccessLive(evt: AccessLiveEvent): void {
if (subscribers.size === 0) return;
const data = JSON.stringify(evt);
for (const s of [...subscribers]) {
if (s.ticketId !== evt.request_id && !(s.isAdmin && s.ticketId === null)) continue;
try {
if (!s.send(data)) subscribers.delete(s);
} catch {
subscribers.delete(s);
}
}
}
+157 -3
View File
@@ -66,6 +66,19 @@ import {
setAccessStatus,
type AccessStatus,
} from "./access.ts";
import { broadcastAccessLive, subscribeAccessLive } from "./accessLive.ts";
import {
REPORT_REASONS,
REPORT_STATUSES,
createReport,
getReport,
initReportsTables,
listReports,
setReportShadowbanned,
setReportStatus,
type ReportReason,
type ReportStatus,
} from "./reports.ts";
import type { ConversationRow } from "./db.ts";
import type { OllamaDriver } from "./drivers/ollama.ts";
import { DriverRegistry } from "./drivers/registry.ts";
@@ -95,6 +108,7 @@ const registry = new DriverRegistry();
const ollamaDriver = registry.get("ollama") as OllamaDriver;
await tools.init();
initAccessTables();
initReportsTables();
initTotpTables();
function json(data: unknown, status = 200): Response {
@@ -179,13 +193,48 @@ async function readJson(req: Request): Promise<{ ok: boolean; body: Record<strin
}
}
const server = Bun.serve({
/** WebSocket -> hub unsubscribe (cleaned up on close). */
const wsUnsub = new WeakMap<object, () => void>();
const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
port: config.port,
hostname: config.host,
// SSE chat streams can legitimately go silent for a while (cold model load,
// slow/thinking models). Bun's default 10s idle timeout would kill them
// mid-generation — disable it (0). Nginx in front has its own timeouts.
idleTimeout: 0,
websocket: {
open(ws) {
wsUnsub.set(
ws,
subscribeAccessLive({
ticketId: ws.data.ticketId,
isAdmin: ws.data.isAdmin,
send: (frame) => {
try {
ws.send(frame);
return true;
} catch {
return false;
}
},
}),
);
},
message(ws, msg) {
// Heartbeat only — clients ping, we pong. Anything else is ignored.
try {
const m = JSON.parse(typeof msg === "string" ? msg : Buffer.from(msg as Uint8Array).toString());
if (m?.type === "ping") ws.send(JSON.stringify({ type: "pong" }));
} catch {
// malformed frame — ignore
}
},
close(ws) {
wsUnsub.get(ws)?.();
wsUnsub.delete(ws);
},
},
async fetch(req, server) {
const url = new URL(req.url);
const path = url.pathname;
@@ -475,8 +524,10 @@ const server = Bun.serve({
return json({ error: "a request for this username or email is already open" }, 409);
}
const created = createAccessRequest({ username, email, message });
addAccessMessage(created.id, "user", message);
const firstMsg = addAccessMessage(created.id, "user", message);
clearAttempts(ip);
broadcastAccessLive({ type: "access_created", request_id: created.id, request: created });
broadcastAccessLive({ type: "access_message", request_id: created.id, message: firstMsg });
// Ticket page: /review/<uuid> (unguessable, same pattern as share links).
const reviewUrl = `${config.appUrl}/review/${created.id}`;
sendAccessReceivedEmail(email, username, reviewUrl).catch(() => {});
@@ -504,11 +555,31 @@ const server = Bun.serve({
}
const msg = addAccessMessage(r.id, "user", text);
clearAttempts(ip);
broadcastAccessLive({ type: "access_message", request_id: r.id, message: msg });
return json({ message: msg }, 201);
}
return json({ error: "not found" }, 404);
}
// --- live access-request updates (WebSocket, replaces HTTP polling) ---
// Ticket socket: capability auth by unguessable UUID, exactly like
// GET /api/access/ticket/:id above. Admin socket: admin Bearer via
// ?token= (browsers can't set WS headers) or Authorization header.
const ticketWsMatch = path.match(/^\/api\/access\/ws\/([0-9a-f-]{36})$/);
if (ticketWsMatch && req.method === "GET") {
const t = getAccessRequest(ticketWsMatch[1]!);
if (!t) return json({ error: "invalid ticket link" }, 404);
if (server.upgrade(req, { data: { ticketId: t.id, isAdmin: false } })) return;
return json({ error: "websocket upgrade failed" }, 500);
}
if (path === "/api/admin/ws" && req.method === "GET") {
const token = url.searchParams.get("token") ?? extractBearer(req);
const u = token ? verifyToken(token) : null;
if (!u?.isAdmin) return json({ error: "unauthorized" }, 401);
if (server.upgrade(req, { data: { ticketId: null, isAdmin: true } })) return;
return json({ error: "websocket upgrade failed" }, 500);
}
// --- everything below requires auth ---
if (path.startsWith("/api/")) {
const user = requireAuth(req);
@@ -643,7 +714,7 @@ const server = Bun.serve({
return json({ user: toPublicUser(created), key }, 201);
}
const adminUserMatch = path.match(/^\/api\/admin\/users\/([0-9a-f-]{10,50})(\/regenerate)?$/);
const adminUserMatch = path.match(/^\/api\/admin\/users\/([0-9a-f-]{10,50})(\/regenerate|\/shadowban)?$/);
if (adminUserMatch) {
if (!admin) return json({ error: "forbidden" }, 403);
const target = getUserById(adminUserMatch[1]!);
@@ -658,6 +729,13 @@ const server = Bun.serve({
deleteUserSessions(target.id);
return json({ key });
}
if (req.method === "POST" && adminUserMatch[2] === "/shadowban") {
const { ok, body } = await readJson(req);
if (!ok || typeof body.shadowbanned !== "boolean") {
return json({ error: "expected { shadowbanned: boolean }" }, 400);
}
return json({ shadowbanned: setReportShadowbanned(target.id, body.shadowbanned) === 1 });
}
if (req.method === "PATCH" && !adminUserMatch[2]) {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
@@ -828,6 +906,80 @@ const server = Bun.serve({
}
}
// --- content reports (flagged AI responses) ---
if (path === "/api/reports" && req.method === "POST") {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const reason = body.reason as ReportReason | undefined;
if (!reason || !(REPORT_REASONS as readonly string[]).includes(reason)) {
return json({ error: "reason must be copyright|gore|falseinfo|bug" }, 400);
}
const details = body.details === undefined ? "" : cleanStr(body.details, 2000);
if (details === null) return json({ error: "details: max 2000 chars" }, 400);
const clientContent = typeof body.content === "string" ? body.content.slice(0, 20000) : "";
const clientPrompt = typeof body.prompt === "string" ? body.prompt.slice(0, 20000) : "";
const clientModel = typeof body.model === "string" ? body.model.slice(0, 160) : "";
let conversationId = "";
let messageIndex = -1;
if (body.conversationId !== undefined) {
if (typeof body.conversationId !== "string" || !body.conversationId) {
return json({ error: "bad conversationId" }, 400);
}
const conv = getConversation(body.conversationId);
if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404);
conversationId = conv.id;
if (body.messageIndex !== undefined) {
if (!Number.isInteger(body.messageIndex) || (body.messageIndex as number) < 0) {
return json({ error: "bad messageIndex" }, 400);
}
messageIndex = body.messageIndex as number;
}
}
try {
const report = createReport({
reporterId: user.id,
reporterName: user.displayName,
conversationId,
messageIndex,
reason,
details,
clientContent,
clientPrompt,
clientModel,
});
return json({ report }, 201);
} catch (e) {
if ((e as Error).message === "TOO_MANY_OPEN") return json({ error: "too many open reports" }, 429);
if ((e as Error).message === "ALREADY_REPORTED") {
return json({ error: "already reported", report: (e as Error & { report: unknown }).report }, 409);
}
throw e;
}
}
if (path === "/api/admin/reports" && req.method === "GET") {
if (!admin) return json({ error: "forbidden" }, 403);
return json({ reports: listReports() });
}
const reportMatch = path.match(/^\/api\/admin\/reports\/([0-9a-f-]{36})$/);
if (reportMatch) {
if (!admin) return json({ error: "forbidden" }, 403);
const target = getReport(reportMatch[1]!);
if (!target) return json({ error: "report not found" }, 404);
if (req.method === "PATCH") {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const status = body.status as ReportStatus | undefined;
if (!status || !(REPORT_STATUSES as readonly string[]).includes(status)) {
return json({ error: "status must be open|reviewing|resolved|dismissed" }, 400);
}
const adminNote = body.adminNote === undefined ? "" : cleanStr(body.adminNote, 1000);
if (adminNote === null) return json({ error: "adminNote: max 1000 chars" }, 400);
return json({ report: setReportStatus(target.id, status, adminNote) });
}
}
// --- admin: access-request wishlist triage ---
if (path === "/api/admin/access" && req.method === "GET") {
if (!admin) return json({ error: "forbidden" }, 403);
@@ -850,6 +1002,7 @@ const server = Bun.serve({
const msg = addAccessMessage(target.id, "admin", text);
// Every admin reply is forwarded to the requester (notification-only).
sendAccessMessageEmail(target.email, target.username, true, text, reviewUrl).catch(() => {});
broadcastAccessLive({ type: "access_message", request_id: target.id, message: msg });
return json({ message: msg });
}
if (req.method === "PATCH" && !accessMatch[2]) {
@@ -874,6 +1027,7 @@ const server = Bun.serve({
if (status !== target.status && status !== "pending") {
sendAccessStatusEmail(target.email, target.username, status, reason, reviewUrl, key).catch(() => {});
}
if (updated) broadcastAccessLive({ type: "access_status", request_id: updated.id, request: updated });
return json({ request: updated, ...(key ? { key } : {}) });
}
return json({ error: "not found" }, 404);
+180
View File
@@ -0,0 +1,180 @@
import { randomUUID } from "node:crypto";
import { getConversation, getDb, getMessages } from "./db.ts";
/**
* Content reports (user-flagged AI responses). Tables are created
* idempotently here so db.ts stays untouched — same pattern as access.ts.
*
* Flow: user hits "Report" under an assistant message, picks a reason
* (copyright / gore / falseinfo / bug) -> row in `reports` with a content
* snapshot (conversation may later be edited or deleted) -> admins triage
* in the AdminCenter (open / reviewing / resolved / dismissed + note).
*/
export const REPORT_REASONS = ["copyright", "gore", "falseinfo", "bug"] as const;
export type ReportReason = (typeof REPORT_REASONS)[number];
export const REPORT_STATUSES = ["open", "reviewing", "resolved", "dismissed"] as const;
export type ReportStatus = (typeof REPORT_STATUSES)[number];
export interface Report {
id: string;
reporter_id: string;
reporter_name: string;
conversation_id: string;
message_index: number;
/** Snapshot of the reported assistant message. */
content: string;
/** Snapshot of the user prompt that triggered it (previous user message). */
prompt: string;
model: string;
reason: ReportReason;
details: string;
status: ReportStatus;
admin_note: string;
/** Live flag from users.report_shadowbanned (JOIN, not stored). */
reporter_shadowbanned: number;
created_at: number;
updated_at: number;
}
export function initReportsTables(): void {
const d = getDb();
d.query(`CREATE TABLE IF NOT EXISTS reports (
id TEXT PRIMARY KEY,
reporter_id TEXT NOT NULL,
reporter_name TEXT NOT NULL DEFAULT '',
conversation_id TEXT NOT NULL DEFAULT '',
message_index INTEGER NOT NULL DEFAULT -1,
content TEXT NOT NULL DEFAULT '',
prompt TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open',
admin_note TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status, updated_at DESC)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_reporter ON reports(reporter_id, created_at DESC)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_message ON reports(conversation_id, message_index)`).run();
// Prompt snapshot added after the initial schema — keep idempotent.
const rcols = d.query("PRAGMA table_info(reports)").all() as { name: string }[];
if (!rcols.some((c) => c.name === "prompt")) {
d.query("ALTER TABLE reports ADD COLUMN prompt TEXT NOT NULL DEFAULT ''").run();
}
// Per-user report shadow-ban (unreliable reporters) — keep idempotent.
const ucols = d.query("PRAGMA table_info(users)").all() as { name: string }[];
if (!ucols.some((c) => c.name === "report_shadowbanned")) {
d.query("ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0").run();
}
}
/** Max non-terminal reports per reporter (spam guard). */
export const MAX_OPEN_REPORTS = 20;
/** Still-triageable report already covering this message, if any. */
export function findOpenReport(conversationId: string, messageIndex: number): Report | null {
if (!conversationId || messageIndex < 0) return null;
return getDb().query(
"SELECT r.*, COALESCE(u.report_shadowbanned, 0) AS reporter_shadowbanned FROM reports r LEFT JOIN users u ON u.id = r.reporter_id WHERE r.conversation_id = ? AND r.message_index = ? AND r.status IN ('open','reviewing') ORDER BY r.created_at DESC LIMIT 1",
).get(conversationId, messageIndex) as Report | null;
}
export function createReport(opts: {
reporterId: string;
reporterName: string;
conversationId: string;
messageIndex: number;
reason: ReportReason;
details: string;
clientContent: string;
clientPrompt: string;
clientModel: string;
}): Report {
const d = getDb();
const dup = opts.conversationId && opts.messageIndex >= 0
? findOpenReport(opts.conversationId, opts.messageIndex)
: null;
if (dup) {
const err = new Error("ALREADY_REPORTED") as Error & { report: Report };
err.report = dup;
throw err;
}
const open = d.query(
"SELECT COUNT(*) AS n FROM reports WHERE reporter_id = ? AND status IN ('open','reviewing')",
).get(opts.reporterId) as { n: number };
if (open.n >= MAX_OPEN_REPORTS) throw new Error("TOO_MANY_OPEN");
let content = opts.clientContent;
let prompt = opts.clientPrompt;
let model = opts.clientModel;
if (opts.conversationId) {
const conv = getConversation(opts.conversationId);
if (conv && conv.user_id === opts.reporterId) {
if (conv.model) model = conv.model;
const msgs = getMessages(conv.id).filter((m) => m.role !== "system");
if (opts.messageIndex >= 0) {
const at = msgs[opts.messageIndex];
if (at) content = at.content;
const prev = msgs[opts.messageIndex - 1];
if (prev && prev.role === "user") prompt = prev.content;
}
}
}
const now = Date.now();
const row: Report = {
id: randomUUID(),
reporter_id: opts.reporterId,
reporter_name: opts.reporterName,
conversation_id: opts.conversationId,
message_index: opts.messageIndex,
content,
prompt,
model,
reason: opts.reason,
details: opts.details,
status: "open",
admin_note: "",
reporter_shadowbanned: 0,
created_at: now,
updated_at: now,
};
d.query(
"INSERT INTO reports (id, reporter_id, reporter_name, conversation_id, message_index, content, prompt, model, reason, details, status, admin_note, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
).run(
row.id, row.reporter_id, row.reporter_name, row.conversation_id, row.message_index,
row.content, row.prompt, row.model, row.reason, row.details, row.status, row.admin_note,
row.created_at, row.updated_at,
);
return row;
}
export function listReports(): Report[] {
return getDb().query(
"SELECT r.*, COALESCE(u.report_shadowbanned, 0) AS reporter_shadowbanned FROM reports r LEFT JOIN users u ON u.id = r.reporter_id ORDER BY r.updated_at DESC LIMIT 500",
).all() as Report[];
}
export function getReport(id: string): Report | null {
return getDb().query(
"SELECT r.*, COALESCE(u.report_shadowbanned, 0) AS reporter_shadowbanned FROM reports r LEFT JOIN users u ON u.id = r.reporter_id WHERE r.id = ?",
).get(id) as Report | null;
}
export function setReportStatus(id: string, status: ReportStatus, adminNote: string): Report | null {
getDb().query("UPDATE reports SET status = ?, admin_note = ?, updated_at = ? WHERE id = ?").run(
status, adminNote, Date.now(), id,
);
return getReport(id);
}
/**
* Shadow-ban a reporter: their reports are still accepted but flagged
* unreliable (excluded from the admin badge). Returns the new flag.
*/
export function setReportShadowbanned(userId: string, shadowbanned: boolean): number {
getDb().query("UPDATE users SET report_shadowbanned = ? WHERE id = ?").run(shadowbanned ? 1 : 0, userId);
const row = getDb().query("SELECT report_shadowbanned AS v FROM users WHERE id = ?").get(userId) as { v: number } | null;
return row?.v ?? 0;
}