report system
This commit is contained in:
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user