diff --git a/workers/crash-report/migrate.sql b/workers/crash-report/migrate.sql new file mode 100644 index 0000000000..fde0af7381 --- /dev/null +++ b/workers/crash-report/migrate.sql @@ -0,0 +1,34 @@ +-- One-time upgrade of the live reasonix-crash DB to the account system. +-- Apply once: wrangler d1 execute reasonix-crash --remote --file=migrate.sql +-- The ALTERs are not idempotent; the CREATEs are. Fresh installs use schema.sql. +ALTER TABLE groups ADD COLUMN status TEXT NOT NULL DEFAULT 'open'; +ALTER TABLE groups ADD COLUMN note TEXT NOT NULL DEFAULT ''; + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL, + approved_at TEXT, + approved_by INTEGER +); + +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS sessions_user ON sessions (user_id); + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + actor_id INTEGER, + actor_email TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '' +); diff --git a/workers/crash-report/schema.sql b/workers/crash-report/schema.sql index 37b4afac27..9444d61021 100644 --- a/workers/crash-report/schema.sql +++ b/workers/crash-report/schema.sql @@ -5,7 +5,9 @@ CREATE TABLE IF NOT EXISTS groups ( count INTEGER NOT NULL, first_seen TEXT NOT NULL, last_seen TEXT NOT NULL, - last_version TEXT NOT NULL + last_version TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + note TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS reports ( @@ -44,3 +46,32 @@ CREATE TABLE IF NOT EXISTS metrics ( count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (date, version, os, signal, bucket) ); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL, + approved_at TEXT, + approved_by INTEGER +); + +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS sessions_user ON sessions (user_id); + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + actor_id INTEGER, + actor_email TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '' +); diff --git a/workers/crash-report/src/admin.ts b/workers/crash-report/src/admin.ts new file mode 100644 index 0000000000..b390f706c1 --- /dev/null +++ b/workers/crash-report/src/admin.ts @@ -0,0 +1,75 @@ +import { esc, page } from "./shell"; +import { type Role, type User, userNav } from "./auth"; + +export interface UserRow { + id: number; + email: string; + role: Role; + created_at: string; + approved_at: string | null; +} + +export interface AuditRow { + at: string; + actor_email: string; + action: string; + target: string; + detail: string; +} + +function roleSelect(row: UserRow): string { + const opt = (r: Role) => ``; + return `
+ + +
`; +} + +function deleteForm(row: UserRow): string { + return `
+ +
`; +} + +export function renderUsers(viewer: User, users: UserRow[], n?: { kind: "err" | "ok"; text: string }): string { + const pending = users.filter((u) => u.role === "pending").length; + const rows = users + .map((u) => { + const self = u.id === viewer.id; + const approved = u.approved_at ? esc(u.approved_at.slice(0, 10)) : "—"; + return `${esc(u.email)}${self ? ' you' : ""} +${self ? `${u.role}` : roleSelect(u)} +${esc(u.created_at.slice(0, 10))}${approved} +${self ? "" : deleteForm(u)}`; + }) + .join(""); + return page( + "Reasonix · Users", + "admin / users", + `

Users

${users.length} accounts · ${pending} awaiting approval · set a role to grant or revoke access

+${n ? `
${esc(n.text)}
` : ""} +
+${rows}
emailrolejoinedapproved
+View audit log →`, + userNav(viewer), + ); +} + +export function renderAudit(viewer: User, rows: AuditRow[]): string { + const body = rows.length + ? `${rows + .map( + (r) => + ``, + ) + .join("")}
whenactoractiontargetdetail
${esc(r.at.slice(0, 19).replace("T", " "))}${esc(r.actor_email)}${esc(r.action)}${esc(r.target)}${esc(r.detail)}
` + : `
No actions logged yet
`; + return page( + "Reasonix · Audit", + "admin / audit", + `

Audit log

Permission and crash-data changes, newest first

+
${body}
+← Back to users`, + userNav(viewer), + ); +} diff --git a/workers/crash-report/src/auth.ts b/workers/crash-report/src/auth.ts new file mode 100644 index 0000000000..4f93d3855c --- /dev/null +++ b/workers/crash-report/src/auth.ts @@ -0,0 +1,145 @@ +import type { Env } from "./env"; +import { esc } from "./shell"; + +export type Role = "pending" | "viewer" | "admin"; + +export interface User { + id: number; + email: string; + role: Role; + created_at: string; + approved_at: string | null; +} + +const RANK: Record = { pending: 0, viewer: 1, admin: 2 }; + +export function atLeast(role: Role, min: Role): boolean { + return RANK[role] >= RANK[min]; +} + +const COOKIE = "rxsess"; +const SESSION_TTL_MS = 30 * 86_400_000; +const PBKDF2_ITERS = 100_000; + +function b64(bytes: Uint8Array): string { + let s = ""; + for (const byte of bytes) s += String.fromCharCode(byte); + return btoa(s); +} + +function unb64(s: string): Uint8Array { + const bin = atob(s); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +async function deriveBits(password: string, salt: Uint8Array, iterations: number): Promise { + const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]); + const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations, hash: "SHA-256" }, key, 256); + return new Uint8Array(bits); +} + +export async function hashPassword(password: string): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const hash = await deriveBits(password, salt, PBKDF2_ITERS); + return `pbkdf2$${PBKDF2_ITERS}$${b64(salt)}$${b64(hash)}`; +} + +export async function verifyPassword(password: string, stored: string): Promise { + const [scheme, iters, saltB64, hashB64] = stored.split("$"); + if (scheme !== "pbkdf2") return false; + const got = await deriveBits(password, unb64(saltB64), Number(iters)); + const want = unb64(hashB64); + return got.byteLength === want.byteLength && crypto.subtle.timingSafeEqual(got, want); +} + +function randomToken(): string { + const b = crypto.getRandomValues(new Uint8Array(32)); + return [...b].map((x) => x.toString(16).padStart(2, "0")).join(""); +} + +export async function createSession(env: Env, userId: number): Promise { + const token = randomToken(); + const now = Date.now(); + await env.DB.prepare("INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?1, ?2, ?3, ?4)") + .bind(token, userId, new Date(now).toISOString(), new Date(now + SESSION_TTL_MS).toISOString()) + .run(); + return token; +} + +export async function destroySession(env: Env, token: string): Promise { + await env.DB.prepare("DELETE FROM sessions WHERE token = ?1").bind(token).run(); +} + +export async function endSession(request: Request, env: Env): Promise { + const token = getCookie(request, COOKIE); + if (token) await destroySession(env, token); +} + +export function sessionCookie(token: string): string { + return `${COOKIE}=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`; +} + +export function clearCookie(): string { + return `${COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`; +} + +export function getCookie(request: Request, name: string): string | null { + const header = request.headers.get("cookie") ?? ""; + for (const part of header.split(";")) { + const [k, ...v] = part.trim().split("="); + if (k === name) return v.join("="); + } + return null; +} + +export async function currentUser(request: Request, env: Env): Promise { + const token = getCookie(request, COOKIE); + if (!token) return null; + const row = await env.DB.prepare( + `SELECT u.id, u.email, u.role, u.created_at, u.approved_at, s.expires_at + FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?1`, + ) + .bind(token) + .first<{ id: number; email: string; role: Role; created_at: string; approved_at: string | null; expires_at: string }>(); + if (!row) return null; + if (row.expires_at < new Date().toISOString()) { + await destroySession(env, token); + return null; + } + return { id: row.id, email: row.email, role: row.role, created_at: row.created_at, approved_at: row.approved_at }; +} + +export function isAdminEmail(env: Env, email: string): boolean { + const list = (env.ADMIN_EMAILS ?? "") + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + return list.includes(email.toLowerCase()); +} + +// CSRF guard for cookie-authed POSTs: a missing Origin is rejected, not waved +// through as same-site. +export function sameOrigin(request: Request): boolean { + const origin = request.headers.get("origin"); + if (!origin) return false; + try { + return new URL(origin).host === new URL(request.url).host; + } catch { + return false; + } +} + +export async function logAction(env: Env, actor: User, action: string, target = "", detail = ""): Promise { + await env.DB.prepare( + "INSERT INTO audit_log (at, actor_id, actor_email, action, target, detail) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + ) + .bind(new Date().toISOString(), actor.id, actor.email, action, target, detail) + .run(); +} + +export function userNav(user: User): string { + const admin = user.role === "admin" ? `Admin` : ""; + return `${user.role}${esc(user.email)}Account${admin}
`; +} diff --git a/workers/crash-report/src/auth_pages.ts b/workers/crash-report/src/auth_pages.ts new file mode 100644 index 0000000000..4a6302ceae --- /dev/null +++ b/workers/crash-report/src/auth_pages.ts @@ -0,0 +1,58 @@ +import { esc, page } from "./shell"; +import { type User, userNav } from "./auth"; + +function notice(n?: { kind: "err" | "ok"; text: string }): string { + return n ? `
${esc(n.text)}
` : ""; +} + +function authShell(title: string, crumb: string, body: string): string { + return page(title, crumb, `
${body}
`); +} + +export function renderLogin(n?: { kind: "err" | "ok"; text: string }): string { + return authShell( + "Reasonix · Sign in", + "sign in", + `

Sign in

Crash & telemetry dashboard

${notice(n)} +
+
+
+ +
+
No account? Register
`, + ); +} + +export function renderRegister(n?: { kind: "err" | "ok"; text: string }): string { + return authShell( + "Reasonix · Register", + "register", + `

Create account

New accounts start with no access until an admin approves them.

${notice(n)} +
+
+
+ +
+
Already have an account? Sign in
`, + ); +} + +export function renderAccount(user: User, n?: { kind: "err" | "ok"; text: string }): string { + const pending = user.role === "pending"; + const status = pending + ? `
Your account is awaiting approval. An admin needs to grant you access before the crash dashboard becomes visible.
` + : `
You have ${esc(user.role)} access. Open the dashboard →
`; + return page( + "Reasonix · Account", + "account", + `

Account

${esc(user.email)} · joined ${esc(user.created_at.slice(0, 10))}

+${notice(n)}${status} +

Change password

+
+
+
+ +
`, + userNav(user), + ); +} diff --git a/workers/crash-report/src/env.ts b/workers/crash-report/src/env.ts new file mode 100644 index 0000000000..9a86a1b174 --- /dev/null +++ b/workers/crash-report/src/env.ts @@ -0,0 +1,11 @@ +export interface RateLimiter { + limit(opts: { key: string }): Promise<{ success: boolean }>; +} + +export interface Env { + DB: D1Database; + RATE_LIMITER: RateLimiter; + PING_LIMITER: RateLimiter; + METRICS_LIMITER: RateLimiter; + ADMIN_EMAILS?: string; +} diff --git a/workers/crash-report/src/index.ts b/workers/crash-report/src/index.ts index 72ee80706a..2214499891 100644 --- a/workers/crash-report/src/index.ts +++ b/workers/crash-report/src/index.ts @@ -1,19 +1,26 @@ -// Ingest + stats for desktop crash/feedback reports and the anonymous launch +// Ingest + dashboard for desktop crash/feedback reports and the anonymous launch // ping. Reports are user-initiated; pings are opt-out (desktop.telemetry). import { z } from "zod"; -import { renderGroup, renderStats } from "./stats"; - -interface RateLimiter { - limit(opts: { key: string }): Promise<{ success: boolean }>; -} - -interface Env { - DB: D1Database; - RATE_LIMITER: RateLimiter; - PING_LIMITER: RateLimiter; - METRICS_LIMITER: RateLimiter; - STATS_PASSWORD?: string; -} +import type { Env } from "./env"; +import { html, redirect } from "./shell"; +import { renderGroup, renderStats, type Group } from "./stats"; +import { renderLogin, renderRegister, renderAccount } from "./auth_pages"; +import { renderUsers, renderAudit, type UserRow, type AuditRow } from "./admin"; +import { + atLeast, + createSession, + currentUser, + endSession, + hashPassword, + isAdminEmail, + logAction, + sameOrigin, + sessionCookie, + clearCookie, + verifyPassword, + type Role, + type User, +} from "./auth"; const MAX_BODY_BYTES = 32 * 1024; const SAMPLES_PER_GROUP = 5; @@ -187,29 +194,92 @@ async function handleMetrics(request: Request, env: Env): Promise { return new Response("ok", { status: 202 }); } -function statsAuthorized(request: Request, env: Env): boolean { - // trim: secrets piped in via PowerShell arrive with a trailing newline. - const want = (env.STATS_PASSWORD ?? "").trim(); - if (!want) return false; - const header = request.headers.get("authorization") ?? ""; - if (!header.startsWith("Basic ")) return false; - let pass: string; - try { - pass = atob(header.slice(6)).split(":").slice(1).join(":"); - } catch { - return false; - } - const enc = new TextEncoder(); - const a = enc.encode(pass); - const b = enc.encode(want); - return a.byteLength === b.byteLength && crypto.subtle.timingSafeEqual(a, b); +const Credentials = z.object({ + email: z.string().email().max(254), + password: z.string().min(8).max(200), +}); + +const PasswordChange = z.object({ + current: z.string().min(1).max(200), + next: z.string().min(8).max(200), +}); + +const UserAction = z.object({ + action: z.enum(["role", "delete"]), + userId: z.coerce.number().int().positive(), + role: z.enum(["pending", "viewer", "admin"]).optional(), +}); + +const GroupAction = z.object({ + action: z.enum(["status", "delete", "note"]), + status: z.enum(["open", "resolved", "ignored"]).optional(), + note: z.string().max(500).optional(), +}); + +async function formObject(request: Request): Promise> { + const form = await request.formData(); + const out: Record = {}; + for (const [k, v] of form) out[k] = typeof v === "string" ? v : ""; + return out; } -function html(body: string): Response { - return new Response(body, { headers: { "content-type": "text/html; charset=utf-8" } }); +async function handleRegister(request: Request, env: Env): Promise { + if (!sameOrigin(request)) return new Response("forbidden", { status: 403 }); + const parsed = Credentials.safeParse(await formObject(request)); + if (!parsed.success) + return html(renderRegister({ kind: "err", text: "Enter a valid email and a password of at least 8 characters." })); + const email = parsed.data.email.toLowerCase(); + + const existing = await env.DB.prepare("SELECT id FROM users WHERE email = ?1").bind(email).first(); + if (existing) return html(renderRegister({ kind: "err", text: "That email is already registered — try signing in." })); + + const role: Role = isAdminEmail(env, email) ? "admin" : "pending"; + const now = new Date().toISOString(); + const hash = await hashPassword(parsed.data.password); + const res = await env.DB.prepare( + "INSERT INTO users (email, password_hash, role, created_at, approved_at) VALUES (?1, ?2, ?3, ?4, ?5)", + ) + .bind(email, hash, role, now, role === "admin" ? now : null) + .run(); + + const token = await createSession(env, res.meta.last_row_id); + return redirect(role === "pending" ? "/account" : "/stats", sessionCookie(token)); } -async function handleStats(env: Env): Promise { +async function handleLogin(request: Request, env: Env): Promise { + if (!sameOrigin(request)) return new Response("forbidden", { status: 403 }); + const parsed = Credentials.safeParse(await formObject(request)); + if (!parsed.success) return html(renderLogin({ kind: "err", text: "Enter a valid email and password." })); + const email = parsed.data.email.toLowerCase(); + + const row = await env.DB.prepare("SELECT id, password_hash, role FROM users WHERE email = ?1") + .bind(email) + .first<{ id: number; password_hash: string; role: Role }>(); + const ok = row ? await verifyPassword(parsed.data.password, row.password_hash) : false; + if (!row || !ok) return html(renderLogin({ kind: "err", text: "Wrong email or password." })); + + const token = await createSession(env, row.id); + return redirect(atLeast(row.role, "viewer") ? "/stats" : "/account", sessionCookie(token)); +} + +async function handleAccountPassword(request: Request, env: Env, user: User): Promise { + if (!sameOrigin(request)) return new Response("forbidden", { status: 403 }); + const parsed = PasswordChange.safeParse(await formObject(request)); + if (!parsed.success) return html(renderAccount(user, { kind: "err", text: "New password must be at least 8 characters." })); + + const row = await env.DB.prepare("SELECT password_hash FROM users WHERE id = ?1") + .bind(user.id) + .first<{ password_hash: string }>(); + if (!row || !(await verifyPassword(parsed.data.current, row.password_hash))) + return html(renderAccount(user, { kind: "err", text: "Current password is incorrect." })); + + await env.DB.prepare("UPDATE users SET password_hash = ?1 WHERE id = ?2") + .bind(await hashPassword(parsed.data.next), user.id) + .run(); + return html(renderAccount(user, { kind: "ok", text: "Password updated." })); +} + +async function handleStats(env: Env, user: User): Promise { const [daily, versions, platforms, crashes, metrics] = await Promise.all([ env.DB.prepare( "SELECT date, COUNT(*) AS users, SUM(opens) AS opens FROM pings WHERE date >= date('now', '-29 day') GROUP BY date", @@ -221,58 +291,172 @@ async function handleStats(env: Env): Promise { "SELECT os || ' ' || arch AS label, COUNT(DISTINCT install_id) AS users FROM pings WHERE date >= date('now', '-6 day') GROUP BY label ORDER BY users DESC", ).all<{ label: string; users: number }>(), env.DB.prepare( - "SELECT fingerprint, kind, count, last_version, substr(last_seen, 1, 10) AS seen FROM groups ORDER BY last_seen DESC LIMIT 25", - ).all<{ fingerprint: string; kind: string; count: number; last_version: string; seen: string }>(), + "SELECT fingerprint, kind, count, last_version, substr(last_seen, 1, 10) AS seen, status FROM groups ORDER BY last_seen DESC LIMIT 25", + ).all<{ fingerprint: string; kind: string; count: number; last_version: string; seen: string; status: string }>(), env.DB.prepare( "SELECT signal, bucket, SUM(count) AS total FROM metrics WHERE date >= date('now', '-6 day') GROUP BY signal, bucket ORDER BY signal, total DESC", ).all<{ signal: string; bucket: string; total: number }>(), ]); return html( - renderStats({ - daily: daily.results, - versions: versions.results, - platforms: platforms.results, - crashes: crashes.results, - metrics: metrics.results, - }), + renderStats( + { + daily: daily.results, + versions: versions.results, + platforms: platforms.results, + crashes: crashes.results, + metrics: metrics.results, + }, + user, + ), ); } -async function handleGroup(env: Env, fingerprint: string): Promise { - const group = await env.DB.prepare("SELECT * FROM groups WHERE fingerprint = ?1") - .bind(fingerprint) - .first<{ fingerprint: string; kind: string; count: number; first_seen: string; last_seen: string; last_version: string }>(); +async function handleGroup(env: Env, fingerprint: string, user: User): Promise { + const group = await env.DB.prepare("SELECT * FROM groups WHERE fingerprint = ?1").bind(fingerprint).first(); if (!group) return new Response("not found", { status: 404 }); const reports = await env.DB.prepare( "SELECT version, os, arch, message, device, created_at FROM reports WHERE fingerprint = ?1 ORDER BY id DESC", ) .bind(fingerprint) .all<{ version: string; os: string; arch: string; message: string; device: string; created_at: string }>(); - return html(renderGroup(group, reports.results)); + return html(renderGroup(group, reports.results, user)); +} + +async function handleGroupAction(request: Request, env: Env, admin: User, fingerprint: string): Promise { + if (!sameOrigin(request)) return new Response("forbidden", { status: 403 }); + const parsed = GroupAction.safeParse(await formObject(request)); + if (!parsed.success) return redirect(`/stats/group/${fingerprint}`); + const a = parsed.data; + + if (a.action === "delete") { + await env.DB.batch([ + env.DB.prepare("DELETE FROM reports WHERE fingerprint = ?1").bind(fingerprint), + env.DB.prepare("DELETE FROM groups WHERE fingerprint = ?1").bind(fingerprint), + ]); + await logAction(env, admin, "delete_group", fingerprint.slice(0, 8)); + return redirect("/stats"); + } + if (a.action === "status") { + const status = a.status ?? "open"; + await env.DB.prepare("UPDATE groups SET status = ?1 WHERE fingerprint = ?2").bind(status, fingerprint).run(); + await logAction(env, admin, "set_status", fingerprint.slice(0, 8), status); + return redirect(`/stats/group/${fingerprint}`); + } + await env.DB.prepare("UPDATE groups SET note = ?1 WHERE fingerprint = ?2").bind(a.note ?? "", fingerprint).run(); + await logAction(env, admin, "set_note", fingerprint.slice(0, 8)); + return redirect(`/stats/group/${fingerprint}`); +} + +async function handleAdminUsers(request: Request, env: Env, admin: User): Promise { + if (!sameOrigin(request)) return new Response("forbidden", { status: 403 }); + const parsed = UserAction.safeParse(await formObject(request)); + if (!parsed.success) return redirect("/admin"); + const a = parsed.data; + if (a.userId === admin.id) return redirect("/admin"); + + const target = await env.DB.prepare("SELECT email, role FROM users WHERE id = ?1") + .bind(a.userId) + .first<{ email: string; role: Role }>(); + if (!target) return redirect("/admin"); + + if (a.action === "delete") { + await env.DB.batch([ + env.DB.prepare("DELETE FROM sessions WHERE user_id = ?1").bind(a.userId), + env.DB.prepare("DELETE FROM users WHERE id = ?1").bind(a.userId), + ]); + await logAction(env, admin, "delete_user", target.email); + return redirect("/admin"); + } + + const role: Role = a.role ?? "pending"; + const now = new Date().toISOString(); + await env.DB.prepare("UPDATE users SET role = ?1, approved_at = ?2, approved_by = ?3 WHERE id = ?4") + .bind(role, role === "pending" ? null : now, admin.id, a.userId) + .run(); + await logAction(env, admin, "set_role", target.email, `${target.role} → ${role}`); + return redirect("/admin"); +} + +async function handleAdminList(env: Env, admin: User): Promise { + const users = await env.DB.prepare( + "SELECT id, email, role, created_at, approved_at FROM users ORDER BY (role = 'pending') DESC, created_at DESC", + ).all(); + return html(renderUsers(admin, users.results)); +} + +async function handleAdminAudit(env: Env, admin: User): Promise { + const rows = await env.DB.prepare( + "SELECT at, actor_email, action, target, detail FROM audit_log ORDER BY id DESC LIMIT 200", + ).all(); + return html(renderAudit(admin, rows.results)); +} + +function requireViewer(user: User | null): Response | null { + if (!user) return redirect("/login"); + if (!atLeast(user.role, "viewer")) return redirect("/account"); + return null; } export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); - if (url.pathname === "/v1/report" && request.method === "POST") return handleReport(request, env); - if (url.pathname === "/v1/ping" && request.method === "POST") return handlePing(request, env); - if (url.pathname === "/v1/metrics" && request.method === "POST") return handleMetrics(request, env); + const path = url.pathname; + const method = request.method; - const group = url.pathname.match(/^\/stats\/group\/([0-9a-f]{64})$/); - if ((url.pathname === "/stats" || group) && request.method === "GET") { - if (!statsAuthorized(request, env)) { - return new Response("auth required", { - status: 401, - headers: { "www-authenticate": 'Basic realm="reasonix-stats"' }, - }); - } - return group ? handleGroup(env, group[1]) : handleStats(env); + if (path === "/v1/report" && method === "POST") return handleReport(request, env); + if (path === "/v1/ping" && method === "POST") return handlePing(request, env); + if (path === "/v1/metrics" && method === "POST") return handleMetrics(request, env); + + if (path === "/register" && method === "GET") return html(renderRegister()); + if (path === "/register" && method === "POST") return handleRegister(request, env); + if (path === "/login" && method === "GET") return html(renderLogin()); + if (path === "/login" && method === "POST") return handleLogin(request, env); + if (path === "/logout" && method === "POST") { + await endSession(request, env); + return redirect("/login", clearCookie()); } + + const user = await currentUser(request, env); + + if (path === "/") return redirect(user ? (atLeast(user.role, "viewer") ? "/stats" : "/account") : "/login"); + + if (path === "/account" && method === "GET") + return user ? html(renderAccount(user)) : redirect("/login"); + if (path === "/account/password" && method === "POST") + return user ? handleAccountPassword(request, env, user) : redirect("/login"); + + const groupMatch = path.match(/^\/stats\/group\/([0-9a-f]{64})$/); + if (path === "/stats" && method === "GET") return requireViewer(user) ?? handleStats(env, user as User); + if (groupMatch && method === "GET") return requireViewer(user) ?? handleGroup(env, groupMatch[1], user as User); + if (groupMatch && method === "POST") { + if (user?.role !== "admin") return new Response("forbidden", { status: 403 }); + return handleGroupAction(request, env, user, groupMatch[1]); + } + + if (path === "/admin" && method === "GET") { + if (!user) return redirect("/login"); + return user.role === "admin" ? handleAdminList(env, user) : redirect("/account"); + } + if (path === "/admin/audit" && method === "GET") { + if (!user) return redirect("/login"); + return user.role === "admin" ? handleAdminAudit(env, user) : redirect("/account"); + } + if (path === "/admin/users" && method === "POST") { + if (user?.role !== "admin") return new Response("forbidden", { status: 403 }); + return handleAdminUsers(request, env, user); + } + if ( - url.pathname === "/v1/report" || - url.pathname === "/v1/ping" || - url.pathname === "/v1/metrics" || - url.pathname.startsWith("/stats") + path === "/v1/report" || + path === "/v1/ping" || + path === "/v1/metrics" || + path === "/login" || + path === "/register" || + path === "/logout" || + path === "/account" || + path === "/account/password" || + path.startsWith("/stats") || + path.startsWith("/admin") ) { return new Response("method not allowed", { status: 405 }); } diff --git a/workers/crash-report/src/shell.ts b/workers/crash-report/src/shell.ts new file mode 100644 index 0000000000..bc1b272cf5 --- /dev/null +++ b/workers/crash-report/src/shell.ts @@ -0,0 +1,106 @@ +// Shared page shell + UI kit. Visual language mirrors site/src/styles/global.css +// — white, blue accent, large radii; crash stacks use the site's dark terminal. + +export function esc(s: unknown): string { + return String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); +} + +const LOGO = ``; + +const CSS = ` +:root{--accent:oklch(0.55 0.17 257);--accent-soft:color-mix(in oklch,var(--accent) 8%,white); +--bg-soft:oklch(0.976 0.0025 247);--ink:oklch(0.25 0.006 260);--ink-2:oklch(0.47 0.008 260); +--ink-3:oklch(0.6 0.008 260);--line:oklch(0.925 0.004 247);--term-bg:oklch(0.25 0.006 260); +--shadow-1:0 1px 2px oklch(0.35 0.01 260/0.18),0 1px 3px 1px oklch(0.35 0.01 260/0.08); +--sans:"Outfit","Helvetica Neue","PingFang SC","Microsoft YaHei",sans-serif; +--mono:"JetBrains Mono","SF Mono",Menlo,Consolas,monospace} +*{margin:0;padding:0;box-sizing:border-box} +body{font-family:var(--sans);background:#fff;color:var(--ink);-webkit-font-smoothing:antialiased} +.wrap{max-width:1060px;margin:0 auto;padding:0 28px 64px} +header{display:flex;align-items:center;gap:10px;height:68px} +.logo{width:28px;height:28px;border-radius:8px} +header .brand{font-size:17px;font-weight:600;color:var(--ink);text-decoration:none} +header .crumb{color:var(--ink-3);font-size:15px} +header .nav{margin-left:auto;display:flex;align-items:center;gap:14px} +.navlink{color:var(--accent);text-decoration:none;font-size:14px;font-weight:500} +.navlink:hover{text-decoration:underline} +.chip{display:inline-flex;align-items:center;gap:8px;font-size:13.5px;color:var(--ink-2)} +.badge{font-size:11px;font-weight:600;letter-spacing:.02em;text-transform:uppercase;padding:2px 8px;border-radius:999px} +.badge.pending{background:oklch(0.95 0.04 80);color:oklch(0.5 0.12 80)} +.badge.viewer{background:var(--accent-soft);color:var(--accent)} +.badge.admin{background:oklch(0.95 0.05 150);color:oklch(0.48 0.13 150)} +h1{font-size:30px;font-weight:600;letter-spacing:-0.02em;margin:18px 0 6px} +.sub{color:var(--ink-2);font-size:14.5px;margin-bottom:28px} +.grid{display:grid;grid-template-columns:1fr 1fr;gap:20px} +.card{background:#fff;border:1px solid var(--line);border-radius:24px;padding:24px 28px;box-shadow:var(--shadow-1)} +.card.full{grid-column:1/-1} +.card h2{font-size:15px;font-weight:600;color:var(--ink-2);margin-bottom:16px} +.card h2 b{color:var(--ink)} +.empty{color:var(--ink-3);font-size:14px;padding:14px 0} +svg.chart{width:100%;height:auto;display:block} +.row{display:grid;grid-template-columns:minmax(90px,auto) 1fr 3.5em;align-items:center;gap:12px;padding:7px 0;font-size:14px} +.row+.row{border-top:1px solid var(--line)} +.row .bar{height:10px;border-radius:999px;background:linear-gradient(90deg,#4f9dff,#7a6bff);min-width:4px} +.row .n{text-align:right;font-family:var(--mono);font-size:13px;color:var(--ink-2)} +table{border-collapse:collapse;width:100%;font-size:14px} +th{text-align:left;color:var(--ink-3);font-weight:500;font-size:12.5px;padding:0 14px 8px 0} +td{padding:8px 14px 8px 0;border-top:1px solid var(--line);vertical-align:middle} +td.n{font-family:var(--mono);font-size:13px} +a.fp{font-family:var(--mono);font-size:13px;color:var(--accent);text-decoration:none} +a.fp:hover{text-decoration:underline} +.pill{display:inline-block;font-size:12px;font-weight:500;padding:2px 10px;border-radius:999px;background:var(--accent-soft);color:var(--accent)} +.pill.crash{background:oklch(0.95 0.03 25);color:oklch(0.55 0.18 25)} +.pill.resolved{background:oklch(0.95 0.05 150);color:oklch(0.48 0.13 150)} +.pill.ignored{background:var(--bg-soft);color:var(--ink-3)} +.back{display:inline-block;margin:18px 0 0;color:var(--accent);text-decoration:none;font-size:14px} +.report{margin-top:20px} +.report .meta{display:flex;flex-wrap:wrap;gap:8px 18px;font-size:13px;color:var(--ink-2);margin-bottom:10px} +.report .meta b{color:var(--ink);font-weight:500} +pre{background:var(--term-bg);color:#e6e8f0;border-radius:12px;padding:16px 18px;font-family:var(--mono); +font-size:12.5px;line-height:1.55;overflow:auto;white-space:pre-wrap;word-break:break-word} +.metrics{display:grid;grid-template-columns:1fr 1fr;gap:8px 28px} +.metric-block h3{font-family:var(--mono);font-size:12.5px;font-weight:600;color:var(--ink-2);margin:6px 0 4px} +form.inline{display:inline} +.authwrap{max-width:400px;margin:6vh auto 0} +.authcard{background:#fff;border:1px solid var(--line);border-radius:24px;padding:32px 34px;box-shadow:var(--shadow-1)} +.authcard h1{font-size:23px;margin:0 0 4px} +.authcard .sub{margin-bottom:22px} +.field{display:block;margin-bottom:14px} +.field label{display:block;font-size:13px;color:var(--ink-2);margin-bottom:6px} +.field input,select{width:100%;font:inherit;font-size:14.5px;color:var(--ink);background:#fff;border:1px solid var(--line);border-radius:12px;padding:10px 13px;outline:none} +.field input:focus,select:focus{border-color:var(--accent)} +.btn{font:inherit;font-size:14.5px;font-weight:600;color:#fff;background:var(--accent);border:none;border-radius:12px;padding:10px 18px;cursor:pointer} +.btn:hover{filter:brightness(1.05)} +.btn.block{width:100%} +.btn.ghost{background:var(--accent-soft);color:var(--accent)} +.btn.danger{background:oklch(0.95 0.03 25);color:oklch(0.55 0.18 25)} +.btn.sm{font-size:13px;padding:6px 12px;border-radius:10px} +.notice{font-size:13.5px;padding:10px 14px;border-radius:12px;margin-bottom:16px} +.notice.err{background:oklch(0.96 0.03 25);color:oklch(0.5 0.18 25)} +.notice.ok{background:oklch(0.95 0.05 150);color:oklch(0.45 0.13 150)} +.alt{margin-top:18px;font-size:13.5px;color:var(--ink-3);text-align:center} +.alt a{color:var(--accent);text-decoration:none} +.actions{display:flex;gap:8px;align-items:center} +td select{width:auto;padding:6px 10px;border-radius:10px} +.note-edit{margin-top:12px;display:flex;gap:8px;max-width:560px} +.note-edit input{flex:1} +@media(max-width:760px){.grid{grid-template-columns:1fr}.metrics{grid-template-columns:1fr}.wrap{padding:0 16px 48px}} +`; + +export function page(title: string, crumb: string, body: string, nav = ""): string { + return ` + +${esc(title)}
+
${LOGO}Reasonix/ ${esc(crumb)}${nav ? `${nav}` : ""}
+${body}
`; +} + +export function html(body: string): Response { + return new Response(body, { headers: { "content-type": "text/html; charset=utf-8" } }); +} + +export function redirect(location: string, cookie?: string): Response { + const headers: Record = { location }; + if (cookie) headers["set-cookie"] = cookie; + return new Response(null, { status: 303, headers }); +} diff --git a/workers/crash-report/src/stats.ts b/workers/crash-report/src/stats.ts index 4d0fc7a4d4..4a23bfb411 100644 --- a/workers/crash-report/src/stats.ts +++ b/workers/crash-report/src/stats.ts @@ -1,65 +1,5 @@ -// Stats pages. Visual language mirrors site/src/styles/global.css — white, blue -// accent, large radii; crash stacks render in the site's dark terminal style. - -export function esc(s: unknown): string { - return String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); -} - -const LOGO = ``; - -const CSS = ` -:root{--accent:oklch(0.55 0.17 257);--accent-soft:color-mix(in oklch,var(--accent) 8%,white); ---bg-soft:oklch(0.976 0.0025 247);--ink:oklch(0.25 0.006 260);--ink-2:oklch(0.47 0.008 260); ---ink-3:oklch(0.6 0.008 260);--line:oklch(0.925 0.004 247);--term-bg:oklch(0.25 0.006 260); ---shadow-1:0 1px 2px oklch(0.35 0.01 260/0.18),0 1px 3px 1px oklch(0.35 0.01 260/0.08); ---sans:"Outfit","Helvetica Neue","PingFang SC","Microsoft YaHei",sans-serif; ---mono:"JetBrains Mono","SF Mono",Menlo,Consolas,monospace} -*{margin:0;padding:0;box-sizing:border-box} -body{font-family:var(--sans);background:#fff;color:var(--ink);-webkit-font-smoothing:antialiased} -.wrap{max-width:1060px;margin:0 auto;padding:0 28px 64px} -header{display:flex;align-items:center;gap:10px;height:68px} -.logo{width:28px;height:28px;border-radius:8px} -header .brand{font-size:17px;font-weight:600;color:var(--ink);text-decoration:none} -header .crumb{color:var(--ink-3);font-size:15px} -h1{font-size:30px;font-weight:600;letter-spacing:-0.02em;margin:18px 0 6px} -.sub{color:var(--ink-2);font-size:14.5px;margin-bottom:28px} -.grid{display:grid;grid-template-columns:1fr 1fr;gap:20px} -.card{background:#fff;border:1px solid var(--line);border-radius:24px;padding:24px 28px;box-shadow:var(--shadow-1)} -.card.full{grid-column:1/-1} -.card h2{font-size:15px;font-weight:600;color:var(--ink-2);margin-bottom:16px} -.card h2 b{color:var(--ink)} -.empty{color:var(--ink-3);font-size:14px;padding:14px 0} -svg.chart{width:100%;height:auto;display:block} -.row{display:grid;grid-template-columns:minmax(90px,auto) 1fr 3.5em;align-items:center;gap:12px;padding:7px 0;font-size:14px} -.row+.row{border-top:1px solid var(--line)} -.row .bar{height:10px;border-radius:999px;background:linear-gradient(90deg,#4f9dff,#7a6bff);min-width:4px} -.row .n{text-align:right;font-family:var(--mono);font-size:13px;color:var(--ink-2)} -table{border-collapse:collapse;width:100%;font-size:14px} -th{text-align:left;color:var(--ink-3);font-weight:500;font-size:12.5px;padding:0 14px 8px 0} -td{padding:8px 14px 8px 0;border-top:1px solid var(--line)} -td.n{font-family:var(--mono);font-size:13px} -a.fp{font-family:var(--mono);font-size:13px;color:var(--accent);text-decoration:none} -a.fp:hover{text-decoration:underline} -.pill{display:inline-block;font-size:12px;font-weight:500;padding:2px 10px;border-radius:999px;background:var(--accent-soft);color:var(--accent)} -.pill.crash{background:oklch(0.95 0.03 25);color:oklch(0.55 0.18 25)} -.back{display:inline-block;margin:18px 0 0;color:var(--accent);text-decoration:none;font-size:14px} -.report{margin-top:20px} -.report .meta{display:flex;flex-wrap:wrap;gap:8px 18px;font-size:13px;color:var(--ink-2);margin-bottom:10px} -.report .meta b{color:var(--ink);font-weight:500} -pre{background:var(--term-bg);color:#e6e8f0;border-radius:12px;padding:16px 18px;font-family:var(--mono); -font-size:12.5px;line-height:1.55;overflow:auto;white-space:pre-wrap;word-break:break-word} -.metrics{display:grid;grid-template-columns:1fr 1fr;gap:8px 28px} -.metric-block h3{font-family:var(--mono);font-size:12.5px;font-weight:600;color:var(--ink-2);margin:6px 0 4px} -@media(max-width:760px){.grid{grid-template-columns:1fr}.metrics{grid-template-columns:1fr}.wrap{padding:0 16px 48px}} -`; - -function page(title: string, crumb: string, body: string): string { - return ` - -${esc(title)}
-
${LOGO}Reasonix/ ${esc(crumb)}
-${body}
`; -} +import { esc, page } from "./shell"; +import { type User, userNav } from "./auth"; type Daily = { date: string; users: number; opens: number }; @@ -120,21 +60,32 @@ function metricsCards(rows: { signal: string; bucket: string; total: number }[]) .join("")}`; } -export function renderStats(data: { - daily: Daily[]; - versions: { label: string; users: number }[]; - platforms: { label: string; users: number }[]; - crashes: { fingerprint: string; kind: string; count: number; last_version: string; seen: string }[]; - metrics: { signal: string; bucket: string; total: number }[]; -}): string { +function statusPill(status: string): string { + if (status === "resolved") return `resolved`; + if (status === "ignored") return `ignored`; + return ""; +} + +type CrashRow = { fingerprint: string; kind: string; count: number; last_version: string; seen: string; status: string }; + +export function renderStats( + data: { + daily: Daily[]; + versions: { label: string; users: number }[]; + platforms: { label: string; users: number }[]; + crashes: CrashRow[]; + metrics: { signal: string; bucket: string; total: number }[]; + }, + user: User, +): string { const days = last30Days(data.daily); const totalUsers = days.at(-1)?.users ?? 0; const anyPing = days.some((d) => d.opens > 0); const crashRows = data.crashes.length - ? `${data.crashes + ? `
fingerprintkindcountlast versionlast seen
${data.crashes .map( (c) => - ``, + ``, ) .join("")}
fingerprintkindstatuscountlast versionlast seen
${esc(c.fingerprint.slice(0, 8))}${esc(c.kind)}${c.count}${esc(c.last_version)}${esc(c.seen)}
${esc(c.fingerprint.slice(0, 8))}${esc(c.kind)}${statusPill(c.status)}${c.count}${esc(c.last_version)}${esc(c.seen)}
` : `
No crash reports yet — that's the good kind of empty · 还没有崩溃报告
`; @@ -151,6 +102,7 @@ ${anyPing ? dailyChart(days) : `
No pings yet — data starts

Agent signals · 运行指标 — 7 days, opt-in aggregate

${metricsCards(data.metrics)}

Crash groups · 崩溃分组 — click a fingerprint for stacks

${crashRows}
`, + userNav(user), ); } @@ -165,9 +117,33 @@ function fmtDevice(deviceJSON: string): string { } } +export type Group = { + fingerprint: string; + kind: string; + count: number; + first_seen: string; + last_seen: string; + last_version: string; + status: string; + note: string; +}; + +function manageGroup(group: Group): string { + const fp = esc(group.fingerprint); + const setStatus = (s: string, label: string, cls: string) => + group.status === s + ? "" + : `
`; + return `

Manage — admin

+
${setStatus("resolved", "Mark resolved", "ghost")}${setStatus("ignored", "Ignore", "ghost")}${setStatus("open", "Reopen", "ghost")} +
+
`; +} + export function renderGroup( - group: { fingerprint: string; kind: string; count: number; first_seen: string; last_seen: string; last_version: string }, + group: Group, reports: { version: string; os: string; arch: string; message: string; device: string; created_at: string }[], + user: User, ): string { const samples = reports.length ? reports @@ -179,13 +155,16 @@ export function renderGroup( }) .join("") : `
No raw samples stored for this group
`; + const noteLine = group.note ? ` · note: ${esc(group.note)}` : ""; return page( `Reasonix · ${group.fingerprint.slice(0, 8)}`, `stats / ${group.fingerprint.slice(0, 8)}`, - `

${esc(group.kind)} ${esc(group.fingerprint.slice(0, 8))}

-

${group.count} occurrences · first ${esc(group.first_seen.slice(0, 10))} · last ${esc(group.last_seen.slice(0, 10))} on ${esc(group.last_version)}

+ `

${esc(group.kind)} ${esc(group.fingerprint.slice(0, 8))} ${statusPill(group.status)}

+

${group.count} occurrences · first ${esc(group.first_seen.slice(0, 10))} · last ${esc(group.last_seen.slice(0, 10))} on ${esc(group.last_version)}${noteLine}

Samples — newest first, up to 5 kept

${samples}
+${user.role === "admin" ? manageGroup(group) : ""} ← Back to stats`, + userNav(user), ); }