Now can be powered by postgres sql

This commit is contained in:
k
2026-09-22 09:00:19 +02:00
parent c9e53193b9
commit a57f5f0951
19 changed files with 1026 additions and 553 deletions
+3
View File
@@ -11,6 +11,9 @@ APP_PASSWORD=change-me
SESSION_TTL_HOURS=720 SESSION_TTL_HOURS=720
# Data dir: holds kisassistant.db (SQLite: users, sessions, chats, shares) # Data dir: holds kisassistant.db (SQLite: users, sessions, chats, shares)
DATA_DIR=./data DATA_DIR=./data
# Postgres fallback (typically a docker container). Empty = local SQLite.
# Example: POSTGRESQL_URL=postgres://kisassistant:secret@localhost:5432/kisassistant
POSTGRESQL_URL=
# Local file CDN (avatars). NixOS module points it under the state dir. # Local file CDN (avatars). NixOS module points it under the state dir.
CDN_DIR=./cdn CDN_DIR=./cdn
# Local file CDN (avatars today, more namespaces tomorrow). # Local file CDN (avatars today, more namespaces tomorrow).
+3 -2
View File
@@ -6,7 +6,8 @@ Read this before touching the code. Docs live in `docs/` (English).
KisAssistant — private ChatGPT-like assistant. Bun backend (single process, KisAssistant — private ChatGPT-like assistant. Bun backend (single process,
serves API + static frontend), React 18 + Vite + Tailwind 3 + lucide-react serves API + static frontend), React 18 + Vite + Tailwind 3 + lucide-react
frontend, SQLite persistence via Bun's native `bun:sqlite` driver. frontend, SQLite persistence by default via Bun's native `bun:sqlite` driver
(Postgres fallback via native `bun:sql` when `POSTGRESQL_URL` is set).
## Commands (run from repo root unless noted) ## Commands (run from repo root unless noted)
@@ -45,7 +46,7 @@ flake.nix/module.nix NixOS packaging + service module
- Frontend styling: Tailwind only, emerald accent, `rounded-2xl/3xl` everywhere, `dark:` variants on every surface. Inter Variable font (self-hosted, no CDN). - Frontend styling: Tailwind only, emerald accent, `rounded-2xl/3xl` everywhere, `dark:` variants on every surface. Inter Variable font (self-hosted, no CDN).
- Icons: lucide-react only. **Never use native `<select>`** — use the `Picker` component in `frontend/src/components/ui.tsx` (also `Button`, `Modal`, `ConfirmDialog`, `ContextMenu`, `Avatar`, `CopyButton` live there). - Icons: lucide-react only. **Never use native `<select>`** — use the `Picker` component in `frontend/src/components/ui.tsx` (also `Button`, `Modal`, `ConfirmDialog`, `ContextMenu`, `Avatar`, `CopyButton` live there).
- Backend responses: `json()` helper, `{ error }` shape on failure, correct HTTP codes (400/401/403/404/409/429/502). - Backend responses: `json()` helper, `{ error }` shape on failure, correct HTTP codes (400/401/403/404/409/429/502).
- DB: synchronous `bun:sqlite` queries, prepared statements, `PRAGMA journal_mode=WAL`. Schema is created idempotently at boot — keep it that way (no migration framework). - DB: async store fns through the `DbClient` interface (`src/db/client.ts`) — SQLite default (`bun:sqlite`, `PRAGMA journal_mode=WAL`), Postgres when `POSTGRESQL_URL` is set (Bun native `bun:sql`). Write queries once with `?` placeholders (auto-rewritten to `$n` for pg), keep SQL portable (no bare `GROUP BY pk` — pg rejects it; quote `"window"`). Schema is created idempotently at boot — keep it that way (no migration framework).
- Drivers: new LLM backends implement `LLMDriver` and register in `DriverRegistry` priority order. The arcaic browser driver is user-facing as "Arcaic-Technology" — never expose its scraping internals (no "puppeteer"/"crawler"/upstream-site names) in API responses, error strings, or UI labels. - Drivers: new LLM backends implement `LLMDriver` and register in `DriverRegistry` priority order. The arcaic browser driver is user-facing as "Arcaic-Technology" — never expose its scraping internals (no "puppeteer"/"crawler"/upstream-site names) in API responses, error strings, or UI labels.
- Docs are in English. Update `docs/` + README when adding routes, tables, or env vars. - Docs are in English. Update `docs/` + README when adding routes, tables, or env vars.
+5 -4
View File
@@ -2,7 +2,7 @@
Private ChatGPT/Claude-like chat. React + Tailwind + lucide frontend (rounded, Private ChatGPT/Claude-like chat. React + Tailwind + lucide frontend (rounded,
Signal-style, Inter font, light/dark/auto themes), Bun backend with Signal-style, Inter font, light/dark/auto themes), Bun backend with
class-oriented LLM **drivers** and a **SQLite** database (Bun native driver) for class-oriented LLM **drivers** and a **SQLite/Postgres** database (Bun native drivers) for
accounts, server-side chats and public shares. No cookies — Bearer keys in accounts, server-side chats and public shares. No cookies — Bearer keys in
`localStorage`. `localStorage`.
@@ -89,9 +89,10 @@ from the **Accounts** panel (each gets a one-time access key to hand over).
`POST /api/chat` with `conversationId` persists the user message + assistant `POST /api/chat` with `conversationId` persists the user message + assistant
reply into that conversation (ownership enforced) and auto-titles new chats. reply into that conversation (ownership enforced) and auto-titles new chats.
## Database (SQLite, Bun native) ## Database (SQLite default, Postgres fallback — both Bun native)
`$DATA_DIR/kisassistant.db` (WAL mode). Tables: `users` (id, username, `$DATA_DIR/kisassistant.db` (WAL mode), or Postgres when `POSTGRESQL_URL` is
set (e.g. a docker container — see `docs/OPERATIONS.md`). Tables: `users` (id, username,
display_name, avatar_url, theme, email, key_hash), `sessions` (hashed Bearer tokens), display_name, avatar_url, theme, email, key_hash), `sessions` (hashed Bearer tokens),
`conversations` (id, user, title, topic, model, archived_at), `messages`, `shares`, `conversations` (id, user, title, topic, model, archived_at), `messages`, `shares`,
`resets` (recovery tokens), `settings` (feature flags), `resets` (recovery tokens), `settings` (feature flags),
@@ -109,7 +110,7 @@ src/drivers/browser.ts ArcaicBrowserEngine + site configs (openai/qwen/gemini)
src/drivers/arcaic.ts ArcaicSubDriver ×3 — arcaic-openai / arcaic-gemini / arcaic-qwen (ARCAIC_ENABLED) src/drivers/arcaic.ts ArcaicSubDriver ×3 — arcaic-openai / arcaic-gemini / arcaic-qwen (ARCAIC_ENABLED)
src/drivers/registry.ts DriverRegistry — priority order + "driver:model" routing src/drivers/registry.ts DriverRegistry — priority order + "driver:model" routing
src/userProviders.ts personal providers (BYOK): per-user keys, own-key driver factory src/userProviders.ts personal providers (BYOK): per-user keys, own-key driver factory
src/db.ts SQLite store (users, sessions, convs, messages, shares) src/db.ts async store (users, sessions, convs, messages, shares) — SQLite/Postgres via DbClient
src/auth.ts login/sessions/rate-limit (admin key = APP_PASSWORD) src/auth.ts login/sessions/rate-limit (admin key = APP_PASSWORD)
``` ```
+18 -4
View File
@@ -1,16 +1,27 @@
# Architecture (backend) # Architecture (backend)
Single Bun process: `Bun.serve` handles the JSON/SSE API **and** the static Single Bun process: `Bun.serve` handles the JSON/SSE API **and** the static
frontend (same origin, no CORS). Persistence is SQLite via Bun's native frontend (same origin, no CORS). Persistence is SQLite by default via Bun's
`bun:sqlite` driver (`$DATA_DIR/kisassistant.db`, WAL mode). native `bun:sqlite` driver (`$DATA_DIR/kisassistant.db`, WAL mode), or
Postgres when `POSTGRESQL_URL` is set (Bun's native `bun:sql` driver,
typically a docker container — see `docs/OPERATIONS.md`).
``` ```
browser ──► Bun.serve (src/index.ts) browser ──► Bun.serve (src/index.ts)
├─ /api/* ──► auth gate ──► routes ──► db.ts ──► kisassistant.db ├─ /api/* ──► auth gate ──► routes ──► db.ts ──► sqlite file | postgres
│ └─────► DriverRegistry ──► ollama / APIs │ └─────► DriverRegistry ──► ollama / APIs
└─ /* ──► static files + SPA fallback (index.html, covers /share/:id and /chat/:id) └─ /* ──► static files + SPA fallback (index.html, covers /share/:id and /chat/:id)
``` ```
All store functions (`db.ts`, `access.ts`, `reports.ts`, `totp.ts`,
`modelPolicy.ts`, `userProviders.ts`) are `async` and talk through the
`DbClient` interface (`src/db/client.ts`): `get` / `all` / `run` / `exec` /
`insertReturningId` / `transaction`. Queries are written once with `?`
placeholders and translated to `$1, $2, …` for Postgres (quote-aware);
Postgres `BIGINT` cells arrive as `bigint` and are normalized to `number`.
One portable-SQL rule this forces: no bare `GROUP BY pk` with unaggregated
columns (rejected by Postgres) — use correlated subqueries instead.
## Request flow: chat ## Request flow: chat
`POST /api/chat { model: "driver:model", messages, conversationId?, stream? }` `POST /api/chat { model: "driver:model", messages, conversationId?, stream? }`
@@ -40,7 +51,10 @@ Streaming guarantees (ChatGPT-style, no buffering):
## Database schema ## Database schema
Created idempotently at boot in `getDb()` (no migration framework; keep it so). Created idempotently at boot in `initDb()` (no migration framework; keep it
so). On Postgres, timestamps are `BIGINT`, auto ids `BIGSERIAL`, later
columns arrive via `ADD COLUMN IF NOT EXISTS`, and `"window"` (model_usage)
is quoted — `WINDOW` is reserved on Postgres.
| Table | Purpose | | Table | Purpose |
|---|---| |---|---|
+30 -1
View File
@@ -10,7 +10,8 @@ Copy `.env.example` to `.env`. Full list:
| `PORT` / `HOST` | `3000` / `127.0.0.1` | listen address (keep localhost behind nginx) | | `PORT` / `HOST` | `3000` / `127.0.0.1` | listen address (keep localhost behind nginx) |
| `APP_PASSWORD` | *(required)* | **admin** access key — generate with `openssl rand -base64 24` | | `APP_PASSWORD` | *(required)* | **admin** access key — generate with `openssl rand -base64 24` |
| `SESSION_TTL_HOURS` | `720` | Bearer session lifetime (30 days) | | `SESSION_TTL_HOURS` | `720` | Bearer session lifetime (30 days) |
| `DATA_DIR` | `./data` | holds `kisassistant.db` (SQLite) | | `DATA_DIR` | `./data` | holds `kisassistant.db` (SQLite — unused when `POSTGRESQL_URL` is set) |
| `POSTGRESQL_URL` | *(empty)* | Postgres fallback, e.g. `postgres://kisassistant:secret@localhost:5432/kisassistant` (typically a docker container). Empty = local SQLite. Boot fails fast when unreachable. |
| `CDN_DIR` | `./cdn` | local file CDN storage (avatars; NixOS: under state dir) | | `CDN_DIR` | `./cdn` | local file CDN storage (avatars; NixOS: under state dir) |
| `OCR_LANG` | `eng` | tesseract language(s) for the OCR tool | | `OCR_LANG` | `eng` | tesseract language(s) for the OCR tool |
| `OCR_MAX_CHARS` | `100000` | transcription cap (longer results are truncated + flagged) | | `OCR_MAX_CHARS` | `100000` | transcription cap (longer results are truncated + flagged) |
@@ -42,6 +43,34 @@ Smoke tests: start the backend on a **free non-3000 port** with a throwaway
the API with curl/python: login → models → conversations → chat → share → the API with curl/python: login → models → conversations → chat → share →
unshare. See `AGENTS.md`. unshare. See `AGENTS.md`.
## Postgres fallback (docker)
SQLite is the default. For Postgres, run a container and point the app at it:
```bash
docker run -d --name kisassistant-pg --restart unless-stopped \
-e POSTGRES_USER=kisassistant -e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=kisassistant -p 127.0.0.1:5432:5432 \
-v kisassistant-pgdata:/var/lib/postgresql/data \
postgres:17-alpine
POSTGRESQL_URL=postgres://kisassistant:[email protected]:5432/kisassistant \
DATA_DIR=/tmp/ka-pg-throwaway \
bun run src/index.ts
```
Notes:
- The app uses Bun's native `bun:sql` driver — no extra dependency.
- Schema is created idempotently at boot (same tables as SQLite; timestamps
are `BIGINT`, auto ids `BIGSERIAL`). A fresh database starts empty except
the bootstrapped `admin` row — **there is no SQLite→Postgres data
migration** (re-create accounts or keep SQLite).
- Switching back is just unsetting `POSTGRESQL_URL` (the SQLite file is
untouched while Postgres is active).
- Back up with `pg_dump` (SQLite: copy `kisassistant.db`, or
`.backup` while running under WAL).
## Managing accounts ## Managing accounts
1. Log in as `admin` with the `APP_PASSWORD` key. 1. Log in as `admin` with the `APP_PASSWORD` key.
+11
View File
@@ -29,6 +29,15 @@ in {
description = "File containing APP_PASSWORD (use agenix/sops-nix, never store in the store)."; description = "File containing APP_PASSWORD (use agenix/sops-nix, never store in the store).";
}; };
ollamaHost = lib.mkOption { type = lib.types.str; default = "http://localhost:11434"; }; ollamaHost = lib.mkOption { type = lib.types.str; default = "http://localhost:11434"; };
postgresqlUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Postgres connection URL (e.g. postgres://user:pass@localhost:5432/kisassistant,
typically a docker container). Null (default) keeps the local SQLite file.
Prefer a credential file + extraEnv for secrets in production.
'';
};
domain = lib.mkOption { domain = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = null; default = null;
@@ -93,6 +102,8 @@ in {
CDN_DIR = "${cfg.dataDir}/cdn"; CDN_DIR = "${cfg.dataDir}/cdn";
DRIVER_DEBUG = "false"; DRIVER_DEBUG = "false";
OLLAMA_HOST = cfg.ollamaHost; OLLAMA_HOST = cfg.ollamaHost;
} // lib.optionalAttrs (cfg.postgresqlUrl != null) {
POSTGRESQL_URL = cfg.postgresqlUrl;
} // lib.optionalAttrs cfg.enableBrowserDriver { } // lib.optionalAttrs cfg.enableBrowserDriver {
ARCAIC_ENABLED = "true"; ARCAIC_ENABLED = "true";
ARCAIC_HEADLESS = "true"; ARCAIC_HEADLESS = "true";
+77 -60
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { getDb } from "./db.ts"; import { db } from "./db.ts";
/** /**
* Access-request wishlist (registration approval queue) + minimal ticket * Access-request wishlist (registration approval queue) + minimal ticket
@@ -33,8 +33,40 @@ export interface AccessMessage {
created_at: number; created_at: number;
} }
export function initAccessTables(): void { export async function initAccessTables(): Promise<void> {
const d = getDb(); const d = db();
const autoId = d.kind === "postgres" ? "BIGSERIAL PRIMARY KEY" : "INTEGER PRIMARY KEY AUTOINCREMENT";
await d.exec(`CREATE TABLE IF NOT EXISTS access_requests (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
email TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
reason TEXT NOT NULL DEFAULT '',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
)`);
if (d.kind === "sqlite") {
await repairSqliteAccessTables();
}
await d.exec(`CREATE TABLE IF NOT EXISTS access_messages (
id ${autoId},
request_id TEXT NOT NULL REFERENCES access_requests(id) ON DELETE CASCADE,
author TEXT NOT NULL,
body TEXT NOT NULL,
created_at BIGINT NOT NULL
)`);
await d.exec(`CREATE INDEX IF NOT EXISTS idx_access_requests_status ON access_requests(status, updated_at DESC)`);
await d.exec(`CREATE INDEX IF NOT EXISTS idx_access_messages_req ON access_messages(request_id, id)`);
}
/**
* SQLite-only repairs for DBs shaped by older builds (PRAGMA / sqlite_master
* introspection + DROP COLUMN don't exist on Postgres; fresh Postgres tables
* are created clean above).
*/
async function repairSqliteAccessTables(): Promise<void> {
const d = db();
const createRequests = `CREATE TABLE access_requests ( const createRequests = `CREATE TABLE access_requests (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -52,16 +84,6 @@ export function initAccessTables(): void {
body TEXT NOT NULL, body TEXT NOT NULL,
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
)`; )`;
d.query(`CREATE TABLE IF NOT EXISTS access_requests (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
email TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
reason TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`).run();
// Idempotent migration (must run BEFORE access_messages exists with an FK // Idempotent migration (must run BEFORE access_messages exists with an FK
// on access_requests — SQLite rewrites dependent FKs on DROP COLUMN and // on access_requests — SQLite rewrites dependent FKs on DROP COLUMN and
// would otherwise poison access_messages with a dangling reference). // would otherwise poison access_messages with a dangling reference).
@@ -70,22 +92,22 @@ export function initAccessTables(): void {
// CREATE TABLE IF NOT EXISTS won't fix existing DBs, and inserts without // CREATE TABLE IF NOT EXISTS won't fix existing DBs, and inserts without
// token_hash then fail with SQLITE_CONSTRAINT_NOTNULL. // token_hash then fail with SQLITE_CONSTRAINT_NOTNULL.
try { try {
const cols = d.query("PRAGMA table_info(access_requests)").all() as { name: string }[]; const cols = await d.all<{ name: string }>("PRAGMA table_info(access_requests)");
if (cols.some((c) => c.name === "token_hash")) { if (cols.some((c) => c.name === "token_hash")) {
try { try {
d.query("PRAGMA foreign_keys=OFF").run(); await d.exec("PRAGMA foreign_keys=OFF");
try { try {
d.query("ALTER TABLE access_requests DROP COLUMN token_hash").run(); await d.exec("ALTER TABLE access_requests DROP COLUMN token_hash");
} catch { } catch {
// SQLite builds without DROP COLUMN support: rebuild table. // SQLite builds without DROP COLUMN support: rebuild table.
d.query("ALTER TABLE access_requests RENAME TO access_requests_old").run(); await d.exec("ALTER TABLE access_requests RENAME TO access_requests_old");
d.query(createRequests).run(); await d.exec(createRequests);
d.query(`INSERT INTO access_requests (id, username, email, message, status, reason, created_at, updated_at) await d.exec(`INSERT INTO access_requests (id, username, email, message, status, reason, created_at, updated_at)
SELECT id, username, email, message, status, reason, created_at, updated_at FROM access_requests_old`).run(); SELECT id, username, email, message, status, reason, created_at, updated_at FROM access_requests_old`);
d.query("DROP TABLE access_requests_old").run(); await d.exec("DROP TABLE access_requests_old");
} }
} finally { } finally {
d.query("PRAGMA foreign_keys=ON").run(); await d.exec("PRAGMA foreign_keys=ON");
} }
} }
} catch { } catch {
@@ -95,39 +117,30 @@ export function initAccessTables(): void {
// created before the DROP COLUMN), leaving access_messages with // created before the DROP COLUMN), leaving access_messages with
// REFERENCES "access_requests_old". Rebuild it with the correct FK. // REFERENCES "access_requests_old". Rebuild it with the correct FK.
try { try {
const row = d.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'access_messages'").get() as { sql: string } | null; const row = await d.get<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'access_messages'");
if (row?.sql?.includes("access_requests_old")) { if (row?.sql?.includes("access_requests_old")) {
d.query("PRAGMA foreign_keys=OFF").run(); await d.exec("PRAGMA foreign_keys=OFF");
try { try {
d.query("DROP TABLE IF EXISTS access_messages_old").run(); await d.exec("DROP TABLE IF EXISTS access_messages_old");
d.query("ALTER TABLE access_messages RENAME TO access_messages_old").run(); await d.exec("ALTER TABLE access_messages RENAME TO access_messages_old");
d.query(createMessages).run(); await d.exec(createMessages);
d.query(`INSERT INTO access_messages (id, request_id, author, body, created_at) await d.exec(`INSERT INTO access_messages (id, request_id, author, body, created_at)
SELECT id, request_id, author, body, created_at FROM access_messages_old`).run(); SELECT id, request_id, author, body, created_at FROM access_messages_old`);
d.query("DROP TABLE access_messages_old").run(); await d.exec("DROP TABLE access_messages_old");
} finally { } finally {
d.query("PRAGMA foreign_keys=ON").run(); await d.exec("PRAGMA foreign_keys=ON");
} }
} }
} catch { } catch {
// Leave schema as-is; errors will surface on write. // Leave schema as-is; errors will surface on write.
} }
d.query(`CREATE TABLE IF NOT EXISTS access_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL REFERENCES access_requests(id) ON DELETE CASCADE,
author TEXT NOT NULL,
body TEXT NOT NULL,
created_at INTEGER NOT NULL
)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_access_requests_status ON access_requests(status, updated_at DESC)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_access_messages_req ON access_messages(request_id, id)`).run();
} }
export function createAccessRequest(opts: { export async function createAccessRequest(opts: {
username: string; username: string;
email: string; email: string;
message: string; message: string;
}): AccessRequest { }): Promise<AccessRequest> {
const now = Date.now(); const now = Date.now();
const row: AccessRequest = { const row: AccessRequest = {
id: randomUUID(), id: randomUUID(),
@@ -139,43 +152,47 @@ export function createAccessRequest(opts: {
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
getDb().query( await db().run(
"INSERT INTO access_requests (id, username, email, message, status, reason, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", "INSERT INTO access_requests (id, username, email, message, status, reason, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
).run(row.id, row.username, row.email, row.message, row.status, row.reason, row.created_at, row.updated_at); row.id, row.username, row.email, row.message, row.status, row.reason, row.created_at, row.updated_at,
);
return row; return row;
} }
export function getAccessRequest(id: string): AccessRequest | null { export async function getAccessRequest(id: string): Promise<AccessRequest | null> {
return getDb().query("SELECT * FROM access_requests WHERE id = ?").get(id) as AccessRequest | null; return db().get<AccessRequest>("SELECT * FROM access_requests WHERE id = ?", id);
} }
/** Open (non-terminal) request already reserving this username or email. */ /** Open (non-terminal) request already reserving this username or email. */
export function findOpenAccessRequest(username: string, email: string): AccessRequest | null { export async function findOpenAccessRequest(username: string, email: string): Promise<AccessRequest | null> {
return getDb().query( return db().get<AccessRequest>(
"SELECT * FROM access_requests WHERE status IN ('pending','reviewing') AND (username = ? OR email = ?) ORDER BY created_at DESC LIMIT 1", "SELECT * FROM access_requests WHERE status IN ('pending','reviewing') AND (username = ? OR email = ?) ORDER BY created_at DESC LIMIT 1",
).get(username, email) as AccessRequest | null; username, email,
);
} }
export function listAccessRequests(): (AccessRequest & { message_count: number })[] { export async function listAccessRequests(): Promise<(AccessRequest & { message_count: number })[]> {
return getDb().query( return db().all<AccessRequest & { message_count: number }>(
`SELECT r.*, (SELECT COUNT(*) FROM access_messages m WHERE m.request_id = r.id) AS message_count `SELECT r.*, (SELECT COUNT(*) FROM access_messages m WHERE m.request_id = r.id) AS message_count
FROM access_requests r ORDER BY r.updated_at DESC`, FROM access_requests r ORDER BY r.updated_at DESC`,
).all() as (AccessRequest & { message_count: number })[]; );
} }
export function setAccessStatus(id: string, status: AccessStatus, reason: string): AccessRequest | null { export async function setAccessStatus(id: string, status: AccessStatus, reason: string): Promise<AccessRequest | null> {
getDb().query("UPDATE access_requests SET status = ?, reason = ?, updated_at = ? WHERE id = ?").run(status, reason, Date.now(), id); await db().run("UPDATE access_requests SET status = ?, reason = ?, updated_at = ? WHERE id = ?", status, reason, Date.now(), id);
return getAccessRequest(id); return getAccessRequest(id);
} }
export function listAccessMessages(requestId: string): AccessMessage[] { export async function listAccessMessages(requestId: string): Promise<AccessMessage[]> {
return getDb().query("SELECT * FROM access_messages WHERE request_id = ? ORDER BY id ASC").all(requestId) as AccessMessage[]; return db().all<AccessMessage>("SELECT * FROM access_messages WHERE request_id = ? ORDER BY id ASC", requestId);
} }
export function addAccessMessage(requestId: string, author: "user" | "admin", body: string): AccessMessage { export async function addAccessMessage(requestId: string, author: "user" | "admin", body: string): Promise<AccessMessage> {
const now = Date.now(); const now = Date.now();
getDb().query("INSERT INTO access_messages (request_id, author, body, created_at) VALUES (?, ?, ?, ?)").run(requestId, author, body, now); const id = await db().insertReturningId(
getDb().query("UPDATE access_requests SET updated_at = ? WHERE id = ?").run(now, requestId); "INSERT INTO access_messages (request_id, author, body, created_at) VALUES (?, ?, ?, ?)",
const id = (getDb().query("SELECT last_insert_rowid() AS id").get() as { id: number }).id; requestId, author, body, now,
);
await db().run("UPDATE access_requests SET updated_at = ? WHERE id = ?", now, requestId);
return { id, request_id: requestId, author, body, created_at: now }; return { id, request_id: requestId, author, body, created_at: now };
} }
+12 -16
View File
@@ -3,7 +3,6 @@ import { config } from "./config.ts";
import { import {
createSession, createSession,
deleteSession, deleteSession,
getDb,
getSession, getSession,
getUserById, getUserById,
getUserByUsername, getUserByUsername,
@@ -11,9 +10,6 @@ import {
type PublicUser, type PublicUser,
} from "./db.ts"; } from "./db.ts";
// Ensure DB + admin row exist at startup.
getDb();
export type { PublicUser }; export type { PublicUser };
function sha256hex(s: string): string { function sha256hex(s: string): string {
@@ -49,40 +45,40 @@ function checkAdminKey(candidate: string): boolean {
* - username "admin" authenticates against APP_PASSWORD (.env). * - username "admin" authenticates against APP_PASSWORD (.env).
* - other usernames authenticate against their admin-issued access key. * - other usernames authenticate against their admin-issued access key.
*/ */
export function login(username: string, key: string): PublicUser | null { export async function login(username: string, key: string): Promise<PublicUser | null> {
if (username === "admin") { if (username === "admin") {
if (!checkAdminKey(key)) return null; if (!checkAdminKey(key)) return null;
const admin = getUserByUsername("admin"); const admin = await getUserByUsername("admin");
if (!admin) return null; if (!admin) return null;
return toPublicUser(admin); return toPublicUser(admin);
} }
const u = getUserByUsername(username); const u = await getUserByUsername(username);
if (!u || !u.key_hash) return null; if (!u || !u.key_hash) return null;
if (!secretsEqual(key, u.key_hash)) return null; if (!secretsEqual(key, u.key_hash)) return null;
return toPublicUser(u); return toPublicUser(u);
} }
/** Issue a fresh one-time Bearer token per successful login (stored hashed in SQLite). */ /** Issue a fresh one-time Bearer token per successful login (stored hashed server-side). */
export function issueToken(user: PublicUser): { token: string; expiresAt: number } { export async function issueToken(user: PublicUser): Promise<{ token: string; expiresAt: number }> {
const token = randomBytes(32).toString("hex"); // 256-bit const token = randomBytes(32).toString("hex"); // 256-bit
const expiresAt = Date.now() + config.sessionTtlMs; const expiresAt = Date.now() + config.sessionTtlMs;
createSession(sha256hex(token), user.id, expiresAt); await createSession(sha256hex(token), user.id, expiresAt);
return { token, expiresAt }; return { token, expiresAt };
} }
export function verifyToken(token: string): PublicUser | null { export async function verifyToken(token: string): Promise<PublicUser | null> {
const s = getSession(sha256hex(token)); const s = await getSession(sha256hex(token));
if (!s) return null; if (!s) return null;
const u = getUserById(s.user_id); const u = await getUserById(s.user_id);
if (!u) { if (!u) {
deleteSession(sha256hex(token)); await deleteSession(sha256hex(token));
return null; return null;
} }
return toPublicUser(u); return toPublicUser(u);
} }
export function revokeToken(token: string): void { export async function revokeToken(token: string): Promise<void> {
deleteSession(sha256hex(token)); await deleteSession(sha256hex(token));
} }
export function extractBearer(req: Request): string | null { export function extractBearer(req: Request): string | null {
+5
View File
@@ -24,6 +24,11 @@ export const config = {
sessionTtlMs: envInt("SESSION_TTL_HOURS", 720) * 3600_000, sessionTtlMs: envInt("SESSION_TTL_HOURS", 720) * 3600_000,
dataDir: env("DATA_DIR", "./data"), dataDir: env("DATA_DIR", "./data"),
// Database: empty POSTGRESQL_URL = local SQLite in DATA_DIR (default).
// Set POSTGRESQL_URL=postgres://user:pass@host:5432/db to run on Postgres
// (typically a docker container) via Bun's native SQL driver instead.
postgresqlUrl: env("POSTGRESQL_URL", ""),
rateLimitMax: envInt("RATE_LIMIT_MAX", 5), rateLimitMax: envInt("RATE_LIMIT_MAX", 5),
rateLimitWindowMs: envInt("RATE_LIMIT_WINDOW_MIN", 10) * 60_000, rateLimitWindowMs: envInt("RATE_LIMIT_WINDOW_MIN", 10) * 60_000,
+113 -198
View File
@@ -1,8 +1,9 @@
import { Database } from "bun:sqlite";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { randomBytes, randomUUID } from "node:crypto"; import { randomBytes, randomUUID } from "node:crypto";
import { config } from "./config.ts"; import { db, initDb, closeDb, dbKind } from "./db/client.ts";
import type { DbKind } from "./db/client.ts";
export { db, initDb, closeDb, dbKind };
export type { DbKind };
// --- row types --- // --- row types ---
@@ -54,94 +55,6 @@ export interface PublicUser {
isAdmin: boolean; isAdmin: boolean;
} }
// --- db handle ---
let db: Database | null = null;
export function getDb(): Database {
if (db) return db;
mkdirSync(config.dataDir, { recursive: true });
db = new Database(join(config.dataDir, "kisassistant.db"), { create: true });
db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA foreign_keys = ON;");
db.exec(`
CREATE TABLE IF NOT EXISTS users(
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
avatar_url TEXT NOT NULL DEFAULT '',
theme TEXT NOT NULL DEFAULT 'auto',
key_hash TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions(
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS conversations(
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT 'New chat',
topic TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_conv_user ON conversations(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS messages(
id INTEGER PRIMARY KEY AUTOINCREMENT,
conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conv_id, id);
CREATE TABLE IF NOT EXISTS shares(
conv_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
public_id TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS resets(
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS settings(
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_provider_keys(
user_id TEXT NOT NULL,
provider TEXT NOT NULL,
api_key TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, provider)
);
`);
// Email column added after the initial schema — keep idempotent.
const cols = db.query("PRAGMA table_info(users)").all() as { name: string }[];
if (!cols.some((c) => c.name === "email")) {
db.exec("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''");
}
// Archive flag added after the initial schema — keep idempotent.
const convCols = db.query("PRAGMA table_info(conversations)").all() as { name: string }[];
if (!convCols.some((c) => c.name === "archived_at")) {
db.exec("ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0");
}
// Admin pseudo-account row (auth still goes through APP_PASSWORD from .env).
const admin = db.query("SELECT id FROM users WHERE username = 'admin'").get() as { id: string } | null;
if (!admin) {
db.query(
"INSERT INTO users (id, username, display_name, avatar_url, theme, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(randomUUID(), "admin", "Administrator", "", "auto", "", Date.now());
}
return db;
}
export function toPublicUser(u: UserRow): PublicUser { export function toPublicUser(u: UserRow): PublicUser {
return { return {
id: u.id, id: u.id,
@@ -157,20 +70,19 @@ export function toPublicUser(u: UserRow): PublicUser {
// --- users --- // --- users ---
export function getUserByUsername(username: string): UserRow | null { export async function getUserByUsername(username: string): Promise<UserRow | null> {
return getDb().query("SELECT * FROM users WHERE username = ?").get(username) as UserRow | null; return db().get<UserRow>("SELECT * FROM users WHERE username = ?", username);
} }
export function getUserById(id: string): UserRow | null { export async function getUserById(id: string): Promise<UserRow | null> {
return getDb().query("SELECT * FROM users WHERE id = ?").get(id) as UserRow | null; return db().get<UserRow>("SELECT * FROM users WHERE id = ?", id);
} }
export function listUsers(): UserRow[] { export async function listUsers(): Promise<UserRow[]> {
return getDb().query("SELECT * FROM users ORDER BY created_at ASC").all() as UserRow[]; return db().all<UserRow>("SELECT * FROM users ORDER BY created_at ASC");
} }
export function createUser(opts: { username: string; displayName: string; avatarUrl: string; theme: string; email: string; keyHash: string }): UserRow { export async function createUser(opts: { username: string; displayName: string; avatarUrl: string; theme: string; email: string; keyHash: string }): Promise<UserRow> {
const d = getDb();
const row: UserRow = { const row: UserRow = {
id: randomUUID(), id: randomUUID(),
username: opts.username, username: opts.username,
@@ -181,14 +93,14 @@ export function createUser(opts: { username: string; displayName: string; avatar
key_hash: opts.keyHash, key_hash: opts.keyHash,
created_at: Date.now(), created_at: Date.now(),
}; };
d.query("INSERT INTO users (id, username, display_name, avatar_url, theme, email, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run( await db().run(
"INSERT INTO users (id, username, display_name, avatar_url, theme, email, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
row.id, row.username, row.display_name, row.avatar_url, row.theme, row.email, row.key_hash, row.created_at, row.id, row.username, row.display_name, row.avatar_url, row.theme, row.email, row.key_hash, row.created_at,
); );
return row; return row;
} }
export function updateUser(id: string, patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string; keyHash?: string }): UserRow | null { export async function updateUser(id: string, patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string; keyHash?: string }): Promise<UserRow | null> {
const d = getDb();
const sets: string[] = []; const sets: string[] = [];
const vals: (string | number)[] = []; const vals: (string | number)[] = [];
if (patch.username !== undefined) { sets.push("username = ?"); vals.push(patch.username); } if (patch.username !== undefined) { sets.push("username = ?"); vals.push(patch.username); }
@@ -198,62 +110,62 @@ export function updateUser(id: string, patch: { username?: string; displayName?:
if (patch.email !== undefined) { sets.push("email = ?"); vals.push(patch.email); } if (patch.email !== undefined) { sets.push("email = ?"); vals.push(patch.email); }
if (patch.keyHash !== undefined) { sets.push("key_hash = ?"); vals.push(patch.keyHash); } if (patch.keyHash !== undefined) { sets.push("key_hash = ?"); vals.push(patch.keyHash); }
if (sets.length > 0) { if (sets.length > 0) {
d.query(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`).run(...vals, id); await db().run(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`, ...vals, id);
} }
if (patch.username !== undefined) { if (patch.username !== undefined) {
// Username is denormalized onto past reports — keep the triage list consistent. // Username is denormalized onto past reports — keep the triage list consistent.
d.query("UPDATE reports SET reporter_name = ? WHERE reporter_id = ?").run(patch.username, id); await db().run("UPDATE reports SET reporter_name = ? WHERE reporter_id = ?", patch.username, id);
} }
return getUserById(id); return getUserById(id);
} }
export function deleteUser(id: string): void { export async function deleteUser(id: string): Promise<void> {
const d = getDb(); const d = db();
const convs = d.query("SELECT id FROM conversations WHERE user_id = ?").all(id) as { id: string }[]; const convs = await d.all<{ id: string }>("SELECT id FROM conversations WHERE user_id = ?", id);
const delMsg = d.query("DELETE FROM messages WHERE conv_id = ?"); await d.transaction(async (tx) => {
const delShare = d.query("DELETE FROM shares WHERE conv_id = ?"); for (const c of convs) {
const delConv = d.query("DELETE FROM conversations WHERE id = ?"); await tx.run("DELETE FROM messages WHERE conv_id = ?", c.id);
const txn = d.transaction((cid: string) => { await tx.run("DELETE FROM shares WHERE conv_id = ?", c.id);
delMsg.run(cid); await tx.run("DELETE FROM conversations WHERE id = ?", c.id);
delShare.run(cid); }
delConv.run(cid); await tx.run("DELETE FROM sessions WHERE user_id = ?", id);
await tx.run("DELETE FROM resets WHERE user_id = ?", id);
await tx.run("DELETE FROM user_provider_keys WHERE user_id = ?", id);
await tx.run("DELETE FROM users WHERE id = ?", id);
}); });
for (const c of convs) txn(c.id);
d.query("DELETE FROM sessions WHERE user_id = ?").run(id);
d.query("DELETE FROM resets WHERE user_id = ?").run(id);
d.query("DELETE FROM user_provider_keys WHERE user_id = ?").run(id);
d.query("DELETE FROM users WHERE id = ?").run(id);
} }
// --- sessions --- // --- sessions ---
export function createSession(tokenHash: string, userId: string, expiresAt: number): void { export async function createSession(tokenHash: string, userId: string, expiresAt: number): Promise<void> {
const now = Date.now(); const now = Date.now();
getDb().query("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(tokenHash, userId, now, expiresAt); await db().run(
"INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
tokenHash, userId, now, expiresAt,
);
} }
export function getSession(tokenHash: string): SessionRow | null { export async function getSession(tokenHash: string): Promise<SessionRow | null> {
const d = getDb(); const s = await db().get<SessionRow>("SELECT * FROM sessions WHERE token_hash = ?", tokenHash);
const s = d.query("SELECT * FROM sessions WHERE token_hash = ?").get(tokenHash) as SessionRow | null;
if (!s) return null; if (!s) return null;
if (s.expires_at <= Date.now()) { if (s.expires_at <= Date.now()) {
d.query("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash); await db().run("DELETE FROM sessions WHERE token_hash = ?", tokenHash);
return null; return null;
} }
return s; return s;
} }
export function deleteSession(tokenHash: string): void { export async function deleteSession(tokenHash: string): Promise<void> {
getDb().query("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash); await db().run("DELETE FROM sessions WHERE token_hash = ?", tokenHash);
} }
export function deleteUserSessions(userId: string): void { export async function deleteUserSessions(userId: string): Promise<void> {
getDb().query("DELETE FROM sessions WHERE user_id = ?").run(userId); await db().run("DELETE FROM sessions WHERE user_id = ?", userId);
} }
// --- conversations --- // --- conversations ---
export function createConversation(userId: string, opts: { title?: string; topic?: string; model?: string }): ConversationRow { export async function createConversation(userId: string, opts: { title?: string; topic?: string; model?: string }): Promise<ConversationRow> {
const now = Date.now(); const now = Date.now();
const row: ConversationRow = { const row: ConversationRow = {
id: randomUUID(), id: randomUUID(),
@@ -265,69 +177,74 @@ export function createConversation(userId: string, opts: { title?: string; topic
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
getDb().query( await db().run(
"INSERT INTO conversations (id, user_id, title, topic, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", "INSERT INTO conversations (id, user_id, title, topic, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(row.id, row.user_id, row.title, row.topic, row.model, row.created_at, row.updated_at); row.id, row.user_id, row.title, row.topic, row.model, row.created_at, row.updated_at,
);
return row; return row;
} }
export function listConversations(userId: string): ConversationRow[] { export async function listConversations(userId: string): Promise<ConversationRow[]> {
return getDb().query("SELECT * FROM conversations WHERE user_id = ? AND archived_at = 0 ORDER BY updated_at DESC").all(userId) as ConversationRow[]; return db().all<ConversationRow>(
"SELECT * FROM conversations WHERE user_id = ? AND archived_at = 0 ORDER BY updated_at DESC", userId,
);
} }
/** Archived chats: hidden from the sidebar, managed in settings. */ /** Archived chats: hidden from the sidebar, managed in settings. */
export function listArchivedConversations(userId: string): ConversationRow[] { export async function listArchivedConversations(userId: string): Promise<ConversationRow[]> {
return getDb().query("SELECT * FROM conversations WHERE user_id = ? AND archived_at != 0 ORDER BY updated_at DESC").all(userId) as ConversationRow[]; return db().all<ConversationRow>(
"SELECT * FROM conversations WHERE user_id = ? AND archived_at != 0 ORDER BY updated_at DESC", userId,
);
} }
/** /**
* (Un)archive a conversation. archived_at is left out of updated_at on * (Un)archive a conversation. archived_at is left out of updated_at on
* purpose so an unarchived chat returns to its chronological place. * purpose so an unarchived chat returns to its chronological place.
*/ */
export function setConversationArchived(id: string, archived: boolean): ConversationRow | null { export async function setConversationArchived(id: string, archived: boolean): Promise<ConversationRow | null> {
getDb().query("UPDATE conversations SET archived_at = ? WHERE id = ?").run(archived ? Date.now() : 0, id); await db().run("UPDATE conversations SET archived_at = ? WHERE id = ?", archived ? Date.now() : 0, id);
return getConversation(id); return getConversation(id);
} }
export function getConversation(id: string): ConversationRow | null { export async function getConversation(id: string): Promise<ConversationRow | null> {
return getDb().query("SELECT * FROM conversations WHERE id = ?").get(id) as ConversationRow | null; return db().get<ConversationRow>("SELECT * FROM conversations WHERE id = ?", id);
} }
export function updateConversation(id: string, patch: { title?: string; topic?: string; model?: string }): ConversationRow | null { export async function updateConversation(id: string, patch: { title?: string; topic?: string; model?: string }): Promise<ConversationRow | null> {
const d = getDb();
const sets: string[] = ["updated_at = ?"]; const sets: string[] = ["updated_at = ?"];
const vals: (string | number)[] = [Date.now()]; const vals: (string | number)[] = [Date.now()];
if (patch.title !== undefined) { sets.push("title = ?"); vals.push(patch.title.slice(0, 120) || "New chat"); } if (patch.title !== undefined) { sets.push("title = ?"); vals.push(patch.title.slice(0, 120) || "New chat"); }
if (patch.topic !== undefined) { sets.push("topic = ?"); vals.push(patch.topic.slice(0, 120)); } if (patch.topic !== undefined) { sets.push("topic = ?"); vals.push(patch.topic.slice(0, 120)); }
if (patch.model !== undefined) { sets.push("model = ?"); vals.push(patch.model.slice(0, 160)); } if (patch.model !== undefined) { sets.push("model = ?"); vals.push(patch.model.slice(0, 160)); }
d.query(`UPDATE conversations SET ${sets.join(", ")} WHERE id = ?`).run(...vals, id); await db().run(`UPDATE conversations SET ${sets.join(", ")} WHERE id = ?`, ...vals, id);
return getConversation(id); return getConversation(id);
} }
export function touchConversation(id: string, model?: string): void { export async function touchConversation(id: string, model?: string): Promise<void> {
if (model) { if (model) {
getDb().query("UPDATE conversations SET updated_at = ?, model = ? WHERE id = ?").run(Date.now(), model.slice(0, 160), id); await db().run("UPDATE conversations SET updated_at = ?, model = ? WHERE id = ?", Date.now(), model.slice(0, 160), id);
} else { } else {
getDb().query("UPDATE conversations SET updated_at = ? WHERE id = ?").run(Date.now(), id); await db().run("UPDATE conversations SET updated_at = ? WHERE id = ?", Date.now(), id);
} }
} }
export function deleteConversation(id: string): void { export async function deleteConversation(id: string): Promise<void> {
const d = getDb(); await db().transaction(async (tx) => {
const txn = d.transaction(() => { await tx.run("DELETE FROM messages WHERE conv_id = ?", id);
d.query("DELETE FROM messages WHERE conv_id = ?").run(id); await tx.run("DELETE FROM shares WHERE conv_id = ?", id);
d.query("DELETE FROM shares WHERE conv_id = ?").run(id); await tx.run("DELETE FROM conversations WHERE id = ?", id);
d.query("DELETE FROM conversations WHERE id = ?").run(id);
}); });
txn();
} }
export function addMessage(convId: string, role: string, content: string): void { export async function addMessage(convId: string, role: string, content: string): Promise<void> {
getDb().query("INSERT INTO messages (conv_id, role, content, created_at) VALUES (?, ?, ?, ?)").run(convId, role, content, Date.now()); await db().run(
"INSERT INTO messages (conv_id, role, content, created_at) VALUES (?, ?, ?, ?)",
convId, role, content, Date.now(),
);
} }
export function getMessages(convId: string): MessageRow[] { export async function getMessages(convId: string): Promise<MessageRow[]> {
return getDb().query("SELECT * FROM messages WHERE conv_id = ? ORDER BY id ASC LIMIT 500").all(convId) as MessageRow[]; return db().all<MessageRow>("SELECT * FROM messages WHERE conv_id = ? ORDER BY id ASC LIMIT 500", convId);
} }
// --- password-recovery resets (one-time, hashed, expiring) --- // --- password-recovery resets (one-time, hashed, expiring) ---
@@ -339,81 +256,80 @@ export interface ResetRow {
expires_at: number; expires_at: number;
} }
export function createReset(tokenHash: string, userId: string, expiresAt: number): void { export async function createReset(tokenHash: string, userId: string, expiresAt: number): Promise<void> {
const d = getDb();
// One active reset per account: replace any previous one. // One active reset per account: replace any previous one.
d.query("DELETE FROM resets WHERE user_id = ?").run(userId); await db().run("DELETE FROM resets WHERE user_id = ?", userId);
d.query("INSERT INTO resets (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run( await db().run(
"INSERT INTO resets (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
tokenHash, userId, Date.now(), expiresAt, tokenHash, userId, Date.now(), expiresAt,
); );
} }
export function peekReset(tokenHash: string): ResetRow | null { export async function peekReset(tokenHash: string): Promise<ResetRow | null> {
const d = getDb(); const r = await db().get<ResetRow>("SELECT * FROM resets WHERE token_hash = ?", tokenHash);
const r = d.query("SELECT * FROM resets WHERE token_hash = ?").get(tokenHash) as ResetRow | null;
if (!r) return null; if (!r) return null;
if (r.expires_at <= Date.now()) { if (r.expires_at <= Date.now()) {
d.query("DELETE FROM resets WHERE token_hash = ?").run(tokenHash); await db().run("DELETE FROM resets WHERE token_hash = ?", tokenHash);
return null; return null;
} }
return r; return r;
} }
/** Consume a reset token (single use). Returns the row, or null if invalid/expired. */ /** Consume a reset token (single use). Returns the row, or null if invalid/expired. */
export function consumeReset(tokenHash: string): ResetRow | null { export async function consumeReset(tokenHash: string): Promise<ResetRow | null> {
const r = peekReset(tokenHash); const r = await peekReset(tokenHash);
if (!r) return null; if (!r) return null;
getDb().query("DELETE FROM resets WHERE token_hash = ?").run(tokenHash); await db().run("DELETE FROM resets WHERE token_hash = ?", tokenHash);
return r; return r;
} }
export function deleteUserResets(userId: string): void { export async function deleteUserResets(userId: string): Promise<void> {
getDb().query("DELETE FROM resets WHERE user_id = ?").run(userId); await db().run("DELETE FROM resets WHERE user_id = ?", userId);
} }
// --- platform settings (admin-controlled feature flags) --- // --- platform settings (admin-controlled feature flags) ---
export function getSetting(key: string, fallback: string): string { export async function getSetting(key: string, fallback: string): Promise<string> {
const r = getDb().query("SELECT value FROM settings WHERE key = ?").get(key) as { value: string } | null; const r = await db().get<{ value: string }>("SELECT value FROM settings WHERE key = ?", key);
return r?.value ?? fallback; return r?.value ?? fallback;
} }
export function setSetting(key: string, value: string): void { export async function setSetting(key: string, value: string): Promise<void> {
getDb().query( await db().run(
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", "INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
).run(key, value, Date.now()); key, value, Date.now(),
);
} }
/** Public self-registration switch (default off = admin-created accounts only). */ /** Public self-registration switch (default off = admin-created accounts only). */
export function isRegistrationEnabled(): boolean { export async function isRegistrationEnabled(): Promise<boolean> {
return getSetting("registration_enabled", "0") === "1"; return (await getSetting("registration_enabled", "0")) === "1";
} }
/** Admin kill-switch for the OCR platform tool (default on). */ /** Admin kill-switch for the OCR platform tool (default on). */
export function isOcrToolEnabled(): boolean { export async function isOcrToolEnabled(): Promise<boolean> {
return getSetting("tools_ocr_enabled", "1") === "1"; return (await getSetting("tools_ocr_enabled", "1")) === "1";
} }
/** Admin kill-switch for message reports / flag button (default on). */ /** Admin kill-switch for message reports / flag button (default on). */
export function isReportsEnabled(): boolean { export async function isReportsEnabled(): Promise<boolean> {
return getSetting("reports_enabled", "1") === "1"; return (await getSetting("reports_enabled", "1")) === "1";
} }
/** Admin kill-switch for self-service username changes (default on; admins bypass). */ /** Admin kill-switch for self-service username changes (default on; admins bypass). */
export function isUsernameChangeEnabled(): boolean { export async function isUsernameChangeEnabled(): Promise<boolean> {
return getSetting("username_change_enabled", "1") === "1"; return (await getSetting("username_change_enabled", "1")) === "1";
} }
// --- public shares --- // --- public shares ---
export function ensureShare(convId: string): string { export async function ensureShare(convId: string): Promise<string> {
const d = getDb(); const existing = await db().get<{ public_id: string }>("SELECT public_id FROM shares WHERE conv_id = ?", convId);
const existing = d.query("SELECT public_id FROM shares WHERE conv_id = ?").get(convId) as { public_id: string } | null;
if (existing) return existing.public_id; if (existing) return existing.public_id;
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
const publicId = randomBytes(9).toString("base64url"); const publicId = randomBytes(9).toString("base64url");
try { try {
d.query("INSERT INTO shares (conv_id, public_id, created_at) VALUES (?, ?, ?)").run(convId, publicId, Date.now()); await db().run("INSERT INTO shares (conv_id, public_id, created_at) VALUES (?, ?, ?)", convId, publicId, Date.now());
return publicId; return publicId;
} catch { } catch {
// collision — retry // collision — retry
@@ -422,25 +338,24 @@ export function ensureShare(convId: string): string {
throw new Error("could not generate share id"); throw new Error("could not generate share id");
} }
export function getShareByConv(convId: string): string | null { export async function getShareByConv(convId: string): Promise<string | null> {
const r = getDb().query("SELECT public_id FROM shares WHERE conv_id = ?").get(convId) as { public_id: string } | null; const r = await db().get<{ public_id: string }>("SELECT public_id FROM shares WHERE conv_id = ?", convId);
return r?.public_id ?? null; return r?.public_id ?? null;
} }
export function deleteShare(convId: string): void { export async function deleteShare(convId: string): Promise<void> {
getDb().query("DELETE FROM shares WHERE conv_id = ?").run(convId); await db().run("DELETE FROM shares WHERE conv_id = ?", convId);
} }
export function getShareByPublic(publicId: string): { conv: ConversationRow; messages: MessageRow[]; authorName: string; authorAvatarUrl: string; archived: boolean; sharedAt: number } | null { export async function getShareByPublic(publicId: string): Promise<{ conv: ConversationRow; messages: MessageRow[]; authorName: string; authorAvatarUrl: string; archived: boolean; sharedAt: number } | null> {
const d = getDb(); const s = await db().get<{ conv_id: string; created_at: number }>("SELECT * FROM shares WHERE public_id = ?", publicId);
const s = d.query("SELECT * FROM shares WHERE public_id = ?").get(publicId) as { conv_id: string; created_at: number } | null;
if (!s) return null; if (!s) return null;
const conv = getConversation(s.conv_id); const conv = await getConversation(s.conv_id);
if (!conv) return null; if (!conv) return null;
const author = getUserById(conv.user_id); const author = await getUserById(conv.user_id);
return { return {
conv, conv,
messages: getMessages(conv.id).filter((m) => m.role !== "system"), messages: (await getMessages(conv.id)).filter((m) => m.role !== "system"),
authorName: author?.display_name || "Someone", authorName: author?.display_name || "Someone",
authorAvatarUrl: author?.avatar_url || "", authorAvatarUrl: author?.avatar_url || "",
archived: conv.archived_at !== 0, archived: conv.archived_at !== 0,
+445
View File
@@ -0,0 +1,445 @@
import { Database } from "bun:sqlite";
import { SQL } from "bun";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { config } from "../config.ts";
/**
* Database driver abstraction (SQLite default, Postgres opt-in).
*
* - SQLite: Bun's native `bun:sqlite` (synchronous underneath, single file
* in DATA_DIR). Zero setup, the historical default.
* - Postgres: Bun's native `bun:sql` driver (`new SQL(url)`), typically a
* docker container pointed at by POSTGRESQL_URL.
*
* All queries in the codebase use `?` placeholders (SQLite style); the
* Postgres client rewrites them to `$1, $2, …` (quote/comment aware).
* Postgres returns BIGINT cells as JS `bigint` — the client normalizes them
* to `number` (every integer column fits: ms timestamps, counts, ids).
*/
export type DbKind = "sqlite" | "postgres";
export type SqlParam = string | number | bigint | null;
export type SqlParams = SqlParam[];
export interface DbRunResult {
changes: number;
lastInsertId: number | null;
}
export interface DbTx {
get<T>(sql: string, ...params: SqlParams): Promise<T | null>;
all<T>(sql: string, ...params: SqlParams): Promise<T[]>;
run(sql: string, ...params: SqlParams): Promise<DbRunResult>;
exec(sql: string): Promise<void>;
}
export interface DbClient extends DbTx {
readonly kind: DbKind;
/** INSERT a single row and get its auto id back (RETURNING on pg). */
insertReturningId(sql: string, ...params: SqlParams): Promise<number>;
transaction<T>(fn: (tx: DbTx) => Promise<T>): Promise<T>;
close(): Promise<void>;
}
/** Rewrite `?` placeholders to `$n` for Postgres (skips quoted literals). */
export function sqliteToPgPlaceholders(sql: string): string {
let out = "";
let n = 0;
let i = 0;
const len = sql.length;
while (i < len) {
const c = sql[i]!;
// Single-quoted string literal ('' = escaped quote).
if (c === "'") {
out += c;
i++;
while (i < len) {
out += sql[i]!;
if (sql[i] === "'") {
if (sql[i + 1] === "'") { out += sql[i + 1]!; i += 2; continue; }
i++;
break;
}
i++;
}
continue;
}
// Double-quoted identifier ("..." with "" escape).
if (c === '"') {
out += c;
i++;
while (i < len) {
out += sql[i]!;
if (sql[i] === '"') {
if (sql[i + 1] === '"') { out += sql[i + 1]!; i += 2; continue; }
i++;
break;
}
i++;
}
continue;
}
// Line comment.
if (c === "-" && sql[i + 1] === "-") {
while (i < len && sql[i] !== "\n") out += sql[i++]!;
continue;
}
// Block comment.
if (c === "/" && sql[i + 1] === "*") {
out += "/*";
i += 2;
while (i < len && !(sql[i] === "*" && sql[i + 1] === "/")) out += sql[i++]!;
out += "*/";
i += 2;
continue;
}
if (c === "?") {
n++;
out += `$${n}`;
i++;
continue;
}
out += c;
i++;
}
return out;
}
/** Recursively normalize driver row values (bigint -> number). */
export function normalizeValue<T>(v: T): T {
if (typeof v === "bigint") return Number(v) as T;
if (Array.isArray(v)) return v.map(normalizeValue) as T;
if (v !== null && typeof v === "object") {
const o = v as Record<string, unknown>;
for (const k of Object.keys(o)) o[k] = normalizeValue(o[k]);
return v;
}
return v;
}
/** Redacted one-liner for logs (never print credentials). */
export function redactedUrl(url: string): string {
try {
const u = new URL(url);
return `${u.protocol}//${u.host}${u.pathname}`;
} catch {
return "(unparseable url)";
}
}
// --- SQLite implementation (bun:sqlite, sync underneath) ---
class SqliteTx implements DbTx {
constructor(private d: Database) {}
async get<T>(sql: string, ...params: SqlParams): Promise<T | null> {
return (this.d.query(sql).get(...params) as T | null) ?? null;
}
async all<T>(sql: string, ...params: SqlParams): Promise<T[]> {
return this.d.query(sql).all(...params) as T[];
}
async run(sql: string, ...params: SqlParams): Promise<DbRunResult> {
const r = this.d.query(sql).run(...params) as { changes: number; lastInsertRowid: number | bigint };
return { changes: Number(r.changes), lastInsertId: Number(r.lastInsertRowid) };
}
async exec(sql: string): Promise<void> {
this.d.exec(sql);
}
}
class SqliteClient extends SqliteTx implements DbClient {
readonly kind: DbKind = "sqlite";
constructor(private db: Database) {
super(db);
}
async insertReturningId(sql: string, ...params: SqlParams): Promise<number> {
const r = await this.run(sql, ...params);
return r.lastInsertId ?? 0;
}
async transaction<T>(fn: (tx: DbTx) => Promise<T>): Promise<T> {
// Manual BEGIN/COMMIT (bun:sqlite's transaction() only takes sync
// callbacks, so it can't wrap our async bodies — this is equivalent).
this.db.exec("BEGIN IMMEDIATE");
try {
const out = await fn(this);
this.db.exec("COMMIT");
return out;
} catch (e) {
try {
this.db.exec("ROLLBACK");
} catch {
// already rolled back / never began — report the original error
}
throw e;
}
}
async close(): Promise<void> {
this.db.close();
}
}
// --- Postgres implementation (Bun native bun:sql) ---
type PgRunner = {
unsafe: (sql: string, params?: SqlParams) => Promise<unknown>;
};
class PgTx implements DbTx {
constructor(private runner: PgRunner) {}
async get<T>(sql: string, ...params: SqlParams): Promise<T | null> {
const rows = (await this.runner.unsafe(sqliteToPgPlaceholders(sql), params)) as T[];
return rows.length > 0 ? normalizeValue(rows[0]!) : null;
}
async all<T>(sql: string, ...params: SqlParams): Promise<T[]> {
const rows = (await this.runner.unsafe(sqliteToPgPlaceholders(sql), params)) as T[];
return normalizeValue(rows);
}
async run(sql: string, ...params: SqlParams): Promise<DbRunResult> {
const res = (await this.runner.unsafe(sqliteToPgPlaceholders(sql), params)) as { count?: unknown } | null;
const changes = typeof res?.count === "number" ? res.count : 0;
return { changes, lastInsertId: null };
}
async exec(sql: string): Promise<void> {
await this.runner.unsafe(sql);
}
}
class PgClient extends PgTx implements DbClient {
readonly kind: DbKind = "postgres";
constructor(private sql: SQL) {
super(sql as unknown as PgRunner);
}
async insertReturningId(sql: string, ...params: SqlParams): Promise<number> {
const row = await this.get<{ id: number | bigint }>(`${sql} RETURNING id`, ...params);
return row ? Number(row.id) : 0;
}
async transaction<T>(fn: (tx: DbTx) => Promise<T>): Promise<T> {
return this.sql.begin(async (tx) => fn(new PgTx(tx as unknown as PgRunner)));
}
async close(): Promise<void> {
await this.sql.close();
}
}
// --- singleton ---
let client: DbClient | null = null;
export function db(): DbClient {
if (!client) throw new Error("database not initialized — call initDb() first");
return client;
}
export function dbKind(): DbKind {
return client?.kind ?? (config.postgresqlUrl ? "postgres" : "sqlite");
}
/** Connect + create schema idempotently. Fails fast when unreachable. */
export async function initDb(): Promise<DbClient> {
if (client) return client;
const url = config.postgresqlUrl.trim();
if (url) {
if (!/^postgres(ql)?:\/\//i.test(url)) {
throw new Error(`POSTGRESQL_URL must start with postgres:// or postgresql:// (got ${redactedUrl(url)})`);
}
const sql = new SQL(url);
try {
await sql`SELECT 1 AS ok`;
} catch (e) {
await sql.close().catch(() => {});
throw new Error(`cannot reach Postgres at ${redactedUrl(url)}: ${(e as Error).message}`);
}
client = new PgClient(sql);
await initPgSchema(client);
console.log(`[db] using postgres (${redactedUrl(url)})`);
return client;
}
mkdirSync(config.dataDir, { recursive: true });
const dbo = new Database(join(config.dataDir, "kisassistant.db"), { create: true });
dbo.exec("PRAGMA journal_mode = WAL;");
dbo.exec("PRAGMA foreign_keys = ON;");
client = new SqliteClient(dbo);
await initSqliteSchema(client);
console.log(`[db] using sqlite (${join(config.dataDir, "kisassistant.db")})`);
return client;
}
export async function closeDb(): Promise<void> {
if (client) {
await client.close();
client = null;
}
}
// --- core schema (users/sessions/conversations/messages/shares/resets/settings/providers) ---
const CORE_TABLES_SQLITE = `
CREATE TABLE IF NOT EXISTS users(
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
avatar_url TEXT NOT NULL DEFAULT '',
theme TEXT NOT NULL DEFAULT 'auto',
key_hash TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions(
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS conversations(
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT 'New chat',
topic TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_conv_user ON conversations(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS messages(
id INTEGER PRIMARY KEY AUTOINCREMENT,
conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conv_id, id);
CREATE TABLE IF NOT EXISTS shares(
conv_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
public_id TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS resets(
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS settings(
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_provider_keys(
user_id TEXT NOT NULL,
provider TEXT NOT NULL,
api_key TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, provider)
);
`;
const CORE_TABLES_PG = `
CREATE TABLE IF NOT EXISTS users(
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
avatar_url TEXT NOT NULL DEFAULT '',
theme TEXT NOT NULL DEFAULT 'auto',
key_hash TEXT NOT NULL DEFAULT '',
created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions(
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS conversations(
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT 'New chat',
topic TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_conv_user ON conversations(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS messages(
id BIGSERIAL PRIMARY KEY,
conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conv_id, id);
CREATE TABLE IF NOT EXISTS shares(
conv_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
public_id TEXT UNIQUE NOT NULL,
created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS resets(
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings(
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_provider_keys(
user_id TEXT NOT NULL,
provider TEXT NOT NULL,
api_key TEXT NOT NULL,
updated_at BIGINT NOT NULL,
PRIMARY KEY (user_id, provider)
);
`;
/** Columns added after the initial schema — idempotent on both drivers. */
const LATER_COLUMNS = [
"ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''",
"ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''",
"ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0",
];
/** Split a multi-statement DDL string into single statements. */
function splitStatements(ddl: string): string[] {
return ddl
.split(";")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
async function ensureAdminRow(c: DbClient): Promise<void> {
const admin = await c.get<{ id: string }>("SELECT id FROM users WHERE username = 'admin'");
if (!admin) {
const { randomUUID } = await import("node:crypto");
await c.run(
"INSERT INTO users (id, username, display_name, avatar_url, theme, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
randomUUID(), "admin", "Administrator", "", "auto", "", Date.now(),
);
}
}
async function initSqliteSchema(c: DbClient): Promise<void> {
await c.exec(CORE_TABLES_SQLITE);
// Column guards via PRAGMA (kept from the single-driver era).
const cols = await c.all<{ name: string }>("PRAGMA table_info(users)");
const names = new Set(cols.map((col) => col.name));
if (!names.has("email")) await c.exec("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''");
if (!names.has("totp_secret")) await c.exec("ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''");
if (!names.has("report_shadowbanned")) await c.exec("ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0");
const convCols = await c.all<{ name: string }>("PRAGMA table_info(conversations)");
if (!convCols.some((col) => col.name === "archived_at")) {
await c.exec("ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0");
}
await ensureAdminRow(c);
}
async function initPgSchema(c: DbClient): Promise<void> {
for (const stmt of splitStatements(CORE_TABLES_PG)) await c.exec(stmt);
// ADD COLUMN IF NOT EXISTS is natively idempotent on Postgres.
for (const ddl of LATER_COLUMNS) {
const m = ddl.match(/^ALTER TABLE (\S+) ADD COLUMN (\S+) (.+)$/);
if (!m) continue;
await c.exec(`ALTER TABLE ${m[1]} ADD COLUMN IF NOT EXISTS ${m[2]} ${m[3]}`);
}
await ensureAdminRow(c);
}
+150 -137
View File
@@ -25,13 +25,14 @@ import {
deleteUserSessions, deleteUserSessions,
ensureShare, ensureShare,
getConversation, getConversation,
getDb, db,
getMessages, getMessages,
getShareByConv, getShareByConv,
getShareByPublic, getShareByPublic,
getSetting, getSetting,
getUserById, getUserById,
getUserByUsername, getUserByUsername,
initDb,
isRegistrationEnabled, isRegistrationEnabled,
isReportsEnabled, isReportsEnabled,
isUsernameChangeEnabled, isUsernameChangeEnabled,
@@ -50,6 +51,7 @@ import type { ChatMessage } from "./drivers/types.ts";
import { import {
consumeTotpChallenge, consumeTotpChallenge,
createTotpChallenge, createTotpChallenge,
deleteTotpChallenges,
getTotpSecret, getTotpSecret,
initTotpTables, initTotpTables,
newTotpSecret, newTotpSecret,
@@ -123,14 +125,15 @@ import { OCR_MAX_BYTES, ocrAllowed, recordOcrJob } from "./tools/ocr.ts";
import { tools } from "./tools/registry.ts"; import { tools } from "./tools/registry.ts";
assertConfig(); assertConfig();
await initDb();
const registry = new DriverRegistry(); const registry = new DriverRegistry();
// Ollama handles admin model pulls/deletes (host is loopback/LAN, no auth). // Ollama handles admin model pulls/deletes (host is loopback/LAN, no auth).
const ollamaDriver = registry.get("ollama") as OllamaDriver; const ollamaDriver = registry.get("ollama") as OllamaDriver;
await tools.init(); await tools.init();
initAccessTables(); await initAccessTables();
initReportsTables(); await initReportsTables();
initModelUsageTables(); await initModelUsageTables();
initTotpTables(); await initTotpTables();
function json(data: unknown, status = 200): Response { function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), { return new Response(JSON.stringify(data), {
@@ -149,7 +152,7 @@ function clientIp(req: Request, server: { requestIP?: (r: Request) => { address:
return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
} }
function requireAuth(req: Request): PublicUser | null { async function requireAuth(req: Request): Promise<PublicUser | null> {
const token = extractBearer(req); const token = extractBearer(req);
if (!token) return null; if (!token) return null;
return verifyToken(token); return verifyToken(token);
@@ -273,7 +276,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// --- public: shared chat page data (no auth) --- // --- public: shared chat page data (no auth) ---
const shareMatch = path.match(/^\/api\/share\/([A-Za-z0-9_-]{6,64})$/); const shareMatch = path.match(/^\/api\/share\/([A-Za-z0-9_-]{6,64})$/);
if (shareMatch && req.method === "GET") { if (shareMatch && req.method === "GET") {
const s = getShareByPublic(shareMatch[1]!); const s = await getShareByPublic(shareMatch[1]!);
if (!s) return json({ error: "share not found" }, 404); if (!s) return json({ error: "share not found" }, 404);
return json({ return json({
title: s.conv.title, title: s.conv.title,
@@ -338,7 +341,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// extension always comes from magic-byte detection, never the client. // extension always comes from magic-byte detection, never the client.
const cdnPut = path.match(/^\/cdn\/([a-z0-9]{1,16})\/([A-Za-z0-9_-]{1,64})$/); const cdnPut = path.match(/^\/cdn\/([a-z0-9]{1,16})\/([A-Za-z0-9_-]{1,64})$/);
if (cdnPut && req.method === "PUT") { if (cdnPut && req.method === "PUT") {
const user = requireAuth(req); const user = await requireAuth(req);
if (!user) return json({ error: "unauthorized" }, 401); if (!user) return json({ error: "unauthorized" }, 401);
const [, ns, key] = cdnPut as [string, string, string]; const [, ns, key] = cdnPut as [string, string, string];
if (!validNamespace(ns) || !validKey(key)) return json({ error: "unknown namespace or key" }, 404); if (!validNamespace(ns) || !validKey(key)) return json({ error: "unknown namespace or key" }, 404);
@@ -385,7 +388,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
recordAttempt(ip); recordAttempt(ip);
return json({ error: 'expected { username, key }' }, 400); return json({ error: 'expected { username, key }' }, 400);
} }
const user = login(body.username.trim().toLowerCase(), body.key); const user = await login(body.username.trim().toLowerCase(), body.key);
if (!user) { if (!user) {
recordAttempt(ip); recordAttempt(ip);
await Bun.sleep(400); await Bun.sleep(400);
@@ -394,11 +397,11 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
clearAttempts(ip); clearAttempts(ip);
// Second factor: correct key but TOTP enabled -> short-lived challenge, // Second factor: correct key but TOTP enabled -> short-lived challenge,
// the session is only issued after POST /api/auth/totp verifies a code. // the session is only issued after POST /api/auth/totp verifies a code.
if (getTotpSecret(user.id)) { if (await getTotpSecret(user.id)) {
const totpToken = createTotpChallenge(user.id); const totpToken = await createTotpChallenge(user.id);
return json({ totpRequired: true, totpToken, username: user.username }); return json({ totpRequired: true, totpToken, username: user.username });
} }
const { token, expiresAt } = issueToken(user); const { token, expiresAt } = await issueToken(user);
return json({ token, expiresAt, user }); return json({ token, expiresAt, user });
} }
@@ -408,27 +411,27 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429); if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429);
const { ok, body } = await readJson(req); const { ok, body } = await readJson(req);
const userId = const userId =
ok && typeof body.totpToken === "string" ? consumeTotpChallenge(body.totpToken) : null; ok && typeof body.totpToken === "string" ? await consumeTotpChallenge(body.totpToken) : null;
const code = ok && typeof body.code === "string" ? body.code : ""; const code = ok && typeof body.code === "string" ? body.code : "";
const target = userId ? getUserById(userId) : null; const target = userId ? await getUserById(userId) : null;
if (!target || !verifyTotp(getTotpSecret(target.id), code)) { if (!target || !verifyTotp(await getTotpSecret(target.id), code)) {
recordAttempt(ip); recordAttempt(ip);
await Bun.sleep(400); await Bun.sleep(400);
return json({ error: "invalid code" }, 401); return json({ error: "invalid code" }, 401);
} }
clearAttempts(ip); clearAttempts(ip);
const { token, expiresAt } = issueToken(toPublicUser(target)); const { token, expiresAt } = await issueToken(toPublicUser(target));
return json({ token, expiresAt, user: toPublicUser(target) }); return json({ token, expiresAt, user: toPublicUser(target) });
} }
if (path === "/api/auth/verify" && req.method === "GET") { if (path === "/api/auth/verify" && req.method === "GET") {
const user = requireAuth(req); const user = await requireAuth(req);
return user ? json({ ok: true, user }) : json({ error: "unauthorized" }, 401); return user ? json({ ok: true, user }) : json({ error: "unauthorized" }, 401);
} }
if (path === "/api/auth/logout" && req.method === "POST") { if (path === "/api/auth/logout" && req.method === "POST") {
const token = extractBearer(req); const token = extractBearer(req);
if (token) revokeToken(token); if (token) await revokeToken(token);
return json({ ok: true }); return json({ ok: true });
} }
@@ -437,8 +440,8 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
return json({ return json({
recovery: mailEnabled(), recovery: mailEnabled(),
from: mailEnabled() ? config.smtpFrom : undefined, from: mailEnabled() ? config.smtpFrom : undefined,
registration: isRegistrationEnabled(), registration: await isRegistrationEnabled(),
accessRequest: getSetting("registration_request_enabled", "1") === "1", accessRequest: (await getSetting("registration_request_enabled", "1")) === "1",
}); });
} }
@@ -446,7 +449,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (path === "/api/auth/register" && req.method === "POST") { if (path === "/api/auth/register" && req.method === "POST") {
const ip = clientIp(req, server); const ip = clientIp(req, server);
if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429); if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429);
if (!isRegistrationEnabled()) { if (!await isRegistrationEnabled()) {
recordAttempt(ip); recordAttempt(ip);
return json({ error: "registration is currently disabled on this platform" }, 403); return json({ error: "registration is currently disabled on this platform" }, 403);
} }
@@ -460,7 +463,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
recordAttempt(ip); recordAttempt(ip);
return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400); return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400);
} }
if (getUserByUsername(username)) { if (await getUserByUsername(username)) {
recordAttempt(ip); recordAttempt(ip);
return json({ error: "username taken" }, 409); return json({ error: "username taken" }, 409);
} }
@@ -475,7 +478,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
return json({ error: "invalid email address" }, 400); return json({ error: "invalid email address" }, 400);
} }
const key = newAccessKey(); const key = newAccessKey();
const created = createUser({ username, displayName, avatarUrl: "", theme: "auto", email, keyHash: hashSecret(key) }); const created = await createUser({ username, displayName, avatarUrl: "", theme: "auto", email, keyHash: hashSecret(key) });
clearAttempts(ip); clearAttempts(ip);
// Show the raw key exactly once. // Show the raw key exactly once.
return json({ user: toPublicUser(created), key }, 201); return json({ user: toPublicUser(created), key }, 201);
@@ -488,10 +491,10 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// Never enumerate accounts: always answer ok. // Never enumerate accounts: always answer ok.
if (ok && typeof body.username === "string") { if (ok && typeof body.username === "string") {
const name = body.username.trim().toLowerCase(); const name = body.username.trim().toLowerCase();
const target = name === "admin" ? null : getUserByUsername(name); const target = name === "admin" ? null : await getUserByUsername(name);
if (target && target.email && mailEnabled()) { if (target && target.email && mailEnabled()) {
const token = "rt_" + randomBytes(24).toString("base64url"); const token = "rt_" + randomBytes(24).toString("base64url");
createReset(createHash("sha256").update(token).digest("hex"), target.id, Date.now() + config.resetTtlMs); await createReset(createHash("sha256").update(token).digest("hex"), target.id, Date.now() + config.resetTtlMs);
sendRecoveryEmail(target.email, target.username, `${config.appUrl}/reset/${token}`).catch(() => {}); sendRecoveryEmail(target.email, target.username, `${config.appUrl}/reset/${token}`).catch(() => {});
clearAttempts(ip); clearAttempts(ip);
return json({ ok: true }); return json({ ok: true });
@@ -506,19 +509,19 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (resetMatch) { if (resetMatch) {
const tokenHash = createHash("sha256").update(resetMatch[1]!).digest("hex"); const tokenHash = createHash("sha256").update(resetMatch[1]!).digest("hex");
if (req.method === "GET") { if (req.method === "GET") {
const r = peekReset(tokenHash); const r = await peekReset(tokenHash);
const u = r ? getUserById(r.user_id) : null; const u = r ? await getUserById(r.user_id) : null;
if (!r || !u) return json({ error: "invalid or expired link" }, 404); if (!r || !u) return json({ error: "invalid or expired link" }, 404);
return json({ ok: true, username: u.username }); return json({ ok: true, username: u.username });
} }
if (req.method === "POST") { if (req.method === "POST") {
const r = consumeReset(tokenHash); const r = await consumeReset(tokenHash);
const u = r ? getUserById(r.user_id) : null; const u = r ? await getUserById(r.user_id) : null;
if (!r || !u || u.username === "admin") return json({ error: "invalid or expired link" }, 404); if (!r || !u || u.username === "admin") return json({ error: "invalid or expired link" }, 404);
// Recovery = instant key rotation; the new key is shown exactly once. // Recovery = instant key rotation; the new key is shown exactly once.
const key = newAccessKey(); const key = newAccessKey();
updateUser(u.id, { keyHash: hashSecret(key) }); await updateUser(u.id, { keyHash: hashSecret(key) });
deleteUserSessions(u.id); await deleteUserSessions(u.id);
return json({ key }); return json({ key });
} }
} }
@@ -530,7 +533,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429); if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429);
// Wishlist is mutually exclusive with open registration: when anyone // Wishlist is mutually exclusive with open registration: when anyone
// can register, there is nothing to request. // can register, there is nothing to request.
if (isRegistrationEnabled() || getSetting("registration_request_enabled", "1") !== "1") { if ((await isRegistrationEnabled()) || (await getSetting("registration_request_enabled", "1")) !== "1") {
recordAttempt(ip); recordAttempt(ip);
return json({ error: "access requests are currently disabled on this platform" }, 403); return json({ error: "access requests are currently disabled on this platform" }, 403);
} }
@@ -546,16 +549,16 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
recordAttempt(ip); recordAttempt(ip);
return json({ error: "username (2-32 chars), valid email and a motivation (10+ chars) are required" }, 400); return json({ error: "username (2-32 chars), valid email and a motivation (10+ chars) are required" }, 400);
} }
if (getUserByUsername(username)) { if (await getUserByUsername(username)) {
recordAttempt(ip); recordAttempt(ip);
return json({ error: "username taken" }, 409); return json({ error: "username taken" }, 409);
} }
if (findOpenAccessRequest(username, email)) { if (await findOpenAccessRequest(username, email)) {
recordAttempt(ip); recordAttempt(ip);
return json({ error: "a request for this username or email is already open" }, 409); return json({ error: "a request for this username or email is already open" }, 409);
} }
const created = createAccessRequest({ username, email, message }); const created = await createAccessRequest({ username, email, message });
const firstMsg = addAccessMessage(created.id, "user", message); const firstMsg = await addAccessMessage(created.id, "user", message);
clearAttempts(ip); clearAttempts(ip);
broadcastAccessLive({ type: "access_created", request_id: created.id, request: created }); broadcastAccessLive({ type: "access_created", request_id: created.id, request: created });
broadcastAccessLive({ type: "access_message", request_id: created.id, message: firstMsg }); broadcastAccessLive({ type: "access_message", request_id: created.id, message: firstMsg });
@@ -567,10 +570,10 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const ticketMatch = path.match(/^\/api\/access\/ticket\/([0-9a-f-]{36})(\/message)?$/); const ticketMatch = path.match(/^\/api\/access\/ticket\/([0-9a-f-]{36})(\/message)?$/);
if (ticketMatch) { if (ticketMatch) {
const r = getAccessRequest(ticketMatch[1]!); const r = await getAccessRequest(ticketMatch[1]!);
if (!r) return json({ error: "invalid ticket link" }, 404); if (!r) return json({ error: "invalid ticket link" }, 404);
if (req.method === "GET" && !ticketMatch[2]) { if (req.method === "GET" && !ticketMatch[2]) {
return json({ request: r, messages: listAccessMessages(r.id) }); return json({ request: r, messages: await listAccessMessages(r.id) });
} }
if (req.method === "POST" && ticketMatch[2] === "/message") { if (req.method === "POST" && ticketMatch[2] === "/message") {
const ip = clientIp(req, server); const ip = clientIp(req, server);
@@ -584,7 +587,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
recordAttempt(ip); recordAttempt(ip);
return json({ error: "message: 1-2000 chars" }, 400); return json({ error: "message: 1-2000 chars" }, 400);
} }
const msg = addAccessMessage(r.id, "user", text); const msg = await addAccessMessage(r.id, "user", text);
clearAttempts(ip); clearAttempts(ip);
broadcastAccessLive({ type: "access_message", request_id: r.id, message: msg }); broadcastAccessLive({ type: "access_message", request_id: r.id, message: msg });
return json({ message: msg }, 201); return json({ message: msg }, 201);
@@ -598,14 +601,14 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// ?token= (browsers can't set WS headers) or Authorization header. // ?token= (browsers can't set WS headers) or Authorization header.
const ticketWsMatch = path.match(/^\/api\/access\/ws\/([0-9a-f-]{36})$/); const ticketWsMatch = path.match(/^\/api\/access\/ws\/([0-9a-f-]{36})$/);
if (ticketWsMatch && req.method === "GET") { if (ticketWsMatch && req.method === "GET") {
const t = getAccessRequest(ticketWsMatch[1]!); const t = await getAccessRequest(ticketWsMatch[1]!);
if (!t) return json({ error: "invalid ticket link" }, 404); if (!t) return json({ error: "invalid ticket link" }, 404);
if (server.upgrade(req, { data: { ticketId: t.id, isAdmin: false } })) return; if (server.upgrade(req, { data: { ticketId: t.id, isAdmin: false } })) return;
return json({ error: "websocket upgrade failed" }, 500); return json({ error: "websocket upgrade failed" }, 500);
} }
if (path === "/api/admin/ws" && req.method === "GET") { if (path === "/api/admin/ws" && req.method === "GET") {
const token = url.searchParams.get("token") ?? extractBearer(req); const token = url.searchParams.get("token") ?? extractBearer(req);
const u = token ? verifyToken(token) : null; const u = token ? await verifyToken(token) : null;
if (!u?.isAdmin) return json({ error: "unauthorized" }, 401); if (!u?.isAdmin) return json({ error: "unauthorized" }, 401);
if (server.upgrade(req, { data: { ticketId: null, isAdmin: true } })) return; if (server.upgrade(req, { data: { ticketId: null, isAdmin: true } })) return;
return json({ error: "websocket upgrade failed" }, 500); return json({ error: "websocket upgrade failed" }, 500);
@@ -613,7 +616,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// --- everything below requires auth --- // --- everything below requires auth ---
if (path.startsWith("/api/")) { if (path.startsWith("/api/")) {
const user = requireAuth(req); const user = await requireAuth(req);
if (!user) return json({ error: "unauthorized" }, 401); if (!user) return json({ error: "unauthorized" }, 401);
const admin = user.isAdmin; const admin = user.isAdmin;
@@ -626,10 +629,10 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string } = {}; const patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string } = {};
if (body.username !== undefined) { if (body.username !== undefined) {
if (user.username === "admin") return json({ error: "the admin account cannot be renamed" }, 403); if (user.username === "admin") return json({ error: "the admin account cannot be renamed" }, 403);
if (!isUsernameChangeEnabled()) return json({ error: "username changes are disabled" }, 403); if (!await isUsernameChangeEnabled()) return json({ error: "username changes are disabled" }, 403);
const u = validUsername(body.username); const u = validUsername(body.username);
if (!u) return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400); if (!u) return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400);
const taken = getUserByUsername(u); const taken = await getUserByUsername(u);
if (taken && taken.id !== user.id) return json({ error: "username taken" }, 409); if (taken && taken.id !== user.id) return json({ error: "username taken" }, 409);
patch.username = u; patch.username = u;
} }
@@ -652,7 +655,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (e === null) return json({ error: "invalid email address" }, 400); if (e === null) return json({ error: "invalid email address" }, 400);
patch.email = e; patch.email = e;
} }
const updated = updateUser(user.id, patch); const updated = await updateUser(user.id, patch);
if (!updated) return json({ error: "user not found" }, 404); if (!updated) return json({ error: "user not found" }, 404);
return json({ user: toPublicUser(updated) }); return json({ user: toPublicUser(updated) });
} }
@@ -660,25 +663,25 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// --- self-service security: rotate access key (old sessions revoked) --- // --- self-service security: rotate access key (old sessions revoked) ---
if (path === "/api/me/key/rotate" && req.method === "POST") { if (path === "/api/me/key/rotate" && req.method === "POST") {
const key = newAccessKey(); const key = newAccessKey();
updateUser(user.id, { keyHash: hashSecret(key) }); await updateUser(user.id, { keyHash: hashSecret(key) });
deleteUserSessions(user.id); await deleteUserSessions(user.id);
return json({ key }); return json({ key });
} }
// --- self-service: delete your own account (chats, shares, sessions gone) --- // --- self-service: delete your own account (chats, shares, sessions gone) ---
if (path === "/api/me" && req.method === "DELETE") { if (path === "/api/me" && req.method === "DELETE") {
if (user.username === "admin") return json({ error: "the admin account cannot be deleted" }, 403); if (user.username === "admin") return json({ error: "the admin account cannot be deleted" }, 403);
deleteUser(user.id); await deleteUser(user.id);
getDb().query("DELETE FROM totp_challenges WHERE user_id = ?").run(user.id); await deleteTotpChallenges(user.id);
return json({ ok: true }); return json({ ok: true });
} }
// --- self-service security: TOTP two-factor --- // --- self-service security: TOTP two-factor ---
if (path === "/api/me/totp" && req.method === "GET") { if (path === "/api/me/totp" && req.method === "GET") {
return json({ enabled: getTotpSecret(user.id).length > 0 }); return json({ enabled: (await getTotpSecret(user.id)).length > 0 });
} }
if (path === "/api/me/totp/setup" && req.method === "POST") { if (path === "/api/me/totp/setup" && req.method === "POST") {
if (getTotpSecret(user.id)) return json({ error: "TOTP already enabled — disable it first" }, 409); if (await getTotpSecret(user.id)) return json({ error: "TOTP already enabled — disable it first" }, 409);
const secret = newTotpSecret(); const secret = newTotpSecret();
return json({ secret, otpauthUrl: totpAuthUrl(secret, user.username) }); return json({ secret, otpauthUrl: totpAuthUrl(secret, user.username) });
} }
@@ -688,14 +691,14 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const code = ok && typeof body.code === "string" ? body.code : ""; const code = ok && typeof body.code === "string" ? body.code : "";
if (!/^[A-Z2-7]{16,64}$/.test(secret.trim().toUpperCase())) return json({ error: "invalid secret" }, 400); if (!/^[A-Z2-7]{16,64}$/.test(secret.trim().toUpperCase())) return json({ error: "invalid secret" }, 400);
if (!verifyTotp(secret, code)) return json({ error: "invalid code" }, 401); if (!verifyTotp(secret, code)) return json({ error: "invalid code" }, 401);
setTotpSecret(user.id, secret.trim().toUpperCase()); await setTotpSecret(user.id, secret.trim().toUpperCase());
return json({ enabled: true }); return json({ enabled: true });
} }
if (path === "/api/me/totp" && req.method === "DELETE") { if (path === "/api/me/totp" && req.method === "DELETE") {
const { ok, body } = await readJson(req); const { ok, body } = await readJson(req);
const code = ok && typeof body.code === "string" ? body.code : ""; const code = ok && typeof body.code === "string" ? body.code : "";
if (!verifyTotp(getTotpSecret(user.id), code)) return json({ error: "invalid code" }, 401); if (!verifyTotp(await getTotpSecret(user.id), code)) return json({ error: "invalid code" }, 401);
setTotpSecret(user.id, ""); await setTotpSecret(user.id, "");
return json({ enabled: false }); return json({ enabled: false });
} }
@@ -704,7 +707,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// presence + last4. Connected providers add their models to this // presence + last4. Connected providers add their models to this
// user's /api/models list and /api/chat honors the personal key. // user's /api/models list and /api/chat honors the personal key.
if (path === "/api/me/providers" && req.method === "GET") { if (path === "/api/me/providers" && req.method === "GET") {
return json({ providers: listUserProviders(user.id) }); return json({ providers: await listUserProviders(user.id) });
} }
const providerMatch = path.match(/^\/api\/me\/providers\/([a-z]{3,12})$/); const providerMatch = path.match(/^\/api\/me\/providers\/([a-z]{3,12})$/);
@@ -718,12 +721,12 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const { ok, body } = await readJson(req); const { ok, body } = await readJson(req);
const key = ok ? validProviderKey(body.apiKey) : null; const key = ok ? validProviderKey(body.apiKey) : null;
if (!key) return json({ error: "apiKey: 8-256 chars, no whitespace" }, 400); if (!key) return json({ error: "apiKey: 8-256 chars, no whitespace" }, 400);
setUserProviderKey(user.id, provider, key); await setUserProviderKey(user.id, provider, key);
return json({ providers: listUserProviders(user.id) }); return json({ providers: await listUserProviders(user.id) });
} }
if (req.method === "DELETE") { if (req.method === "DELETE") {
deleteUserProviderKey(user.id, provider); await deleteUserProviderKey(user.id, provider);
return json({ providers: listUserProviders(user.id) }); return json({ providers: await listUserProviders(user.id) });
} }
return json({ error: "not found" }, 404); return json({ error: "not found" }, 404);
} }
@@ -737,7 +740,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const filter = url.searchParams.get("filter") || "all"; // all | with-email | no-email const filter = url.searchParams.get("filter") || "all"; // all | with-email | no-email
const per = Math.min(Math.max(Number(url.searchParams.get("per")) || 10, 1), 50); const per = Math.min(Math.max(Number(url.searchParams.get("per")) || 10, 1), 50);
const page = Math.max(Number(url.searchParams.get("page")) || 1, 1); const page = Math.max(Number(url.searchParams.get("page")) || 1, 1);
let users = listUsers(); let users = await listUsers();
if (q) { if (q) {
users = users.filter( users = users.filter(
(u) => (u) =>
@@ -768,7 +771,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (!ok) return json({ error: "invalid JSON" }, 400); if (!ok) return json({ error: "invalid JSON" }, 400);
const username = validUsername(body.username); const username = validUsername(body.username);
if (!username) return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400); if (!username) return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400);
if (getUserByUsername(username)) return json({ error: "username taken" }, 409); if (await getUserByUsername(username)) return json({ error: "username taken" }, 409);
const displayName = body.displayName === undefined ? username : cleanStr(body.displayName, 60); const displayName = body.displayName === undefined ? username : cleanStr(body.displayName, 60);
if (displayName === null) return json({ error: "displayName: 1-60 chars" }, 400); if (displayName === null) return json({ error: "displayName: 1-60 chars" }, 400);
const avatarUrl = body.avatarUrl === undefined ? "" : validAvatar(body.avatarUrl); const avatarUrl = body.avatarUrl === undefined ? "" : validAvatar(body.avatarUrl);
@@ -778,7 +781,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const email = body.email === undefined ? "" : validEmail(body.email); const email = body.email === undefined ? "" : validEmail(body.email);
if (email === null) return json({ error: "invalid email address" }, 400); if (email === null) return json({ error: "invalid email address" }, 400);
const key = newAccessKey(); const key = newAccessKey();
const created = createUser({ username, displayName, avatarUrl, theme, email, keyHash: hashSecret(key) }); const created = await createUser({ username, displayName, avatarUrl, theme, email, keyHash: hashSecret(key) });
// Show the raw key exactly once. // Show the raw key exactly once.
return json({ user: toPublicUser(created), key }, 201); return json({ user: toPublicUser(created), key }, 201);
} }
@@ -786,16 +789,16 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const adminUserMatch = path.match(/^\/api\/admin\/users\/([0-9a-f-]{10,50})(\/regenerate|\/shadowban)?$/); const adminUserMatch = path.match(/^\/api\/admin\/users\/([0-9a-f-]{10,50})(\/regenerate|\/shadowban)?$/);
if (adminUserMatch) { if (adminUserMatch) {
if (!admin) return json({ error: "forbidden" }, 403); if (!admin) return json({ error: "forbidden" }, 403);
const target = getUserById(adminUserMatch[1]!); const target = await getUserById(adminUserMatch[1]!);
if (!target || target.username === "admin") return json({ error: "user not found" }, 404); if (!target || target.username === "admin") return json({ error: "user not found" }, 404);
if (req.method === "DELETE" && !adminUserMatch[2]) { if (req.method === "DELETE" && !adminUserMatch[2]) {
deleteUser(target.id); await deleteUser(target.id);
return json({ ok: true }); return json({ ok: true });
} }
if (req.method === "POST" && adminUserMatch[2] === "/regenerate") { if (req.method === "POST" && adminUserMatch[2] === "/regenerate") {
const key = newAccessKey(); const key = newAccessKey();
updateUser(target.id, { keyHash: hashSecret(key) }); await updateUser(target.id, { keyHash: hashSecret(key) });
deleteUserSessions(target.id); await deleteUserSessions(target.id);
return json({ key }); return json({ key });
} }
if (req.method === "POST" && adminUserMatch[2] === "/shadowban") { if (req.method === "POST" && adminUserMatch[2] === "/shadowban") {
@@ -803,7 +806,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (!ok || typeof body.shadowbanned !== "boolean") { if (!ok || typeof body.shadowbanned !== "boolean") {
return json({ error: "expected { shadowbanned: boolean }" }, 400); return json({ error: "expected { shadowbanned: boolean }" }, 400);
} }
return json({ shadowbanned: setReportShadowbanned(target.id, body.shadowbanned) === 1 }); return json({ shadowbanned: (await setReportShadowbanned(target.id, body.shadowbanned)) === 1 });
} }
if (req.method === "PATCH" && !adminUserMatch[2]) { if (req.method === "PATCH" && !adminUserMatch[2]) {
const { ok, body } = await readJson(req); const { ok, body } = await readJson(req);
@@ -813,7 +816,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (target.username === "admin") return json({ error: "the admin account cannot be renamed" }, 403); if (target.username === "admin") return json({ error: "the admin account cannot be renamed" }, 403);
const u = validUsername(body.username); const u = validUsername(body.username);
if (!u) return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400); if (!u) return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400);
const taken = getUserByUsername(u); const taken = await getUserByUsername(u);
if (taken && taken.id !== target.id) return json({ error: "username taken" }, 409); if (taken && taken.id !== target.id) return json({ error: "username taken" }, 409);
patch.username = u; patch.username = u;
} }
@@ -836,7 +839,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (e === null) return json({ error: "invalid email address" }, 400); if (e === null) return json({ error: "invalid email address" }, 400);
patch.email = e; patch.email = e;
} }
const updated = updateUser(target.id, patch); const updated = await updateUser(target.id, patch);
return json({ user: toPublicUser(updated!) }); return json({ user: toPublicUser(updated!) });
} }
return json({ error: "not found" }, 404); return json({ error: "not found" }, 404);
@@ -847,11 +850,11 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (!admin) return json({ error: "forbidden" }, 403); if (!admin) return json({ error: "forbidden" }, 403);
return json({ return json({
settings: { settings: {
registrationEnabled: isRegistrationEnabled(), registrationEnabled: await isRegistrationEnabled(),
accessRequestEnabled: getSetting("registration_request_enabled", "1") === "1", accessRequestEnabled: (await getSetting("registration_request_enabled", "1")) === "1",
ocrEnabled: getSetting("tools_ocr_enabled", "1") === "1", ocrEnabled: (await getSetting("tools_ocr_enabled", "1")) === "1",
reportsEnabled: isReportsEnabled(), reportsEnabled: await isReportsEnabled(),
usernameChangeEnabled: isUsernameChangeEnabled(), usernameChangeEnabled: await isUsernameChangeEnabled(),
}, },
}); });
} }
@@ -862,38 +865,38 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (!ok) return json({ error: "invalid JSON" }, 400); if (!ok) return json({ error: "invalid JSON" }, 400);
if (body.registrationEnabled !== undefined) { if (body.registrationEnabled !== undefined) {
if (typeof body.registrationEnabled !== "boolean") return json({ error: "registrationEnabled must be boolean" }, 400); if (typeof body.registrationEnabled !== "boolean") return json({ error: "registrationEnabled must be boolean" }, 400);
setSetting("registration_enabled", body.registrationEnabled ? "1" : "0"); await setSetting("registration_enabled", body.registrationEnabled ? "1" : "0");
// Opening registration retires the wishlist automatically. // Opening registration retires the wishlist automatically.
if (body.registrationEnabled) setSetting("registration_request_enabled", "0"); if (body.registrationEnabled) await setSetting("registration_request_enabled", "0");
} }
if (body.accessRequestEnabled !== undefined) { if (body.accessRequestEnabled !== undefined) {
if (typeof body.accessRequestEnabled !== "boolean") return json({ error: "accessRequestEnabled must be boolean" }, 400); if (typeof body.accessRequestEnabled !== "boolean") return json({ error: "accessRequestEnabled must be boolean" }, 400);
// Mutually exclusive with open registration: the wishlist only // Mutually exclusive with open registration: the wishlist only
// makes sense when anyone-can-register is off. // makes sense when anyone-can-register is off.
if (body.accessRequestEnabled && (body.registrationEnabled ?? isRegistrationEnabled())) { if (body.accessRequestEnabled && (body.registrationEnabled ?? await isRegistrationEnabled())) {
return json({ error: "disable public registration first — the wishlist replaces it" }, 409); return json({ error: "disable public registration first — the wishlist replaces it" }, 409);
} }
setSetting("registration_request_enabled", body.accessRequestEnabled ? "1" : "0"); await setSetting("registration_request_enabled", body.accessRequestEnabled ? "1" : "0");
} }
if (body.ocrEnabled !== undefined) { if (body.ocrEnabled !== undefined) {
if (typeof body.ocrEnabled !== "boolean") return json({ error: "ocrEnabled must be boolean" }, 400); if (typeof body.ocrEnabled !== "boolean") return json({ error: "ocrEnabled must be boolean" }, 400);
setSetting("tools_ocr_enabled", body.ocrEnabled ? "1" : "0"); await setSetting("tools_ocr_enabled", body.ocrEnabled ? "1" : "0");
} }
if (body.reportsEnabled !== undefined) { if (body.reportsEnabled !== undefined) {
if (typeof body.reportsEnabled !== "boolean") return json({ error: "reportsEnabled must be boolean" }, 400); if (typeof body.reportsEnabled !== "boolean") return json({ error: "reportsEnabled must be boolean" }, 400);
setSetting("reports_enabled", body.reportsEnabled ? "1" : "0"); await setSetting("reports_enabled", body.reportsEnabled ? "1" : "0");
} }
if (body.usernameChangeEnabled !== undefined) { if (body.usernameChangeEnabled !== undefined) {
if (typeof body.usernameChangeEnabled !== "boolean") return json({ error: "usernameChangeEnabled must be boolean" }, 400); if (typeof body.usernameChangeEnabled !== "boolean") return json({ error: "usernameChangeEnabled must be boolean" }, 400);
setSetting("username_change_enabled", body.usernameChangeEnabled ? "1" : "0"); await setSetting("username_change_enabled", body.usernameChangeEnabled ? "1" : "0");
} }
return json({ return json({
settings: { settings: {
registrationEnabled: isRegistrationEnabled(), registrationEnabled: await isRegistrationEnabled(),
accessRequestEnabled: getSetting("registration_request_enabled", "1") === "1", accessRequestEnabled: (await getSetting("registration_request_enabled", "1")) === "1",
ocrEnabled: getSetting("tools_ocr_enabled", "1") === "1", ocrEnabled: (await getSetting("tools_ocr_enabled", "1")) === "1",
reportsEnabled: isReportsEnabled(), reportsEnabled: await isReportsEnabled(),
usernameChangeEnabled: isUsernameChangeEnabled(), usernameChangeEnabled: await isUsernameChangeEnabled(),
}, },
}); });
} }
@@ -1041,7 +1044,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// --- content reports (flagged AI responses) --- // --- content reports (flagged AI responses) ---
if (path === "/api/reports" && req.method === "POST") { if (path === "/api/reports" && req.method === "POST") {
if (!isReportsEnabled()) return json({ error: "reports are disabled" }, 403); if (!await isReportsEnabled()) return json({ error: "reports are disabled" }, 403);
const { ok, body } = await readJson(req); const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400); if (!ok) return json({ error: "invalid JSON" }, 400);
const reason = body.reason as ReportReason | undefined; const reason = body.reason as ReportReason | undefined;
@@ -1059,7 +1062,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (typeof body.conversationId !== "string" || !body.conversationId) { if (typeof body.conversationId !== "string" || !body.conversationId) {
return json({ error: "bad conversationId" }, 400); return json({ error: "bad conversationId" }, 400);
} }
const conv = getConversation(body.conversationId); const conv = await getConversation(body.conversationId);
if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404); if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404);
conversationId = conv.id; conversationId = conv.id;
if (body.messageIndex !== undefined) { if (body.messageIndex !== undefined) {
@@ -1070,7 +1073,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
} }
} }
try { try {
const report = createReport({ const report = await createReport({
reporterId: user.id, reporterId: user.id,
reporterName: user.displayName, reporterName: user.displayName,
conversationId, conversationId,
@@ -1093,13 +1096,13 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (path === "/api/admin/reports" && req.method === "GET") { if (path === "/api/admin/reports" && req.method === "GET") {
if (!admin) return json({ error: "forbidden" }, 403); if (!admin) return json({ error: "forbidden" }, 403);
return json({ reports: listReports() }); return json({ reports: await listReports() });
} }
const reportMatch = path.match(/^\/api\/admin\/reports\/([0-9a-f-]{36})$/); const reportMatch = path.match(/^\/api\/admin\/reports\/([0-9a-f-]{36})$/);
if (reportMatch) { if (reportMatch) {
if (!admin) return json({ error: "forbidden" }, 403); if (!admin) return json({ error: "forbidden" }, 403);
const target = getReport(reportMatch[1]!); const target = await getReport(reportMatch[1]!);
if (!target) return json({ error: "report not found" }, 404); if (!target) return json({ error: "report not found" }, 404);
if (req.method === "PATCH") { if (req.method === "PATCH") {
const { ok, body } = await readJson(req); const { ok, body } = await readJson(req);
@@ -1110,7 +1113,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
} }
const adminNote = cleanOptionalStr(body.adminNote, 1000); const adminNote = cleanOptionalStr(body.adminNote, 1000);
if (adminNote === null) return json({ error: "adminNote: max 1000 chars" }, 400); if (adminNote === null) return json({ error: "adminNote: max 1000 chars" }, 400);
return json({ report: setReportStatus(target.id, status, adminNote) }); return json({ report: await setReportStatus(target.id, status, adminNote) });
} }
} }
@@ -1122,7 +1125,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
} }
// Full list (policy never hides models from admins) merged with policy. // Full list (policy never hides models from admins) merged with policy.
const models = await registry.listAllModelsCached(); const models = await registry.listAllModelsCached();
const policy = getModelPolicy(); const policy = await getModelPolicy();
return json({ return json({
models: models.map((m) => ({ ...m, ...policyFor(policy, m.id) })), models: models.map((m) => ({ ...m, ...policyFor(policy, m.id) })),
}); });
@@ -1140,7 +1143,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
return json({ error: "nothing to update (enabled, hourly, daily)" }, 400); return json({ error: "nothing to update (enabled, hourly, daily)" }, 400);
} }
try { try {
const entry = setModelPolicyEntry(b.model, { const entry = await setModelPolicyEntry(b.model, {
enabled: b.enabled as boolean | undefined, enabled: b.enabled as boolean | undefined,
hourly: b.hourly as number | undefined, hourly: b.hourly as number | undefined,
daily: b.daily as number | undefined, daily: b.daily as number | undefined,
@@ -1154,23 +1157,23 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// --- admin: access-request wishlist triage --- // --- admin: access-request wishlist triage ---
if (path === "/api/admin/access" && req.method === "GET") { if (path === "/api/admin/access" && req.method === "GET") {
if (!admin) return json({ error: "forbidden" }, 403); if (!admin) return json({ error: "forbidden" }, 403);
return json({ requests: listAccessRequests() }); return json({ requests: await listAccessRequests() });
} }
const accessMatch = path.match(/^\/api\/admin\/access\/([0-9a-f-]{36})(\/message)?$/); const accessMatch = path.match(/^\/api\/admin\/access\/([0-9a-f-]{36})(\/message)?$/);
if (accessMatch) { if (accessMatch) {
if (!admin) return json({ error: "forbidden" }, 403); if (!admin) return json({ error: "forbidden" }, 403);
const target = getAccessRequest(accessMatch[1]!); const target = await getAccessRequest(accessMatch[1]!);
if (!target) return json({ error: "request not found" }, 404); if (!target) return json({ error: "request not found" }, 404);
const reviewUrl = `${config.appUrl}/review/${target.id}`; const reviewUrl = `${config.appUrl}/review/${target.id}`;
if (req.method === "GET" && !accessMatch[2]) { if (req.method === "GET" && !accessMatch[2]) {
return json({ request: target, messages: listAccessMessages(target.id) }); return json({ request: target, messages: await listAccessMessages(target.id) });
} }
if (req.method === "POST" && accessMatch[2] === "/message") { if (req.method === "POST" && accessMatch[2] === "/message") {
const { ok, body } = await readJson(req); const { ok, body } = await readJson(req);
const text = ok ? cleanStr(body.body, 2000) : null; const text = ok ? cleanStr(body.body, 2000) : null;
if (!text) return json({ error: "message: 1-2000 chars" }, 400); if (!text) return json({ error: "message: 1-2000 chars" }, 400);
const msg = addAccessMessage(target.id, "admin", text); const msg = await addAccessMessage(target.id, "admin", text);
// Every admin reply is forwarded to the requester (notification-only). // Every admin reply is forwarded to the requester (notification-only).
sendAccessMessageEmail(target.email, target.username, true, text, reviewUrl).catch(() => {}); sendAccessMessageEmail(target.email, target.username, true, text, reviewUrl).catch(() => {});
broadcastAccessLive({ type: "access_message", request_id: target.id, message: msg }); broadcastAccessLive({ type: "access_message", request_id: target.id, message: msg });
@@ -1190,11 +1193,11 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
} }
let key: string | undefined; let key: string | undefined;
if (status === "accepted" && target.status !== "accepted") { if (status === "accepted" && target.status !== "accepted") {
if (getUserByUsername(target.username)) return json({ error: "username taken" }, 409); if (await getUserByUsername(target.username)) return json({ error: "username taken" }, 409);
key = newAccessKey(); key = newAccessKey();
createUser({ username: target.username, displayName: target.username, avatarUrl: "", theme: "auto", email: target.email, keyHash: hashSecret(key) }); await createUser({ username: target.username, displayName: target.username, avatarUrl: "", theme: "auto", email: target.email, keyHash: hashSecret(key) });
} }
const updated = setAccessStatus(target.id, status, reason); const updated = await setAccessStatus(target.id, status, reason);
if (status !== target.status && status !== "pending") { if (status !== target.status && status !== "pending") {
sendAccessStatusEmail(target.email, target.username, status, reason, reviewUrl, key).catch(() => {}); sendAccessStatusEmail(target.email, target.username, status, reason, reviewUrl, key).catch(() => {});
} }
@@ -1242,28 +1245,34 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// --- conversations (server-persisted, per account) --- // --- conversations (server-persisted, per account) ---
if (path === "/api/conversations" && req.method === "GET") { if (path === "/api/conversations" && req.method === "GET") {
return json({ conversations: listConversations(user.id) }); return json({ conversations: await listConversations(user.id) });
} }
// Archived chats (hidden from the sidebar, managed in settings). // Archived chats (hidden from the sidebar, managed in settings).
if (path === "/api/conversations/archived" && req.method === "GET") { if (path === "/api/conversations/archived" && req.method === "GET") {
return json({ conversations: listArchivedConversations(user.id) }); return json({ conversations: await listArchivedConversations(user.id) });
} }
// Full-text-ish search over your own chats: titles, topics AND old // Full-text-ish search over your own chats: titles, topics AND old
// prompts/replies. Returns the matching conversations, most recent // prompts/replies. Returns the matching conversations, most recent
// first, with a short snippet of the first hit. // first, with a short snippet of the first hit.
// Correlated subqueries (not GROUP BY c.id + bare m.* columns): the
// loose form runs on SQLite but Postgres rejects it.
if (path === "/api/conversations/search" && req.method === "GET") { if (path === "/api/conversations/search" && req.method === "GET") {
const q = (url.searchParams.get("q") ?? "").trim().slice(0, 120); const q = (url.searchParams.get("q") ?? "").trim().slice(0, 120);
if (q.length < 2) return json({ conversations: [] }); if (q.length < 2) return json({ conversations: [] });
const like = `%${q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`; const like = `%${q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
const rows = getDb().query( const rows = await db().all<ConversationRow & { hit: string | null; hit_role: string | null }>(
`SELECT c.*, m.content AS hit, m.role AS hit_role FROM conversations c `SELECT c.*,
LEFT JOIN messages m ON m.conv_id = c.id (SELECT m.content FROM messages m WHERE m.conv_id = c.id AND m.content LIKE ? ESCAPE '\\' ORDER BY m.id ASC LIMIT 1) AS hit,
AND m.content LIKE ? ESCAPE '\\' (SELECT m.role FROM messages m WHERE m.conv_id = c.id AND m.content LIKE ? ESCAPE '\\' ORDER BY m.id ASC LIMIT 1) AS hit_role
WHERE c.user_id = ? AND c.archived_at = 0 AND (c.title LIKE ? ESCAPE '\\' OR c.topic LIKE ? ESCAPE '\\' OR m.id IS NOT NULL) FROM conversations c
GROUP BY c.id ORDER BY c.updated_at DESC LIMIT 30`, WHERE c.user_id = ? AND c.archived_at = 0
).all(like, user.id, like, like) as (ConversationRow & { hit: string | null; hit_role: string | null })[]; AND (c.title LIKE ? ESCAPE '\\' OR c.topic LIKE ? ESCAPE '\\'
OR EXISTS (SELECT 1 FROM messages m WHERE m.conv_id = c.id AND m.content LIKE ? ESCAPE '\\'))
ORDER BY c.updated_at DESC LIMIT 30`,
like, like, user.id, like, like, like,
);
return json({ return json({
conversations: rows.map(({ hit, hit_role, ...c }) => { conversations: rows.map(({ hit, hit_role, ...c }) => {
let snippet: string | null = null; let snippet: string | null = null;
@@ -1284,7 +1293,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (path === "/api/conversations" && req.method === "POST") { if (path === "/api/conversations" && req.method === "POST") {
const { ok, body } = await readJson(req); const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400); if (!ok) return json({ error: "invalid JSON" }, 400);
const conv = createConversation(user.id, { const conv = await createConversation(user.id, {
title: typeof body.title === "string" ? body.title : undefined, title: typeof body.title === "string" ? body.title : undefined,
topic: typeof body.topic === "string" ? body.topic : undefined, topic: typeof body.topic === "string" ? body.topic : undefined,
model: typeof body.model === "string" ? body.model : undefined, model: typeof body.model === "string" ? body.model : undefined,
@@ -1294,35 +1303,35 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const archiveMatch = path.match(/^\/api\/conversations\/([0-9a-f-]{10,50})\/(archive|unarchive)$/); const archiveMatch = path.match(/^\/api\/conversations\/([0-9a-f-]{10,50})\/(archive|unarchive)$/);
if (archiveMatch && req.method === "POST") { if (archiveMatch && req.method === "POST") {
const conv = getConversation(archiveMatch[1]!); const conv = await getConversation(archiveMatch[1]!);
if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404); if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404);
return json({ conversation: setConversationArchived(conv.id, archiveMatch[2] === "archive") }); return json({ conversation: await setConversationArchived(conv.id, archiveMatch[2] === "archive") });
} }
const convMatch = path.match(/^\/api\/conversations\/([0-9a-f-]{10,50})(\/(share))?$/); const convMatch = path.match(/^\/api\/conversations\/([0-9a-f-]{10,50})(\/(share))?$/);
if (convMatch) { if (convMatch) {
const conv = getConversation(convMatch[1]!); const conv = await getConversation(convMatch[1]!);
if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404); if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404);
const isShare = convMatch[3] === "share"; const isShare = convMatch[3] === "share";
if (isShare) { if (isShare) {
if (req.method === "POST") { if (req.method === "POST") {
const publicId = ensureShare(conv.id); const publicId = await ensureShare(conv.id);
return json({ publicId, url: `/share/${publicId}` }, 201); return json({ publicId, url: `/share/${publicId}` }, 201);
} }
if (req.method === "GET") { if (req.method === "GET") {
const publicId = getShareByConv(conv.id); const publicId = await getShareByConv(conv.id);
return publicId ? json({ publicId, url: `/share/${publicId}` }) : json({ shared: false }); return publicId ? json({ publicId, url: `/share/${publicId}` }) : json({ shared: false });
} }
if (req.method === "DELETE") { if (req.method === "DELETE") {
deleteShare(conv.id); await deleteShare(conv.id);
return json({ ok: true }); return json({ ok: true });
} }
return json({ error: "not found" }, 404); return json({ error: "not found" }, 404);
} }
if (req.method === "GET") { if (req.method === "GET") {
return json({ conversation: conv, messages: getMessages(conv.id) }); return json({ conversation: conv, messages: await getMessages(conv.id) });
} }
if (req.method === "PATCH") { if (req.method === "PATCH") {
const { ok, body } = await readJson(req); const { ok, body } = await readJson(req);
@@ -1340,10 +1349,10 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (typeof body.model !== "string" || body.model.length > 160) return json({ error: "model too long" }, 400); if (typeof body.model !== "string" || body.model.length > 160) return json({ error: "model too long" }, 400);
patch.model = body.model; patch.model = body.model;
} }
return json({ conversation: updateConversation(conv.id, patch) }); return json({ conversation: await updateConversation(conv.id, patch) });
} }
if (req.method === "DELETE") { if (req.method === "DELETE") {
deleteConversation(conv.id); await deleteConversation(conv.id);
return json({ ok: true }); return json({ ok: true });
} }
return json({ error: "not found" }, 404); return json({ error: "not found" }, 404);
@@ -1378,18 +1387,22 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (admin) return json({ models }); if (admin) return json({ models });
// Users never see admin-disabled models (nor select them — /api/chat // Users never see admin-disabled models (nor select them — /api/chat
// enforces the same policy server-side). // enforces the same policy server-side).
const policy = getModelPolicy(); const policy = await getModelPolicy();
return json({ models: models.filter((m) => policyFor(policy, m.id).enabled) }); return json({ models: models.filter((m) => policyFor(policy, m.id).enabled) });
} }
// --- platform tools (uploads always go through tools) --- // --- platform tools (uploads always go through tools) ---
if (path === "/api/tools" && req.method === "GET") { if (path === "/api/tools" && req.method === "GET") {
return json({ tools: tools.list(), reportsEnabled: isReportsEnabled(), usernameChangeEnabled: isUsernameChangeEnabled() }); return json({
tools: await tools.list(),
reportsEnabled: await isReportsEnabled(),
usernameChangeEnabled: await isUsernameChangeEnabled(),
});
} }
if (path === "/api/tools/ocr" && req.method === "POST") { if (path === "/api/tools/ocr" && req.method === "POST") {
const ocr = tools.ocr; const ocr = tools.ocr;
if (!ocr.isAvailable()) return json({ error: ocr.unavailableReason() ?? "ocr unavailable" }, 501); if (!await ocr.isAvailable()) return json({ error: (await ocr.unavailableReason()) ?? "ocr unavailable" }, 501);
const declared = Number(req.headers.get("content-length") || "0"); const declared = Number(req.headers.get("content-length") || "0");
if (declared > OCR_MAX_BYTES) return json({ error: "image too large (max 5MB)" }, 413); if (declared > OCR_MAX_BYTES) return json({ error: "image too large (max 5MB)" }, 413);
let buf: Uint8Array; let buf: Uint8Array;
@@ -1430,7 +1443,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// BYOK precedence: when the caller connected this provider with // BYOK precedence: when the caller connected this provider with
// their own key, the request is billed to them, not the platform. // their own key, the request is billed to them, not the platform.
if (isUserProviderId(driver.name)) { if (isUserProviderId(driver.name)) {
const personalKey = getUserProviderKey(user.id, driver.name); const personalKey = await getUserProviderKey(user.id, driver.name);
if (personalKey) driver = buildUserProviderDriver(driver.name, personalKey); if (personalKey) driver = buildUserProviderDriver(driver.name, personalKey);
} }
} catch (e) { } catch (e) {
@@ -1441,7 +1454,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const pname = idx === -1 ? "" : fullId.slice(0, idx); const pname = idx === -1 ? "" : fullId.slice(0, idx);
const sub = idx === -1 ? "" : fullId.slice(idx + 1); const sub = idx === -1 ? "" : fullId.slice(idx + 1);
if (!isUserProviderId(pname)) return json({ error: (e as Error).message }, 400); if (!isUserProviderId(pname)) return json({ error: (e as Error).message }, 400);
const personalKey = getUserProviderKey(user.id, pname); const personalKey = await getUserProviderKey(user.id, pname);
if (!personalKey || !sub) return json({ error: (e as Error).message }, 400); if (!personalKey || !sub) return json({ error: (e as Error).message }, 400);
driver = buildUserProviderDriver(pname, personalKey); driver = buildUserProviderDriver(pname, personalKey);
model = sub; model = sub;
@@ -1449,43 +1462,43 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// Per-model access policy (kill-switch + per-user rate limits). // Per-model access policy (kill-switch + per-user rate limits).
// Admins bypass; attempts count even when generation later fails. // Admins bypass; attempts count even when generation later fails.
if (!user.isAdmin) { if (!user.isAdmin) {
const access = checkModelAccess(user.id, body.model); const access = await checkModelAccess(user.id, body.model);
if (!access.ok) return json({ error: access.message }, access.code); if (!access.ok) return json({ error: access.message }, access.code);
recordModelUse(user.id, body.model); await recordModelUse(user.id, body.model);
} }
const messages = body.messages as ChatMessage[]; const messages = body.messages as ChatMessage[];
// Optional persistence into a server-side conversation (must belong to the user). // Optional persistence into a server-side conversation (must belong to the user).
let conv = null; let conv = null;
if (body.conversationId !== undefined) { if (body.conversationId !== undefined) {
if (typeof body.conversationId !== "string") return json({ error: "bad conversationId" }, 400); if (typeof body.conversationId !== "string") return json({ error: "bad conversationId" }, 400);
conv = getConversation(body.conversationId); conv = await getConversation(body.conversationId);
if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404); if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404);
// Archived chats are read-only: no new messages may be written. // Archived chats are read-only: no new messages may be written.
if (conv.archived_at) return json({ error: "conversation is archived (read-only) — unarchive it to write again" }, 403); if (conv.archived_at) return json({ error: "conversation is archived (read-only) — unarchive it to write again" }, 403);
// Persist the new user message (dedupe against last stored one). // Persist the new user message (dedupe against last stored one).
const lastUser = [...messages].reverse().find((m) => m.role === "user"); const lastUser = [...messages].reverse().find((m) => m.role === "user");
const stored = getMessages(conv.id); const stored = await getMessages(conv.id);
const lastStored = stored[stored.length - 1]; const lastStored = stored[stored.length - 1];
if (lastUser && (!lastStored || lastStored.content !== lastUser.content || lastStored.role !== "user")) { if (lastUser && (!lastStored || lastStored.content !== lastUser.content || lastStored.role !== "user")) {
addMessage(conv.id, "user", lastUser.content); await addMessage(conv.id, "user", lastUser.content);
if (conv.title === "New chat") { if (conv.title === "New chat") {
updateConversation(conv.id, { title: lastUser.content.slice(0, 50) || "New chat" }); await updateConversation(conv.id, { title: lastUser.content.slice(0, 50) || "New chat" });
} }
} }
} }
const wantStream = body.stream === true || req.headers.get("accept") === "text/event-stream"; const wantStream = body.stream === true || req.headers.get("accept") === "text/event-stream";
const persistReply = (text: string) => { const persistReply = async (text: string): Promise<void> => {
if (conv && text) { if (conv && text) {
addMessage(conv.id, "assistant", text); await addMessage(conv.id, "assistant", text);
touchConversation(conv.id, body.model as string); await touchConversation(conv.id, body.model as string);
} }
}; };
if (!wantStream) { if (!wantStream) {
try { try {
const text = await driver.chat(messages, { model, signal: req.signal }); const text = await driver.chat(messages, { model, signal: req.signal });
persistReply(text); await persistReply(text);
return json({ model: body.model, message: { role: "assistant", content: text } }); return json({ model: body.model, message: { role: "assistant", content: text } });
} catch (e) { } catch (e) {
return json({ error: (e as Error).message }, 502); return json({ error: (e as Error).message }, 502);
@@ -1523,12 +1536,12 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
full += token; full += token;
if (!safeSend("token", JSON.stringify({ token }))) break; if (!safeSend("token", JSON.stringify({ token }))) break;
} }
persistReply(full); await persistReply(full);
safeSend("done", "{}"); safeSend("done", "{}");
} catch (e) { } catch (e) {
// Genuine driver error: keep the partial text as-is (no internal // Genuine driver error: keep the partial text as-is (no internal
// markers persisted) and notify the client if still connected. // markers persisted) and notify the client if still connected.
if (full) persistReply(full); if (full) await persistReply(full);
if (!req.signal.aborted && !clientGone) { if (!req.signal.aborted && !clientGone) {
safeSend("error", JSON.stringify({ error: (e as Error).message })); safeSend("error", JSON.stringify({ error: (e as Error).message }));
} }
+31 -28
View File
@@ -1,4 +1,4 @@
import { getDb, getSetting, setSetting } from "./db.ts"; import { db, getSetting, setSetting } from "./db.ts";
/** /**
* Per-model access policy (admin kill-switch + per-user rate limits). * Per-model access policy (admin kill-switch + per-user rate limits).
@@ -41,8 +41,8 @@ function isDefault(e: ModelPolicyEntry): boolean {
return e.enabled && e.hourly === 0 && e.daily === 0; return e.enabled && e.hourly === 0 && e.daily === 0;
} }
export function getModelPolicy(): ModelPolicy { export async function getModelPolicy(): Promise<ModelPolicy> {
const raw = getSetting(SETTINGS_KEY, ""); const raw = await getSetting(SETTINGS_KEY, "");
if (!raw) return {}; if (!raw) return {};
try { try {
const obj = JSON.parse(raw) as Record<string, unknown>; const obj = JSON.parse(raw) as Record<string, unknown>;
@@ -61,14 +61,14 @@ export function policyFor(policy: ModelPolicy, modelId: string): ModelPolicyEntr
return policy[modelId] ?? { ...DEFAULT_POLICY }; return policy[modelId] ?? { ...DEFAULT_POLICY };
} }
export function setModelPolicyEntry( export async function setModelPolicyEntry(
modelId: string, modelId: string,
patch: { enabled?: boolean; hourly?: number; daily?: number }, patch: { enabled?: boolean; hourly?: number; daily?: number },
): ModelPolicyEntry { ): Promise<ModelPolicyEntry> {
if (patch.enabled !== undefined && typeof patch.enabled !== "boolean") throw new Error("BAD_POLICY"); if (patch.enabled !== undefined && typeof patch.enabled !== "boolean") throw new Error("BAD_POLICY");
if (patch.hourly !== undefined && !Number.isInteger(patch.hourly)) throw new Error("BAD_POLICY"); if (patch.hourly !== undefined && !Number.isInteger(patch.hourly)) throw new Error("BAD_POLICY");
if (patch.daily !== undefined && !Number.isInteger(patch.daily)) throw new Error("BAD_POLICY"); if (patch.daily !== undefined && !Number.isInteger(patch.daily)) throw new Error("BAD_POLICY");
const policy = getModelPolicy(); const policy = await getModelPolicy();
const cur = policy[modelId] ?? { ...DEFAULT_POLICY }; const cur = policy[modelId] ?? { ...DEFAULT_POLICY };
const next: ModelPolicyEntry = { const next: ModelPolicyEntry = {
enabled: patch.enabled ?? cur.enabled, enabled: patch.enabled ?? cur.enabled,
@@ -80,53 +80,56 @@ export function setModelPolicyEntry(
} }
if (isDefault(next)) delete policy[modelId]; if (isDefault(next)) delete policy[modelId];
else policy[modelId] = next; else policy[modelId] = next;
setSetting(SETTINGS_KEY, JSON.stringify(policy)); await setSetting(SETTINGS_KEY, JSON.stringify(policy));
return { ...DEFAULT_POLICY, ...policy[modelId] }; return { ...DEFAULT_POLICY, ...policy[modelId] };
} }
export function initModelUsageTables(): void { export async function initModelUsageTables(): Promise<void> {
getDb().query(`CREATE TABLE IF NOT EXISTS model_usage ( // "window" is quoted: WINDOW is a reserved keyword on Postgres (and the
// quotes are harmless on SQLite).
await db().exec(`CREATE TABLE IF NOT EXISTS model_usage (
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
model TEXT NOT NULL, model TEXT NOT NULL,
window TEXT NOT NULL, "window" TEXT NOT NULL,
bucket INTEGER NOT NULL, bucket BIGINT NOT NULL,
count INTEGER NOT NULL DEFAULT 0, count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, model, window, bucket) PRIMARY KEY (user_id, model, "window", bucket)
)`).run(); )`);
} }
function buckets(now = Date.now()): { h: number; d: number } { function buckets(now = Date.now()): { h: number; d: number } {
return { h: Math.floor(now / 3600_000), d: Math.floor(now / 86400_000) }; return { h: Math.floor(now / 3600_000), d: Math.floor(now / 86400_000) };
} }
function windowCount(userId: string, modelId: string, window: "h" | "d", bucket: number): number { async function windowCount(userId: string, modelId: string, window: "h" | "d", bucket: number): Promise<number> {
const row = getDb().query( const row = await db().get<{ n: number }>(
"SELECT count AS n FROM model_usage WHERE user_id = ? AND model = ? AND window = ? AND bucket = ?", 'SELECT count AS n FROM model_usage WHERE user_id = ? AND model = ? AND "window" = ? AND bucket = ?',
).get(userId, modelId, window, bucket) as { n: number } | null; userId, modelId, window, bucket,
);
return row?.n ?? 0; return row?.n ?? 0;
} }
export type ModelAccess = { ok: true } | { ok: false; code: 403 | 429; message: string }; export type ModelAccess = { ok: true } | { ok: false; code: 403 | 429; message: string };
export function checkModelAccess(userId: string, modelId: string): ModelAccess { export async function checkModelAccess(userId: string, modelId: string): Promise<ModelAccess> {
const p = policyFor(getModelPolicy(), modelId); const p = policyFor(await getModelPolicy(), modelId);
if (!p.enabled) return { ok: false, code: 403, message: "model disabled by administrator" }; if (!p.enabled) return { ok: false, code: 403, message: "model disabled by administrator" };
const { h, d } = buckets(); const { h, d } = buckets();
if (p.hourly > 0 && windowCount(userId, modelId, "h", h) >= p.hourly) { if (p.hourly > 0 && (await windowCount(userId, modelId, "h", h)) >= p.hourly) {
return { ok: false, code: 429, message: "hourly limit reached for this model, try again later" }; return { ok: false, code: 429, message: "hourly limit reached for this model, try again later" };
} }
if (p.daily > 0 && windowCount(userId, modelId, "d", d) >= p.daily) { if (p.daily > 0 && (await windowCount(userId, modelId, "d", d)) >= p.daily) {
return { ok: false, code: 429, message: "daily limit reached for this model, try again tomorrow" }; return { ok: false, code: 429, message: "daily limit reached for this model, try again tomorrow" };
} }
return { ok: true }; return { ok: true };
} }
export function recordModelUse(userId: string, modelId: string): void { export async function recordModelUse(userId: string, modelId: string): Promise<void> {
const d = getDb(); const d = db();
const { h, d: day } = buckets(); const { h, d: day } = buckets();
d.query(`INSERT INTO model_usage (user_id, model, window, bucket, count) VALUES (?, ?, 'h', ?, 1) await d.run(`INSERT INTO model_usage (user_id, model, "window", bucket, count) VALUES (?, ?, 'h', ?, 1)
ON CONFLICT(user_id, model, window, bucket) DO UPDATE SET count = count + 1`).run(userId, modelId, h); ON CONFLICT(user_id, model, "window", bucket) DO UPDATE SET count = count + 1`, userId, modelId, h);
d.query(`INSERT INTO model_usage (user_id, model, window, bucket, count) VALUES (?, ?, 'd', ?, 1) await d.run(`INSERT INTO model_usage (user_id, model, "window", bucket, count) VALUES (?, ?, 'd', ?, 1)
ON CONFLICT(user_id, model, window, bucket) DO UPDATE SET count = count + 1`).run(userId, modelId, day); ON CONFLICT(user_id, model, "window", bucket) DO UPDATE SET count = count + 1`, userId, modelId, day);
d.query("DELETE FROM model_usage WHERE (window = 'h' AND bucket < ?) OR (window = 'd' AND bucket < ?)").run(h - 1, day - 1); await d.run('DELETE FROM model_usage WHERE ("window" = \'h\' AND bucket < ?) OR ("window" = \'d\' AND bucket < ?)', h - 1, day - 1);
} }
+47 -40
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { getConversation, getDb, getMessages } from "./db.ts"; import { getConversation, getMessages, db } from "./db.ts";
/** /**
* Content reports (user-flagged AI responses). Tables are created * Content reports (user-flagged AI responses). Tables are created
@@ -38,14 +38,14 @@ export interface Report {
updated_at: number; updated_at: number;
} }
export function initReportsTables(): void { export async function initReportsTables(): Promise<void> {
const d = getDb(); const d = db();
d.query(`CREATE TABLE IF NOT EXISTS reports ( await d.exec(`CREATE TABLE IF NOT EXISTS reports (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
reporter_id TEXT NOT NULL, reporter_id TEXT NOT NULL,
reporter_name TEXT NOT NULL DEFAULT '', reporter_name TEXT NOT NULL DEFAULT '',
conversation_id TEXT NOT NULL DEFAULT '', conversation_id TEXT NOT NULL DEFAULT '',
message_index INTEGER NOT NULL DEFAULT -1, message_index BIGINT NOT NULL DEFAULT -1,
content TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '',
prompt TEXT NOT NULL DEFAULT '', prompt TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '', model TEXT NOT NULL DEFAULT '',
@@ -53,21 +53,27 @@ export function initReportsTables(): void {
details TEXT NOT NULL DEFAULT '', details TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open', status TEXT NOT NULL DEFAULT 'open',
admin_note TEXT NOT NULL DEFAULT '', admin_note TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL, created_at BIGINT NOT NULL,
updated_at INTEGER NOT NULL updated_at BIGINT NOT NULL
)`).run(); )`);
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status, updated_at DESC)`).run(); await d.exec(`CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status, updated_at DESC)`);
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_reporter ON reports(reporter_id, created_at DESC)`).run(); await d.exec(`CREATE INDEX IF NOT EXISTS idx_reports_reporter ON reports(reporter_id, created_at DESC)`);
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_message ON reports(conversation_id, message_index)`).run(); await d.exec(`CREATE INDEX IF NOT EXISTS idx_reports_message ON reports(conversation_id, message_index)`);
if (d.kind === "sqlite") {
// Prompt snapshot added after the initial schema — keep idempotent. // Prompt snapshot added after the initial schema — keep idempotent.
const rcols = d.query("PRAGMA table_info(reports)").all() as { name: string }[]; const rcols = await d.all<{ name: string }>("PRAGMA table_info(reports)");
if (!rcols.some((c) => c.name === "prompt")) { if (!rcols.some((c) => c.name === "prompt")) {
d.query("ALTER TABLE reports ADD COLUMN prompt TEXT NOT NULL DEFAULT ''").run(); await d.exec("ALTER TABLE reports ADD COLUMN prompt TEXT NOT NULL DEFAULT ''");
} }
// Per-user report shadow-ban (unreliable reporters) — keep idempotent. // Per-user report shadow-ban (unreliable reporters) — keep idempotent.
const ucols = d.query("PRAGMA table_info(users)").all() as { name: string }[]; const ucols = await d.all<{ name: string }>("PRAGMA table_info(users)");
if (!ucols.some((c) => c.name === "report_shadowbanned")) { if (!ucols.some((c) => c.name === "report_shadowbanned")) {
d.query("ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0").run(); await d.exec("ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0");
}
} else {
// Postgres: ADD COLUMN IF NOT EXISTS is natively idempotent.
await d.exec("ALTER TABLE reports ADD COLUMN IF NOT EXISTS prompt TEXT NOT NULL DEFAULT ''");
await d.exec("ALTER TABLE users ADD COLUMN IF NOT EXISTS report_shadowbanned INTEGER NOT NULL DEFAULT 0");
} }
} }
@@ -75,14 +81,15 @@ export function initReportsTables(): void {
export const MAX_OPEN_REPORTS = 20; export const MAX_OPEN_REPORTS = 20;
/** Still-triageable report already covering this message, if any. */ /** Still-triageable report already covering this message, if any. */
export function findOpenReport(conversationId: string, messageIndex: number): Report | null { export async function findOpenReport(conversationId: string, messageIndex: number): Promise<Report | null> {
if (!conversationId || messageIndex < 0) return null; if (!conversationId || messageIndex < 0) return null;
return getDb().query( return db().get<Report>(
"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", "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; conversationId, messageIndex,
);
} }
export function createReport(opts: { export async function createReport(opts: {
reporterId: string; reporterId: string;
reporterName: string; reporterName: string;
conversationId: string; conversationId: string;
@@ -92,28 +99,28 @@ export function createReport(opts: {
clientContent: string; clientContent: string;
clientPrompt: string; clientPrompt: string;
clientModel: string; clientModel: string;
}): Report { }): Promise<Report> {
const d = getDb();
const dup = opts.conversationId && opts.messageIndex >= 0 const dup = opts.conversationId && opts.messageIndex >= 0
? findOpenReport(opts.conversationId, opts.messageIndex) ? await findOpenReport(opts.conversationId, opts.messageIndex)
: null; : null;
if (dup) { if (dup) {
const err = new Error("ALREADY_REPORTED") as Error & { report: Report }; const err = new Error("ALREADY_REPORTED") as Error & { report: Report };
err.report = dup; err.report = dup;
throw err; throw err;
} }
const open = d.query( const open = await db().get<{ n: number }>(
"SELECT COUNT(*) AS n FROM reports WHERE reporter_id = ? AND status IN ('open','reviewing')", "SELECT COUNT(*) AS n FROM reports WHERE reporter_id = ? AND status IN ('open','reviewing')",
).get(opts.reporterId) as { n: number }; opts.reporterId,
if (open.n >= MAX_OPEN_REPORTS) throw new Error("TOO_MANY_OPEN"); );
if ((open?.n ?? 0) >= MAX_OPEN_REPORTS) throw new Error("TOO_MANY_OPEN");
let content = opts.clientContent; let content = opts.clientContent;
let prompt = opts.clientPrompt; let prompt = opts.clientPrompt;
let model = opts.clientModel; let model = opts.clientModel;
if (opts.conversationId) { if (opts.conversationId) {
const conv = getConversation(opts.conversationId); const conv = await getConversation(opts.conversationId);
if (conv && conv.user_id === opts.reporterId) { if (conv && conv.user_id === opts.reporterId) {
if (conv.model) model = conv.model; if (conv.model) model = conv.model;
const msgs = getMessages(conv.id).filter((m) => m.role !== "system"); const msgs = (await getMessages(conv.id)).filter((m) => m.role !== "system");
if (opts.messageIndex >= 0) { if (opts.messageIndex >= 0) {
const at = msgs[opts.messageIndex]; const at = msgs[opts.messageIndex];
if (at) content = at.content; if (at) content = at.content;
@@ -140,9 +147,8 @@ export function createReport(opts: {
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
d.query( await db().run(
"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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "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.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.content, row.prompt, row.model, row.reason, row.details, row.status, row.admin_note,
row.created_at, row.updated_at, row.created_at, row.updated_at,
@@ -150,20 +156,21 @@ export function createReport(opts: {
return row; return row;
} }
export function listReports(): Report[] { export async function listReports(): Promise<Report[]> {
return getDb().query( return db().all<Report>(
"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", "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 { export async function getReport(id: string): Promise<Report | null> {
return getDb().query( return db().get<Report>(
"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 = ?", "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; id,
);
} }
export function setReportStatus(id: string, status: ReportStatus, adminNote: string): Report | null { export async function setReportStatus(id: string, status: ReportStatus, adminNote: string): Promise<Report | null> {
getDb().query("UPDATE reports SET status = ?, admin_note = ?, updated_at = ? WHERE id = ?").run( await db().run("UPDATE reports SET status = ?, admin_note = ?, updated_at = ? WHERE id = ?",
status, adminNote, Date.now(), id, status, adminNote, Date.now(), id,
); );
return getReport(id); return getReport(id);
@@ -173,8 +180,8 @@ export function setReportStatus(id: string, status: ReportStatus, adminNote: str
* Shadow-ban a reporter: their reports are still accepted but flagged * Shadow-ban a reporter: their reports are still accepted but flagged
* unreliable (excluded from the admin badge). Returns the new flag. * unreliable (excluded from the admin badge). Returns the new flag.
*/ */
export function setReportShadowbanned(userId: string, shadowbanned: boolean): number { export async function setReportShadowbanned(userId: string, shadowbanned: boolean): Promise<number> {
getDb().query("UPDATE users SET report_shadowbanned = ? WHERE id = ?").run(shadowbanned ? 1 : 0, userId); await db().run("UPDATE users SET report_shadowbanned = ? WHERE id = ?", shadowbanned ? 1 : 0, userId);
const row = getDb().query("SELECT report_shadowbanned AS v FROM users WHERE id = ?").get(userId) as { v: number } | null; const row = await db().get<{ v: number }>("SELECT report_shadowbanned AS v FROM users WHERE id = ?", userId);
return row?.v ?? 0; return row?.v ?? 0;
} }
+5 -5
View File
@@ -62,20 +62,20 @@ export class OcrTool implements PlatformTool {
this.available = availableCache; this.available = availableCache;
} }
isAvailable(): boolean { async isAvailable(): Promise<boolean> {
return (this.available ?? false) && isOcrToolEnabled(); return (this.available ?? false) && (await isOcrToolEnabled());
} }
unavailableReason(): string | null { async unavailableReason(): Promise<string | null> {
if (!(this.available ?? false)) { if (!(this.available ?? false)) {
return `tesseract binary not found (looked for "${tessBin()}"). Install tesseract or set TESSERACT_BIN.`; return `tesseract binary not found (looked for "${tessBin()}"). Install tesseract or set TESSERACT_BIN.`;
} }
if (!isOcrToolEnabled()) return "OCR is disabled by the administrator (Admin Center → Features)."; if (!(await isOcrToolEnabled())) return "OCR is disabled by the administrator (Admin Center → Features).";
return null; return null;
} }
async transcribe(image: Uint8Array): Promise<{ text: string; truncated: boolean }> { async transcribe(image: Uint8Array): Promise<{ text: string; truncated: boolean }> {
if (!this.isAvailable()) throw new Error(this.unavailableReason() ?? "ocr unavailable"); if (!(await this.isAvailable())) throw new Error((await this.unavailableReason()) ?? "ocr unavailable");
const t0 = Date.now(); const t0 = Date.now();
const maxChars = config.ocrMaxChars; const maxChars = config.ocrMaxChars;
let proc; let proc;
+7 -5
View File
@@ -10,13 +10,15 @@ class ToolRegistry {
await this.ocr.init(); await this.ocr.init();
} }
list(): { name: string; description: string; available: boolean; reason: string | null }[] { async list(): Promise<{ name: string; description: string; available: boolean; reason: string | null }[]> {
return this.tools.map((t) => ({ return Promise.all(
this.tools.map(async (t) => ({
name: t.name, name: t.name,
description: t.description, description: t.description,
available: t.isAvailable(), available: await t.isAvailable(),
reason: t.unavailableReason(), reason: await t.unavailableReason(),
})); })),
);
} }
} }
+4 -3
View File
@@ -1,8 +1,9 @@
/** Platform tool: a server-side capability the frontend invokes explicitly /** Platform tool: a server-side capability the frontend invokes explicitly
* (uploads always go through tools — never raw to the model). */ * (uploads always go through tools — never raw to the model). Availability
* is async: feature flags live in the database now. */
export interface PlatformTool { export interface PlatformTool {
readonly name: string; readonly name: string;
readonly description: string; readonly description: string;
isAvailable(): boolean; isAvailable(): Promise<boolean>;
unavailableReason(): string | null; unavailableReason(): Promise<string | null>;
} }
+30 -21
View File
@@ -1,5 +1,5 @@
import { createHash, createHmac, randomBytes } from "node:crypto"; import { createHash, createHmac, randomBytes } from "node:crypto";
import { getDb } from "./db.ts"; import { db } from "./db.ts";
/** /**
* TOTP two-factor (RFC 6238, SHA-1, 30s step, 6 digits, ±1 window). * TOTP two-factor (RFC 6238, SHA-1, 30s step, 6 digits, ±1 window).
@@ -13,19 +13,23 @@ const DIGITS = 6;
const WINDOW = 1; const WINDOW = 1;
const CHALLENGE_TTL_MS = 5 * 60_000; const CHALLENGE_TTL_MS = 5 * 60_000;
export function initTotpTables(): void { export async function initTotpTables(): Promise<void> {
const d = getDb(); const d = db();
if (d.kind === "sqlite") {
try { try {
d.query("ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''").run(); await d.exec("ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''");
} catch { } catch {
// column already exists — fine // column already exists — fine
} }
d.query(`CREATE TABLE IF NOT EXISTS totp_challenges ( } else {
await d.exec("ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret TEXT NOT NULL DEFAULT ''");
}
await d.exec(`CREATE TABLE IF NOT EXISTS totp_challenges (
token_hash TEXT PRIMARY KEY, token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
created_at INTEGER NOT NULL, created_at BIGINT NOT NULL,
expires_at INTEGER NOT NULL expires_at BIGINT NOT NULL
)`).run(); )`);
} }
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
@@ -104,36 +108,41 @@ export function verifyTotp(secret: string, code: string): boolean {
return ok; return ok;
} }
export function getTotpSecret(userId: string): string { export async function getTotpSecret(userId: string): Promise<string> {
const row = getDb().query("SELECT totp_secret AS s FROM users WHERE id = ?").get(userId) as { s: string } | null; const row = await db().get<{ s: string }>("SELECT totp_secret AS s FROM users WHERE id = ?", userId);
return row?.s ?? ""; return row?.s ?? "";
} }
export function setTotpSecret(userId: string, secret: string): void { export async function setTotpSecret(userId: string, secret: string): Promise<void> {
getDb().query("UPDATE users SET totp_secret = ? WHERE id = ?").run(secret, userId); await db().run("UPDATE users SET totp_secret = ? WHERE id = ?", secret, userId);
} }
/** Short-lived login challenge after a correct key; returns the raw token. */ /** Short-lived login challenge after a correct key; returns the raw token. */
export function createTotpChallenge(userId: string): string { export async function createTotpChallenge(userId: string): Promise<string> {
const token = "tc_" + randomBytes(24).toString("base64url"); const token = "tc_" + randomBytes(24).toString("base64url");
const hash = createHash("sha256").update(token).digest("hex"); const hash = createHash("sha256").update(token).digest("hex");
const now = Date.now(); const now = Date.now();
getDb().query("INSERT INTO totp_challenges (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run( await db().run(
"INSERT INTO totp_challenges (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
hash, userId, now, now + CHALLENGE_TTL_MS, hash, userId, now, now + CHALLENGE_TTL_MS,
); );
// Opportunistic cleanup. // Opportunistic cleanup.
getDb().query("DELETE FROM totp_challenges WHERE expires_at < ?").run(now); await db().run("DELETE FROM totp_challenges WHERE expires_at < ?", now);
return token; return token;
} }
export function consumeTotpChallenge(token: string): string | null { export async function consumeTotpChallenge(token: string): Promise<string | null> {
const hash = createHash("sha256").update(token).digest("hex"); const hash = createHash("sha256").update(token).digest("hex");
const row = getDb().query("SELECT user_id, expires_at FROM totp_challenges WHERE token_hash = ?").get(hash) as { const row = await db().get<{ user_id: string; expires_at: number }>(
user_id: string; "SELECT user_id, expires_at FROM totp_challenges WHERE token_hash = ?", hash,
expires_at: number; );
} | null;
if (!row) return null; if (!row) return null;
getDb().query("DELETE FROM totp_challenges WHERE token_hash = ?").run(hash); await db().run("DELETE FROM totp_challenges WHERE token_hash = ?", hash);
if (row.expires_at < Date.now()) return null; if (row.expires_at < Date.now()) return null;
return row.user_id; return row.user_id;
} }
/** Housekeeping for account deletion (no other caller needs it). */
export async function deleteTotpChallenges(userId: string): Promise<void> {
await db().run("DELETE FROM totp_challenges WHERE user_id = ?", userId);
}
+19 -18
View File
@@ -1,4 +1,4 @@
import { getDb } from "./db.ts"; import { db } from "./db.ts";
import { AnthropicDriver, DeepSeekDriver, GeminiDriver, OpenAIDriver } from "./drivers/apis.ts"; import { AnthropicDriver, DeepSeekDriver, GeminiDriver, OpenAIDriver } from "./drivers/apis.ts";
import type { DriverModel, LLMDriver } from "./drivers/types.ts"; import type { DriverModel, LLMDriver } from "./drivers/types.ts";
@@ -7,7 +7,7 @@ import type { DriverModel, LLMDriver } from "./drivers/types.ts";
* settings, and the matching models show up in their model menu. * settings, and the matching models show up in their model menu.
* *
* Security notes (do not weaken): * Security notes (do not weaken):
* - Keys are stored reversibly in SQLite (they must be sent upstream) and * - Keys are stored reversibly server-side (they must be sent upstream) and
* are NEVER returned to any client — GET only exposes presence + last4. * are NEVER returned to any client — GET only exposes presence + last4.
* - Never log or print keys. Key material only travels server -> provider. * - Never log or print keys. Key material only travels server -> provider.
* - Scoping is per account: every helper takes userId and all chat/model * - Scoping is per account: every helper takes userId and all chat/model
@@ -48,17 +48,19 @@ export interface UserProviderStatus {
updatedAt: number; updatedAt: number;
} }
export function getUserProviderKey(userId: string, provider: UserProviderId): string | null { export async function getUserProviderKey(userId: string, provider: UserProviderId): Promise<string | null> {
const r = getDb() const r = await db().get<{ api_key: string }>(
.query("SELECT api_key FROM user_provider_keys WHERE user_id = ? AND provider = ?") "SELECT api_key FROM user_provider_keys WHERE user_id = ? AND provider = ?",
.get(userId, provider) as { api_key: string } | null; userId, provider,
);
return r?.api_key ?? null; return r?.api_key ?? null;
} }
export function listUserProviders(userId: string): UserProviderStatus[] { export async function listUserProviders(userId: string): Promise<UserProviderStatus[]> {
const rows = getDb() const rows = await db().all<{ provider: string; api_key: string; updated_at: number }>(
.query("SELECT provider, api_key, updated_at FROM user_provider_keys WHERE user_id = ?") "SELECT provider, api_key, updated_at FROM user_provider_keys WHERE user_id = ?",
.all(userId) as { provider: string; api_key: string; updated_at: number }[]; userId,
);
const byId = new Map(rows.map((r) => [r.provider, r])); const byId = new Map(rows.map((r) => [r.provider, r]));
return USER_PROVIDER_IDS.map((p) => { return USER_PROVIDER_IDS.map((p) => {
const r = byId.get(p); const r = byId.get(p);
@@ -72,16 +74,15 @@ export function listUserProviders(userId: string): UserProviderStatus[] {
}); });
} }
export function setUserProviderKey(userId: string, provider: UserProviderId, apiKey: string): void { export async function setUserProviderKey(userId: string, provider: UserProviderId, apiKey: string): Promise<void> {
getDb() await db().run(
.query(
"INSERT INTO user_provider_keys (user_id, provider, api_key, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(user_id, provider) DO UPDATE SET api_key = excluded.api_key, updated_at = excluded.updated_at", "INSERT INTO user_provider_keys (user_id, provider, api_key, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(user_id, provider) DO UPDATE SET api_key = excluded.api_key, updated_at = excluded.updated_at",
) userId, provider, apiKey, Date.now(),
.run(userId, provider, apiKey, Date.now()); );
} }
export function deleteUserProviderKey(userId: string, provider: UserProviderId): void { export async function deleteUserProviderKey(userId: string, provider: UserProviderId): Promise<void> {
getDb().query("DELETE FROM user_provider_keys WHERE user_id = ? AND provider = ?").run(userId, provider); await db().run("DELETE FROM user_provider_keys WHERE user_id = ? AND provider = ?", userId, provider);
} }
/** Build a per-user driver instance authenticated with the user's own key. */ /** Build a per-user driver instance authenticated with the user's own key. */
@@ -107,7 +108,7 @@ export function buildUserProviderDriver(provider: UserProviderId, apiKey: string
export async function listUserProviderModels(userId: string): Promise<DriverModel[]> { export async function listUserProviderModels(userId: string): Promise<DriverModel[]> {
const out: DriverModel[] = []; const out: DriverModel[] = [];
const jobs = USER_PROVIDER_IDS.map(async (p) => { const jobs = USER_PROVIDER_IDS.map(async (p) => {
const key = getUserProviderKey(userId, p); const key = await getUserProviderKey(userId, p);
if (!key) return; if (!key) return;
try { try {
for (const m of await buildUserProviderDriver(p, key).listModels()) { for (const m of await buildUserProviderDriver(p, key).listModels()) {