Now can be powered by postgres sql
This commit is contained in:
+77
-60
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getDb } from "./db.ts";
|
||||
import { db } from "./db.ts";
|
||||
|
||||
/**
|
||||
* Access-request wishlist (registration approval queue) + minimal ticket
|
||||
@@ -33,8 +33,40 @@ export interface AccessMessage {
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export function initAccessTables(): void {
|
||||
const d = getDb();
|
||||
export async function initAccessTables(): Promise<void> {
|
||||
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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
@@ -52,16 +84,6 @@ export function initAccessTables(): void {
|
||||
body TEXT 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
|
||||
// on access_requests — SQLite rewrites dependent FKs on DROP COLUMN and
|
||||
// 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
|
||||
// token_hash then fail with SQLITE_CONSTRAINT_NOTNULL.
|
||||
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")) {
|
||||
try {
|
||||
d.query("PRAGMA foreign_keys=OFF").run();
|
||||
await d.exec("PRAGMA foreign_keys=OFF");
|
||||
try {
|
||||
d.query("ALTER TABLE access_requests DROP COLUMN token_hash").run();
|
||||
await d.exec("ALTER TABLE access_requests DROP COLUMN token_hash");
|
||||
} catch {
|
||||
// SQLite builds without DROP COLUMN support: rebuild table.
|
||||
d.query("ALTER TABLE access_requests RENAME TO access_requests_old").run();
|
||||
d.query(createRequests).run();
|
||||
d.query(`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();
|
||||
d.query("DROP TABLE access_requests_old").run();
|
||||
await d.exec("ALTER TABLE access_requests RENAME TO access_requests_old");
|
||||
await d.exec(createRequests);
|
||||
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`);
|
||||
await d.exec("DROP TABLE access_requests_old");
|
||||
}
|
||||
} finally {
|
||||
d.query("PRAGMA foreign_keys=ON").run();
|
||||
await d.exec("PRAGMA foreign_keys=ON");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -95,39 +117,30 @@ export function initAccessTables(): void {
|
||||
// created before the DROP COLUMN), leaving access_messages with
|
||||
// REFERENCES "access_requests_old". Rebuild it with the correct FK.
|
||||
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")) {
|
||||
d.query("PRAGMA foreign_keys=OFF").run();
|
||||
await d.exec("PRAGMA foreign_keys=OFF");
|
||||
try {
|
||||
d.query("DROP TABLE IF EXISTS access_messages_old").run();
|
||||
d.query("ALTER TABLE access_messages RENAME TO access_messages_old").run();
|
||||
d.query(createMessages).run();
|
||||
d.query(`INSERT INTO access_messages (id, request_id, author, body, created_at)
|
||||
SELECT id, request_id, author, body, created_at FROM access_messages_old`).run();
|
||||
d.query("DROP TABLE access_messages_old").run();
|
||||
await d.exec("DROP TABLE IF EXISTS access_messages_old");
|
||||
await d.exec("ALTER TABLE access_messages RENAME TO access_messages_old");
|
||||
await d.exec(createMessages);
|
||||
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`);
|
||||
await d.exec("DROP TABLE access_messages_old");
|
||||
} finally {
|
||||
d.query("PRAGMA foreign_keys=ON").run();
|
||||
await d.exec("PRAGMA foreign_keys=ON");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 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;
|
||||
email: string;
|
||||
message: string;
|
||||
}): AccessRequest {
|
||||
}): Promise<AccessRequest> {
|
||||
const now = Date.now();
|
||||
const row: AccessRequest = {
|
||||
id: randomUUID(),
|
||||
@@ -139,43 +152,47 @@ export function createAccessRequest(opts: {
|
||||
created_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 (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).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;
|
||||
}
|
||||
|
||||
export function getAccessRequest(id: string): AccessRequest | null {
|
||||
return getDb().query("SELECT * FROM access_requests WHERE id = ?").get(id) as AccessRequest | null;
|
||||
export async function getAccessRequest(id: string): Promise<AccessRequest | null> {
|
||||
return db().get<AccessRequest>("SELECT * FROM access_requests WHERE id = ?", id);
|
||||
}
|
||||
|
||||
/** Open (non-terminal) request already reserving this username or email. */
|
||||
export function findOpenAccessRequest(username: string, email: string): AccessRequest | null {
|
||||
return getDb().query(
|
||||
export async function findOpenAccessRequest(username: string, email: string): Promise<AccessRequest | null> {
|
||||
return db().get<AccessRequest>(
|
||||
"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 })[] {
|
||||
return getDb().query(
|
||||
export async function listAccessRequests(): Promise<(AccessRequest & { message_count: number })[]> {
|
||||
return db().all<AccessRequest & { message_count: number }>(
|
||||
`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`,
|
||||
).all() as (AccessRequest & { message_count: number })[];
|
||||
);
|
||||
}
|
||||
|
||||
export function setAccessStatus(id: string, status: AccessStatus, reason: string): AccessRequest | null {
|
||||
getDb().query("UPDATE access_requests SET status = ?, reason = ?, updated_at = ? WHERE id = ?").run(status, reason, Date.now(), id);
|
||||
export async function setAccessStatus(id: string, status: AccessStatus, reason: string): Promise<AccessRequest | null> {
|
||||
await db().run("UPDATE access_requests SET status = ?, reason = ?, updated_at = ? WHERE id = ?", status, reason, Date.now(), id);
|
||||
return getAccessRequest(id);
|
||||
}
|
||||
|
||||
export function listAccessMessages(requestId: string): AccessMessage[] {
|
||||
return getDb().query("SELECT * FROM access_messages WHERE request_id = ? ORDER BY id ASC").all(requestId) as AccessMessage[];
|
||||
export async function listAccessMessages(requestId: string): Promise<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();
|
||||
getDb().query("INSERT INTO access_messages (request_id, author, body, created_at) VALUES (?, ?, ?, ?)").run(requestId, author, body, now);
|
||||
getDb().query("UPDATE access_requests SET updated_at = ? WHERE id = ?").run(now, requestId);
|
||||
const id = (getDb().query("SELECT last_insert_rowid() AS id").get() as { id: number }).id;
|
||||
const id = await db().insertReturningId(
|
||||
"INSERT INTO access_messages (request_id, author, body, created_at) VALUES (?, ?, ?, ?)",
|
||||
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 };
|
||||
}
|
||||
|
||||
+12
-16
@@ -3,7 +3,6 @@ import { config } from "./config.ts";
|
||||
import {
|
||||
createSession,
|
||||
deleteSession,
|
||||
getDb,
|
||||
getSession,
|
||||
getUserById,
|
||||
getUserByUsername,
|
||||
@@ -11,9 +10,6 @@ import {
|
||||
type PublicUser,
|
||||
} from "./db.ts";
|
||||
|
||||
// Ensure DB + admin row exist at startup.
|
||||
getDb();
|
||||
|
||||
export type { PublicUser };
|
||||
|
||||
function sha256hex(s: string): string {
|
||||
@@ -49,40 +45,40 @@ function checkAdminKey(candidate: string): boolean {
|
||||
* - username "admin" authenticates against APP_PASSWORD (.env).
|
||||
* - 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 (!checkAdminKey(key)) return null;
|
||||
const admin = getUserByUsername("admin");
|
||||
const admin = await getUserByUsername("admin");
|
||||
if (!admin) return null;
|
||||
return toPublicUser(admin);
|
||||
}
|
||||
const u = getUserByUsername(username);
|
||||
const u = await getUserByUsername(username);
|
||||
if (!u || !u.key_hash) return null;
|
||||
if (!secretsEqual(key, u.key_hash)) return null;
|
||||
return toPublicUser(u);
|
||||
}
|
||||
|
||||
/** Issue a fresh one-time Bearer token per successful login (stored hashed in SQLite). */
|
||||
export function issueToken(user: PublicUser): { token: string; expiresAt: number } {
|
||||
/** Issue a fresh one-time Bearer token per successful login (stored hashed server-side). */
|
||||
export async function issueToken(user: PublicUser): Promise<{ token: string; expiresAt: number }> {
|
||||
const token = randomBytes(32).toString("hex"); // 256-bit
|
||||
const expiresAt = Date.now() + config.sessionTtlMs;
|
||||
createSession(sha256hex(token), user.id, expiresAt);
|
||||
await createSession(sha256hex(token), user.id, expiresAt);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): PublicUser | null {
|
||||
const s = getSession(sha256hex(token));
|
||||
export async function verifyToken(token: string): Promise<PublicUser | null> {
|
||||
const s = await getSession(sha256hex(token));
|
||||
if (!s) return null;
|
||||
const u = getUserById(s.user_id);
|
||||
const u = await getUserById(s.user_id);
|
||||
if (!u) {
|
||||
deleteSession(sha256hex(token));
|
||||
await deleteSession(sha256hex(token));
|
||||
return null;
|
||||
}
|
||||
return toPublicUser(u);
|
||||
}
|
||||
|
||||
export function revokeToken(token: string): void {
|
||||
deleteSession(sha256hex(token));
|
||||
export async function revokeToken(token: string): Promise<void> {
|
||||
await deleteSession(sha256hex(token));
|
||||
}
|
||||
|
||||
export function extractBearer(req: Request): string | null {
|
||||
|
||||
@@ -24,6 +24,11 @@ export const config = {
|
||||
sessionTtlMs: envInt("SESSION_TTL_HOURS", 720) * 3600_000,
|
||||
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),
|
||||
rateLimitWindowMs: envInt("RATE_LIMIT_WINDOW_MIN", 10) * 60_000,
|
||||
|
||||
|
||||
@@ -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 { 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 ---
|
||||
|
||||
@@ -54,94 +55,6 @@ export interface PublicUser {
|
||||
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 {
|
||||
return {
|
||||
id: u.id,
|
||||
@@ -157,20 +70,19 @@ export function toPublicUser(u: UserRow): PublicUser {
|
||||
|
||||
// --- users ---
|
||||
|
||||
export function getUserByUsername(username: string): UserRow | null {
|
||||
return getDb().query("SELECT * FROM users WHERE username = ?").get(username) as UserRow | null;
|
||||
export async function getUserByUsername(username: string): Promise<UserRow | null> {
|
||||
return db().get<UserRow>("SELECT * FROM users WHERE username = ?", username);
|
||||
}
|
||||
|
||||
export function getUserById(id: string): UserRow | null {
|
||||
return getDb().query("SELECT * FROM users WHERE id = ?").get(id) as UserRow | null;
|
||||
export async function getUserById(id: string): Promise<UserRow | null> {
|
||||
return db().get<UserRow>("SELECT * FROM users WHERE id = ?", id);
|
||||
}
|
||||
|
||||
export function listUsers(): UserRow[] {
|
||||
return getDb().query("SELECT * FROM users ORDER BY created_at ASC").all() as UserRow[];
|
||||
export async function listUsers(): Promise<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 {
|
||||
const d = getDb();
|
||||
export async function createUser(opts: { username: string; displayName: string; avatarUrl: string; theme: string; email: string; keyHash: string }): Promise<UserRow> {
|
||||
const row: UserRow = {
|
||||
id: randomUUID(),
|
||||
username: opts.username,
|
||||
@@ -181,14 +93,14 @@ export function createUser(opts: { username: string; displayName: string; avatar
|
||||
key_hash: opts.keyHash,
|
||||
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,
|
||||
);
|
||||
return row;
|
||||
}
|
||||
|
||||
export function updateUser(id: string, patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string; keyHash?: string }): UserRow | null {
|
||||
const d = getDb();
|
||||
export async function updateUser(id: string, patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string; keyHash?: string }): Promise<UserRow | null> {
|
||||
const sets: string[] = [];
|
||||
const vals: (string | number)[] = [];
|
||||
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.keyHash !== undefined) { sets.push("key_hash = ?"); vals.push(patch.keyHash); }
|
||||
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) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
export function deleteUser(id: string): void {
|
||||
const d = getDb();
|
||||
const convs = d.query("SELECT id FROM conversations WHERE user_id = ?").all(id) as { id: string }[];
|
||||
const delMsg = d.query("DELETE FROM messages WHERE conv_id = ?");
|
||||
const delShare = d.query("DELETE FROM shares WHERE conv_id = ?");
|
||||
const delConv = d.query("DELETE FROM conversations WHERE id = ?");
|
||||
const txn = d.transaction((cid: string) => {
|
||||
delMsg.run(cid);
|
||||
delShare.run(cid);
|
||||
delConv.run(cid);
|
||||
export async function deleteUser(id: string): Promise<void> {
|
||||
const d = db();
|
||||
const convs = await d.all<{ id: string }>("SELECT id FROM conversations WHERE user_id = ?", id);
|
||||
await d.transaction(async (tx) => {
|
||||
for (const c of convs) {
|
||||
await tx.run("DELETE FROM messages WHERE conv_id = ?", c.id);
|
||||
await tx.run("DELETE FROM shares WHERE conv_id = ?", c.id);
|
||||
await tx.run("DELETE FROM conversations WHERE id = ?", c.id);
|
||||
}
|
||||
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 ---
|
||||
|
||||
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();
|
||||
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 {
|
||||
const d = getDb();
|
||||
const s = d.query("SELECT * FROM sessions WHERE token_hash = ?").get(tokenHash) as SessionRow | null;
|
||||
export async function getSession(tokenHash: string): Promise<SessionRow | null> {
|
||||
const s = await db().get<SessionRow>("SELECT * FROM sessions WHERE token_hash = ?", tokenHash);
|
||||
if (!s) return null;
|
||||
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 s;
|
||||
}
|
||||
|
||||
export function deleteSession(tokenHash: string): void {
|
||||
getDb().query("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
|
||||
export async function deleteSession(tokenHash: string): Promise<void> {
|
||||
await db().run("DELETE FROM sessions WHERE token_hash = ?", tokenHash);
|
||||
}
|
||||
|
||||
export function deleteUserSessions(userId: string): void {
|
||||
getDb().query("DELETE FROM sessions WHERE user_id = ?").run(userId);
|
||||
export async function deleteUserSessions(userId: string): Promise<void> {
|
||||
await db().run("DELETE FROM sessions WHERE user_id = ?", userId);
|
||||
}
|
||||
|
||||
// --- 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 row: ConversationRow = {
|
||||
id: randomUUID(),
|
||||
@@ -265,69 +177,74 @@ export function createConversation(userId: string, opts: { title?: string; topic
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
getDb().query(
|
||||
await db().run(
|
||||
"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;
|
||||
}
|
||||
|
||||
export function listConversations(userId: string): ConversationRow[] {
|
||||
return getDb().query("SELECT * FROM conversations WHERE user_id = ? AND archived_at = 0 ORDER BY updated_at DESC").all(userId) as ConversationRow[];
|
||||
export async function listConversations(userId: string): Promise<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. */
|
||||
export function listArchivedConversations(userId: string): ConversationRow[] {
|
||||
return getDb().query("SELECT * FROM conversations WHERE user_id = ? AND archived_at != 0 ORDER BY updated_at DESC").all(userId) as ConversationRow[];
|
||||
export async function listArchivedConversations(userId: string): Promise<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
|
||||
* purpose so an unarchived chat returns to its chronological place.
|
||||
*/
|
||||
export function setConversationArchived(id: string, archived: boolean): ConversationRow | null {
|
||||
getDb().query("UPDATE conversations SET archived_at = ? WHERE id = ?").run(archived ? Date.now() : 0, id);
|
||||
export async function setConversationArchived(id: string, archived: boolean): Promise<ConversationRow | null> {
|
||||
await db().run("UPDATE conversations SET archived_at = ? WHERE id = ?", archived ? Date.now() : 0, id);
|
||||
return getConversation(id);
|
||||
}
|
||||
|
||||
export function getConversation(id: string): ConversationRow | null {
|
||||
return getDb().query("SELECT * FROM conversations WHERE id = ?").get(id) as ConversationRow | null;
|
||||
export async function getConversation(id: string): Promise<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 {
|
||||
const d = getDb();
|
||||
export async function updateConversation(id: string, patch: { title?: string; topic?: string; model?: string }): Promise<ConversationRow | null> {
|
||||
const sets: string[] = ["updated_at = ?"];
|
||||
const vals: (string | number)[] = [Date.now()];
|
||||
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.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);
|
||||
}
|
||||
|
||||
export function touchConversation(id: string, model?: string): void {
|
||||
export async function touchConversation(id: string, model?: string): Promise<void> {
|
||||
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 {
|
||||
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 {
|
||||
const d = getDb();
|
||||
const txn = d.transaction(() => {
|
||||
d.query("DELETE FROM messages WHERE conv_id = ?").run(id);
|
||||
d.query("DELETE FROM shares WHERE conv_id = ?").run(id);
|
||||
d.query("DELETE FROM conversations WHERE id = ?").run(id);
|
||||
export async function deleteConversation(id: string): Promise<void> {
|
||||
await db().transaction(async (tx) => {
|
||||
await tx.run("DELETE FROM messages WHERE conv_id = ?", id);
|
||||
await tx.run("DELETE FROM shares WHERE conv_id = ?", id);
|
||||
await tx.run("DELETE FROM conversations WHERE id = ?", id);
|
||||
});
|
||||
txn();
|
||||
}
|
||||
|
||||
export function addMessage(convId: string, role: string, content: string): void {
|
||||
getDb().query("INSERT INTO messages (conv_id, role, content, created_at) VALUES (?, ?, ?, ?)").run(convId, role, content, Date.now());
|
||||
export async function addMessage(convId: string, role: string, content: string): Promise<void> {
|
||||
await db().run(
|
||||
"INSERT INTO messages (conv_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
convId, role, content, Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
export function getMessages(convId: string): MessageRow[] {
|
||||
return getDb().query("SELECT * FROM messages WHERE conv_id = ? ORDER BY id ASC LIMIT 500").all(convId) as MessageRow[];
|
||||
export async function getMessages(convId: string): Promise<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) ---
|
||||
@@ -339,81 +256,80 @@ export interface ResetRow {
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
export function createReset(tokenHash: string, userId: string, expiresAt: number): void {
|
||||
const d = getDb();
|
||||
export async function createReset(tokenHash: string, userId: string, expiresAt: number): Promise<void> {
|
||||
// One active reset per account: replace any previous one.
|
||||
d.query("DELETE FROM resets WHERE user_id = ?").run(userId);
|
||||
d.query("INSERT INTO resets (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(
|
||||
await db().run("DELETE FROM resets WHERE user_id = ?", userId);
|
||||
await db().run(
|
||||
"INSERT INTO resets (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
||||
tokenHash, userId, Date.now(), expiresAt,
|
||||
);
|
||||
}
|
||||
|
||||
export function peekReset(tokenHash: string): ResetRow | null {
|
||||
const d = getDb();
|
||||
const r = d.query("SELECT * FROM resets WHERE token_hash = ?").get(tokenHash) as ResetRow | null;
|
||||
export async function peekReset(tokenHash: string): Promise<ResetRow | null> {
|
||||
const r = await db().get<ResetRow>("SELECT * FROM resets WHERE token_hash = ?", tokenHash);
|
||||
if (!r) return null;
|
||||
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 r;
|
||||
}
|
||||
|
||||
/** Consume a reset token (single use). Returns the row, or null if invalid/expired. */
|
||||
export function consumeReset(tokenHash: string): ResetRow | null {
|
||||
const r = peekReset(tokenHash);
|
||||
export async function consumeReset(tokenHash: string): Promise<ResetRow | null> {
|
||||
const r = await peekReset(tokenHash);
|
||||
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;
|
||||
}
|
||||
|
||||
export function deleteUserResets(userId: string): void {
|
||||
getDb().query("DELETE FROM resets WHERE user_id = ?").run(userId);
|
||||
export async function deleteUserResets(userId: string): Promise<void> {
|
||||
await db().run("DELETE FROM resets WHERE user_id = ?", userId);
|
||||
}
|
||||
|
||||
// --- platform settings (admin-controlled feature flags) ---
|
||||
|
||||
export function getSetting(key: string, fallback: string): string {
|
||||
const r = getDb().query("SELECT value FROM settings WHERE key = ?").get(key) as { value: string } | null;
|
||||
export async function getSetting(key: string, fallback: string): Promise<string> {
|
||||
const r = await db().get<{ value: string }>("SELECT value FROM settings WHERE key = ?", key);
|
||||
return r?.value ?? fallback;
|
||||
}
|
||||
|
||||
export function setSetting(key: string, value: string): void {
|
||||
getDb().query(
|
||||
export async function setSetting(key: string, value: string): Promise<void> {
|
||||
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",
|
||||
).run(key, value, Date.now());
|
||||
key, value, Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Public self-registration switch (default off = admin-created accounts only). */
|
||||
export function isRegistrationEnabled(): boolean {
|
||||
return getSetting("registration_enabled", "0") === "1";
|
||||
export async function isRegistrationEnabled(): Promise<boolean> {
|
||||
return (await getSetting("registration_enabled", "0")) === "1";
|
||||
}
|
||||
|
||||
/** Admin kill-switch for the OCR platform tool (default on). */
|
||||
export function isOcrToolEnabled(): boolean {
|
||||
return getSetting("tools_ocr_enabled", "1") === "1";
|
||||
export async function isOcrToolEnabled(): Promise<boolean> {
|
||||
return (await getSetting("tools_ocr_enabled", "1")) === "1";
|
||||
}
|
||||
|
||||
/** Admin kill-switch for message reports / flag button (default on). */
|
||||
export function isReportsEnabled(): boolean {
|
||||
return getSetting("reports_enabled", "1") === "1";
|
||||
export async function isReportsEnabled(): Promise<boolean> {
|
||||
return (await getSetting("reports_enabled", "1")) === "1";
|
||||
}
|
||||
|
||||
/** Admin kill-switch for self-service username changes (default on; admins bypass). */
|
||||
export function isUsernameChangeEnabled(): boolean {
|
||||
return getSetting("username_change_enabled", "1") === "1";
|
||||
export async function isUsernameChangeEnabled(): Promise<boolean> {
|
||||
return (await getSetting("username_change_enabled", "1")) === "1";
|
||||
}
|
||||
|
||||
// --- public shares ---
|
||||
|
||||
export function ensureShare(convId: string): string {
|
||||
const d = getDb();
|
||||
const existing = d.query("SELECT public_id FROM shares WHERE conv_id = ?").get(convId) as { public_id: string } | null;
|
||||
export async function ensureShare(convId: string): Promise<string> {
|
||||
const existing = await db().get<{ public_id: string }>("SELECT public_id FROM shares WHERE conv_id = ?", convId);
|
||||
if (existing) return existing.public_id;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const publicId = randomBytes(9).toString("base64url");
|
||||
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;
|
||||
} catch {
|
||||
// collision — retry
|
||||
@@ -422,25 +338,24 @@ export function ensureShare(convId: string): string {
|
||||
throw new Error("could not generate share id");
|
||||
}
|
||||
|
||||
export function getShareByConv(convId: string): string | null {
|
||||
const r = getDb().query("SELECT public_id FROM shares WHERE conv_id = ?").get(convId) as { public_id: string } | null;
|
||||
export async function getShareByConv(convId: string): Promise<string | null> {
|
||||
const r = await db().get<{ public_id: string }>("SELECT public_id FROM shares WHERE conv_id = ?", convId);
|
||||
return r?.public_id ?? null;
|
||||
}
|
||||
|
||||
export function deleteShare(convId: string): void {
|
||||
getDb().query("DELETE FROM shares WHERE conv_id = ?").run(convId);
|
||||
export async function deleteShare(convId: string): Promise<void> {
|
||||
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 {
|
||||
const d = getDb();
|
||||
const s = d.query("SELECT * FROM shares WHERE public_id = ?").get(publicId) as { conv_id: string; created_at: number } | null;
|
||||
export async function getShareByPublic(publicId: string): Promise<{ conv: ConversationRow; messages: MessageRow[]; authorName: string; authorAvatarUrl: string; archived: boolean; sharedAt: number } | null> {
|
||||
const s = await db().get<{ conv_id: string; created_at: number }>("SELECT * FROM shares WHERE public_id = ?", publicId);
|
||||
if (!s) return null;
|
||||
const conv = getConversation(s.conv_id);
|
||||
const conv = await getConversation(s.conv_id);
|
||||
if (!conv) return null;
|
||||
const author = getUserById(conv.user_id);
|
||||
const author = await getUserById(conv.user_id);
|
||||
return {
|
||||
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",
|
||||
authorAvatarUrl: author?.avatar_url || "",
|
||||
archived: conv.archived_at !== 0,
|
||||
|
||||
@@ -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
@@ -25,13 +25,14 @@ import {
|
||||
deleteUserSessions,
|
||||
ensureShare,
|
||||
getConversation,
|
||||
getDb,
|
||||
db,
|
||||
getMessages,
|
||||
getShareByConv,
|
||||
getShareByPublic,
|
||||
getSetting,
|
||||
getUserById,
|
||||
getUserByUsername,
|
||||
initDb,
|
||||
isRegistrationEnabled,
|
||||
isReportsEnabled,
|
||||
isUsernameChangeEnabled,
|
||||
@@ -50,6 +51,7 @@ import type { ChatMessage } from "./drivers/types.ts";
|
||||
import {
|
||||
consumeTotpChallenge,
|
||||
createTotpChallenge,
|
||||
deleteTotpChallenges,
|
||||
getTotpSecret,
|
||||
initTotpTables,
|
||||
newTotpSecret,
|
||||
@@ -123,14 +125,15 @@ import { OCR_MAX_BYTES, ocrAllowed, recordOcrJob } from "./tools/ocr.ts";
|
||||
import { tools } from "./tools/registry.ts";
|
||||
|
||||
assertConfig();
|
||||
await initDb();
|
||||
const registry = new DriverRegistry();
|
||||
// Ollama handles admin model pulls/deletes (host is loopback/LAN, no auth).
|
||||
const ollamaDriver = registry.get("ollama") as OllamaDriver;
|
||||
await tools.init();
|
||||
initAccessTables();
|
||||
initReportsTables();
|
||||
initModelUsageTables();
|
||||
initTotpTables();
|
||||
await initAccessTables();
|
||||
await initReportsTables();
|
||||
await initModelUsageTables();
|
||||
await initTotpTables();
|
||||
|
||||
function json(data: unknown, status = 200): Response {
|
||||
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";
|
||||
}
|
||||
|
||||
function requireAuth(req: Request): PublicUser | null {
|
||||
async function requireAuth(req: Request): Promise<PublicUser | null> {
|
||||
const token = extractBearer(req);
|
||||
if (!token) return null;
|
||||
return verifyToken(token);
|
||||
@@ -273,7 +276,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
// --- public: shared chat page data (no auth) ---
|
||||
const shareMatch = path.match(/^\/api\/share\/([A-Za-z0-9_-]{6,64})$/);
|
||||
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);
|
||||
return json({
|
||||
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.
|
||||
const cdnPut = path.match(/^\/cdn\/([a-z0-9]{1,16})\/([A-Za-z0-9_-]{1,64})$/);
|
||||
if (cdnPut && req.method === "PUT") {
|
||||
const user = requireAuth(req);
|
||||
const user = await requireAuth(req);
|
||||
if (!user) return json({ error: "unauthorized" }, 401);
|
||||
const [, ns, key] = cdnPut as [string, string, string];
|
||||
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);
|
||||
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) {
|
||||
recordAttempt(ip);
|
||||
await Bun.sleep(400);
|
||||
@@ -394,11 +397,11 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
clearAttempts(ip);
|
||||
// Second factor: correct key but TOTP enabled -> short-lived challenge,
|
||||
// the session is only issued after POST /api/auth/totp verifies a code.
|
||||
if (getTotpSecret(user.id)) {
|
||||
const totpToken = createTotpChallenge(user.id);
|
||||
if (await getTotpSecret(user.id)) {
|
||||
const totpToken = await createTotpChallenge(user.id);
|
||||
return json({ totpRequired: true, totpToken, username: user.username });
|
||||
}
|
||||
const { token, expiresAt } = issueToken(user);
|
||||
const { token, expiresAt } = await issueToken(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);
|
||||
const { ok, body } = await readJson(req);
|
||||
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 target = userId ? getUserById(userId) : null;
|
||||
if (!target || !verifyTotp(getTotpSecret(target.id), code)) {
|
||||
const target = userId ? await getUserById(userId) : null;
|
||||
if (!target || !verifyTotp(await getTotpSecret(target.id), code)) {
|
||||
recordAttempt(ip);
|
||||
await Bun.sleep(400);
|
||||
return json({ error: "invalid code" }, 401);
|
||||
}
|
||||
clearAttempts(ip);
|
||||
const { token, expiresAt } = issueToken(toPublicUser(target));
|
||||
const { token, expiresAt } = await issueToken(toPublicUser(target));
|
||||
return json({ token, expiresAt, user: toPublicUser(target) });
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (path === "/api/auth/logout" && req.method === "POST") {
|
||||
const token = extractBearer(req);
|
||||
if (token) revokeToken(token);
|
||||
if (token) await revokeToken(token);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -437,8 +440,8 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
return json({
|
||||
recovery: mailEnabled(),
|
||||
from: mailEnabled() ? config.smtpFrom : undefined,
|
||||
registration: isRegistrationEnabled(),
|
||||
accessRequest: getSetting("registration_request_enabled", "1") === "1",
|
||||
registration: await isRegistrationEnabled(),
|
||||
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") {
|
||||
const ip = clientIp(req, server);
|
||||
if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429);
|
||||
if (!isRegistrationEnabled()) {
|
||||
if (!await isRegistrationEnabled()) {
|
||||
recordAttempt(ip);
|
||||
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);
|
||||
return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400);
|
||||
}
|
||||
if (getUserByUsername(username)) {
|
||||
if (await getUserByUsername(username)) {
|
||||
recordAttempt(ip);
|
||||
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);
|
||||
}
|
||||
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);
|
||||
// Show the raw key exactly once.
|
||||
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.
|
||||
if (ok && typeof body.username === "string") {
|
||||
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()) {
|
||||
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(() => {});
|
||||
clearAttempts(ip);
|
||||
return json({ ok: true });
|
||||
@@ -506,19 +509,19 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
if (resetMatch) {
|
||||
const tokenHash = createHash("sha256").update(resetMatch[1]!).digest("hex");
|
||||
if (req.method === "GET") {
|
||||
const r = peekReset(tokenHash);
|
||||
const u = r ? getUserById(r.user_id) : null;
|
||||
const r = await peekReset(tokenHash);
|
||||
const u = r ? await getUserById(r.user_id) : null;
|
||||
if (!r || !u) return json({ error: "invalid or expired link" }, 404);
|
||||
return json({ ok: true, username: u.username });
|
||||
}
|
||||
if (req.method === "POST") {
|
||||
const r = consumeReset(tokenHash);
|
||||
const u = r ? getUserById(r.user_id) : null;
|
||||
const r = await consumeReset(tokenHash);
|
||||
const u = r ? await getUserById(r.user_id) : null;
|
||||
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.
|
||||
const key = newAccessKey();
|
||||
updateUser(u.id, { keyHash: hashSecret(key) });
|
||||
deleteUserSessions(u.id);
|
||||
await updateUser(u.id, { keyHash: hashSecret(key) });
|
||||
await deleteUserSessions(u.id);
|
||||
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);
|
||||
// Wishlist is mutually exclusive with open registration: when anyone
|
||||
// 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);
|
||||
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);
|
||||
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);
|
||||
return json({ error: "username taken" }, 409);
|
||||
}
|
||||
if (findOpenAccessRequest(username, email)) {
|
||||
if (await findOpenAccessRequest(username, email)) {
|
||||
recordAttempt(ip);
|
||||
return json({ error: "a request for this username or email is already open" }, 409);
|
||||
}
|
||||
const created = createAccessRequest({ username, email, message });
|
||||
const firstMsg = addAccessMessage(created.id, "user", message);
|
||||
const created = await createAccessRequest({ username, email, message });
|
||||
const firstMsg = await addAccessMessage(created.id, "user", message);
|
||||
clearAttempts(ip);
|
||||
broadcastAccessLive({ type: "access_created", request_id: created.id, request: created });
|
||||
broadcastAccessLive({ type: "access_message", request_id: created.id, message: firstMsg });
|
||||
@@ -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)?$/);
|
||||
if (ticketMatch) {
|
||||
const r = getAccessRequest(ticketMatch[1]!);
|
||||
const r = await getAccessRequest(ticketMatch[1]!);
|
||||
if (!r) return json({ error: "invalid ticket link" }, 404);
|
||||
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") {
|
||||
const ip = clientIp(req, server);
|
||||
@@ -584,7 +587,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
recordAttempt(ip);
|
||||
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);
|
||||
broadcastAccessLive({ type: "access_message", request_id: r.id, message: msg });
|
||||
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.
|
||||
const ticketWsMatch = path.match(/^\/api\/access\/ws\/([0-9a-f-]{36})$/);
|
||||
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 (server.upgrade(req, { data: { ticketId: t.id, isAdmin: false } })) return;
|
||||
return json({ error: "websocket upgrade failed" }, 500);
|
||||
}
|
||||
if (path === "/api/admin/ws" && req.method === "GET") {
|
||||
const token = url.searchParams.get("token") ?? extractBearer(req);
|
||||
const u = token ? verifyToken(token) : null;
|
||||
const u = token ? await verifyToken(token) : null;
|
||||
if (!u?.isAdmin) return json({ error: "unauthorized" }, 401);
|
||||
if (server.upgrade(req, { data: { ticketId: null, isAdmin: true } })) return;
|
||||
return json({ error: "websocket upgrade failed" }, 500);
|
||||
@@ -613,7 +616,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
|
||||
// --- everything below requires auth ---
|
||||
if (path.startsWith("/api/")) {
|
||||
const user = requireAuth(req);
|
||||
const user = await requireAuth(req);
|
||||
if (!user) return json({ error: "unauthorized" }, 401);
|
||||
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 } = {};
|
||||
if (body.username !== undefined) {
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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) ---
|
||||
if (path === "/api/me/key/rotate" && req.method === "POST") {
|
||||
const key = newAccessKey();
|
||||
updateUser(user.id, { keyHash: hashSecret(key) });
|
||||
deleteUserSessions(user.id);
|
||||
await updateUser(user.id, { keyHash: hashSecret(key) });
|
||||
await deleteUserSessions(user.id);
|
||||
return json({ key });
|
||||
}
|
||||
|
||||
// --- self-service: delete your own account (chats, shares, sessions gone) ---
|
||||
if (path === "/api/me" && req.method === "DELETE") {
|
||||
if (user.username === "admin") return json({ error: "the admin account cannot be deleted" }, 403);
|
||||
deleteUser(user.id);
|
||||
getDb().query("DELETE FROM totp_challenges WHERE user_id = ?").run(user.id);
|
||||
await deleteUser(user.id);
|
||||
await deleteTotpChallenges(user.id);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
// --- self-service security: TOTP two-factor ---
|
||||
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 (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();
|
||||
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 : "";
|
||||
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);
|
||||
setTotpSecret(user.id, secret.trim().toUpperCase());
|
||||
await setTotpSecret(user.id, secret.trim().toUpperCase());
|
||||
return json({ enabled: true });
|
||||
}
|
||||
if (path === "/api/me/totp" && req.method === "DELETE") {
|
||||
const { ok, body } = await readJson(req);
|
||||
const code = ok && typeof body.code === "string" ? body.code : "";
|
||||
if (!verifyTotp(getTotpSecret(user.id), code)) return json({ error: "invalid code" }, 401);
|
||||
setTotpSecret(user.id, "");
|
||||
if (!verifyTotp(await getTotpSecret(user.id), code)) return json({ error: "invalid code" }, 401);
|
||||
await setTotpSecret(user.id, "");
|
||||
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
|
||||
// user's /api/models list and /api/chat honors the personal key.
|
||||
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})$/);
|
||||
@@ -718,12 +721,12 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
const { ok, body } = await readJson(req);
|
||||
const key = ok ? validProviderKey(body.apiKey) : null;
|
||||
if (!key) return json({ error: "apiKey: 8-256 chars, no whitespace" }, 400);
|
||||
setUserProviderKey(user.id, provider, key);
|
||||
return json({ providers: listUserProviders(user.id) });
|
||||
await setUserProviderKey(user.id, provider, key);
|
||||
return json({ providers: await listUserProviders(user.id) });
|
||||
}
|
||||
if (req.method === "DELETE") {
|
||||
deleteUserProviderKey(user.id, provider);
|
||||
return json({ providers: listUserProviders(user.id) });
|
||||
await deleteUserProviderKey(user.id, provider);
|
||||
return json({ providers: await listUserProviders(user.id) });
|
||||
}
|
||||
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 per = Math.min(Math.max(Number(url.searchParams.get("per")) || 10, 1), 50);
|
||||
const page = Math.max(Number(url.searchParams.get("page")) || 1, 1);
|
||||
let users = listUsers();
|
||||
let users = await listUsers();
|
||||
if (q) {
|
||||
users = users.filter(
|
||||
(u) =>
|
||||
@@ -768,7 +771,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
if (!ok) return json({ error: "invalid JSON" }, 400);
|
||||
const username = validUsername(body.username);
|
||||
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);
|
||||
if (displayName === null) return json({ error: "displayName: 1-60 chars" }, 400);
|
||||
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);
|
||||
if (email === null) return json({ error: "invalid email address" }, 400);
|
||||
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.
|
||||
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)?$/);
|
||||
if (adminUserMatch) {
|
||||
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 (req.method === "DELETE" && !adminUserMatch[2]) {
|
||||
deleteUser(target.id);
|
||||
await deleteUser(target.id);
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (req.method === "POST" && adminUserMatch[2] === "/regenerate") {
|
||||
const key = newAccessKey();
|
||||
updateUser(target.id, { keyHash: hashSecret(key) });
|
||||
deleteUserSessions(target.id);
|
||||
await updateUser(target.id, { keyHash: hashSecret(key) });
|
||||
await deleteUserSessions(target.id);
|
||||
return json({ key });
|
||||
}
|
||||
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") {
|
||||
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]) {
|
||||
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);
|
||||
const u = validUsername(body.username);
|
||||
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);
|
||||
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);
|
||||
patch.email = e;
|
||||
}
|
||||
const updated = updateUser(target.id, patch);
|
||||
const updated = await updateUser(target.id, patch);
|
||||
return json({ user: toPublicUser(updated!) });
|
||||
}
|
||||
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);
|
||||
return json({
|
||||
settings: {
|
||||
registrationEnabled: isRegistrationEnabled(),
|
||||
accessRequestEnabled: getSetting("registration_request_enabled", "1") === "1",
|
||||
ocrEnabled: getSetting("tools_ocr_enabled", "1") === "1",
|
||||
reportsEnabled: isReportsEnabled(),
|
||||
usernameChangeEnabled: isUsernameChangeEnabled(),
|
||||
registrationEnabled: await isRegistrationEnabled(),
|
||||
accessRequestEnabled: (await getSetting("registration_request_enabled", "1")) === "1",
|
||||
ocrEnabled: (await getSetting("tools_ocr_enabled", "1")) === "1",
|
||||
reportsEnabled: await isReportsEnabled(),
|
||||
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 (body.registrationEnabled !== undefined) {
|
||||
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.
|
||||
if (body.registrationEnabled) setSetting("registration_request_enabled", "0");
|
||||
if (body.registrationEnabled) await setSetting("registration_request_enabled", "0");
|
||||
}
|
||||
if (body.accessRequestEnabled !== undefined) {
|
||||
if (typeof body.accessRequestEnabled !== "boolean") return json({ error: "accessRequestEnabled must be boolean" }, 400);
|
||||
// Mutually exclusive with open registration: the wishlist only
|
||||
// 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);
|
||||
}
|
||||
setSetting("registration_request_enabled", body.accessRequestEnabled ? "1" : "0");
|
||||
await setSetting("registration_request_enabled", body.accessRequestEnabled ? "1" : "0");
|
||||
}
|
||||
if (body.ocrEnabled !== undefined) {
|
||||
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 (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 (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({
|
||||
settings: {
|
||||
registrationEnabled: isRegistrationEnabled(),
|
||||
accessRequestEnabled: getSetting("registration_request_enabled", "1") === "1",
|
||||
ocrEnabled: getSetting("tools_ocr_enabled", "1") === "1",
|
||||
reportsEnabled: isReportsEnabled(),
|
||||
usernameChangeEnabled: isUsernameChangeEnabled(),
|
||||
registrationEnabled: await isRegistrationEnabled(),
|
||||
accessRequestEnabled: (await getSetting("registration_request_enabled", "1")) === "1",
|
||||
ocrEnabled: (await getSetting("tools_ocr_enabled", "1")) === "1",
|
||||
reportsEnabled: await isReportsEnabled(),
|
||||
usernameChangeEnabled: await isUsernameChangeEnabled(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1041,7 +1044,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
|
||||
// --- content reports (flagged AI responses) ---
|
||||
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);
|
||||
if (!ok) return json({ error: "invalid JSON" }, 400);
|
||||
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) {
|
||||
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);
|
||||
conversationId = conv.id;
|
||||
if (body.messageIndex !== undefined) {
|
||||
@@ -1070,7 +1073,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
}
|
||||
}
|
||||
try {
|
||||
const report = createReport({
|
||||
const report = await createReport({
|
||||
reporterId: user.id,
|
||||
reporterName: user.displayName,
|
||||
conversationId,
|
||||
@@ -1093,13 +1096,13 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
|
||||
if (path === "/api/admin/reports" && req.method === "GET") {
|
||||
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})$/);
|
||||
if (reportMatch) {
|
||||
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 (req.method === "PATCH") {
|
||||
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);
|
||||
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.
|
||||
const models = await registry.listAllModelsCached();
|
||||
const policy = getModelPolicy();
|
||||
const policy = await getModelPolicy();
|
||||
return json({
|
||||
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);
|
||||
}
|
||||
try {
|
||||
const entry = setModelPolicyEntry(b.model, {
|
||||
const entry = await setModelPolicyEntry(b.model, {
|
||||
enabled: b.enabled as boolean | undefined,
|
||||
hourly: b.hourly 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 ---
|
||||
if (path === "/api/admin/access" && req.method === "GET") {
|
||||
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)?$/);
|
||||
if (accessMatch) {
|
||||
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);
|
||||
const reviewUrl = `${config.appUrl}/review/${target.id}`;
|
||||
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") {
|
||||
const { ok, body } = await readJson(req);
|
||||
const text = ok ? cleanStr(body.body, 2000) : null;
|
||||
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).
|
||||
sendAccessMessageEmail(target.email, target.username, true, text, reviewUrl).catch(() => {});
|
||||
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;
|
||||
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();
|
||||
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") {
|
||||
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) ---
|
||||
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).
|
||||
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
|
||||
// prompts/replies. Returns the matching conversations, most recent
|
||||
// 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") {
|
||||
const q = (url.searchParams.get("q") ?? "").trim().slice(0, 120);
|
||||
if (q.length < 2) return json({ conversations: [] });
|
||||
const like = `%${q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
||||
const rows = getDb().query(
|
||||
`SELECT c.*, m.content AS hit, m.role AS hit_role FROM conversations c
|
||||
LEFT JOIN messages m ON m.conv_id = c.id
|
||||
AND m.content LIKE ? ESCAPE '\\'
|
||||
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)
|
||||
GROUP BY c.id ORDER BY c.updated_at DESC LIMIT 30`,
|
||||
).all(like, user.id, like, like) as (ConversationRow & { hit: string | null; hit_role: string | null })[];
|
||||
const rows = await db().all<ConversationRow & { hit: string | null; hit_role: string | null }>(
|
||||
`SELECT c.*,
|
||||
(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,
|
||||
(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
|
||||
FROM conversations c
|
||||
WHERE c.user_id = ? AND c.archived_at = 0
|
||||
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({
|
||||
conversations: rows.map(({ hit, hit_role, ...c }) => {
|
||||
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") {
|
||||
const { ok, body } = await readJson(req);
|
||||
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,
|
||||
topic: typeof body.topic === "string" ? body.topic : 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)$/);
|
||||
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);
|
||||
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))?$/);
|
||||
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);
|
||||
const isShare = convMatch[3] === "share";
|
||||
|
||||
if (isShare) {
|
||||
if (req.method === "POST") {
|
||||
const publicId = ensureShare(conv.id);
|
||||
const publicId = await ensureShare(conv.id);
|
||||
return json({ publicId, url: `/share/${publicId}` }, 201);
|
||||
}
|
||||
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 });
|
||||
}
|
||||
if (req.method === "DELETE") {
|
||||
deleteShare(conv.id);
|
||||
await deleteShare(conv.id);
|
||||
return json({ ok: true });
|
||||
}
|
||||
return json({ error: "not found" }, 404);
|
||||
}
|
||||
|
||||
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") {
|
||||
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);
|
||||
patch.model = body.model;
|
||||
}
|
||||
return json({ conversation: updateConversation(conv.id, patch) });
|
||||
return json({ conversation: await updateConversation(conv.id, patch) });
|
||||
}
|
||||
if (req.method === "DELETE") {
|
||||
deleteConversation(conv.id);
|
||||
await deleteConversation(conv.id);
|
||||
return json({ ok: true });
|
||||
}
|
||||
return json({ error: "not found" }, 404);
|
||||
@@ -1378,18 +1387,22 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
if (admin) return json({ models });
|
||||
// Users never see admin-disabled models (nor select them — /api/chat
|
||||
// enforces the same policy server-side).
|
||||
const policy = getModelPolicy();
|
||||
const policy = await getModelPolicy();
|
||||
return json({ models: models.filter((m) => policyFor(policy, m.id).enabled) });
|
||||
}
|
||||
|
||||
// --- platform tools (uploads always go through tools) ---
|
||||
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") {
|
||||
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");
|
||||
if (declared > OCR_MAX_BYTES) return json({ error: "image too large (max 5MB)" }, 413);
|
||||
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
|
||||
// their own key, the request is billed to them, not the platform.
|
||||
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);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1441,7 +1454,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
const pname = idx === -1 ? "" : fullId.slice(0, idx);
|
||||
const sub = idx === -1 ? "" : fullId.slice(idx + 1);
|
||||
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);
|
||||
driver = buildUserProviderDriver(pname, personalKey);
|
||||
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).
|
||||
// Admins bypass; attempts count even when generation later fails.
|
||||
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);
|
||||
recordModelUse(user.id, body.model);
|
||||
await recordModelUse(user.id, body.model);
|
||||
}
|
||||
const messages = body.messages as ChatMessage[];
|
||||
// Optional persistence into a server-side conversation (must belong to the user).
|
||||
let conv = null;
|
||||
if (body.conversationId !== undefined) {
|
||||
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);
|
||||
// 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);
|
||||
// Persist the new user message (dedupe against last stored one).
|
||||
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];
|
||||
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") {
|
||||
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 persistReply = (text: string) => {
|
||||
const persistReply = async (text: string): Promise<void> => {
|
||||
if (conv && text) {
|
||||
addMessage(conv.id, "assistant", text);
|
||||
touchConversation(conv.id, body.model as string);
|
||||
await addMessage(conv.id, "assistant", text);
|
||||
await touchConversation(conv.id, body.model as string);
|
||||
}
|
||||
};
|
||||
|
||||
if (!wantStream) {
|
||||
try {
|
||||
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 } });
|
||||
} catch (e) {
|
||||
return json({ error: (e as Error).message }, 502);
|
||||
@@ -1523,12 +1536,12 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
full += token;
|
||||
if (!safeSend("token", JSON.stringify({ token }))) break;
|
||||
}
|
||||
persistReply(full);
|
||||
await persistReply(full);
|
||||
safeSend("done", "{}");
|
||||
} catch (e) {
|
||||
// Genuine driver error: keep the partial text as-is (no internal
|
||||
// markers persisted) and notify the client if still connected.
|
||||
if (full) persistReply(full);
|
||||
if (full) await persistReply(full);
|
||||
if (!req.signal.aborted && !clientGone) {
|
||||
safeSend("error", JSON.stringify({ error: (e as Error).message }));
|
||||
}
|
||||
|
||||
+31
-28
@@ -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).
|
||||
@@ -41,8 +41,8 @@ function isDefault(e: ModelPolicyEntry): boolean {
|
||||
return e.enabled && e.hourly === 0 && e.daily === 0;
|
||||
}
|
||||
|
||||
export function getModelPolicy(): ModelPolicy {
|
||||
const raw = getSetting(SETTINGS_KEY, "");
|
||||
export async function getModelPolicy(): Promise<ModelPolicy> {
|
||||
const raw = await getSetting(SETTINGS_KEY, "");
|
||||
if (!raw) return {};
|
||||
try {
|
||||
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 };
|
||||
}
|
||||
|
||||
export function setModelPolicyEntry(
|
||||
export async function setModelPolicyEntry(
|
||||
modelId: string,
|
||||
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.hourly !== undefined && !Number.isInteger(patch.hourly)) 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 next: ModelPolicyEntry = {
|
||||
enabled: patch.enabled ?? cur.enabled,
|
||||
@@ -80,53 +80,56 @@ export function setModelPolicyEntry(
|
||||
}
|
||||
if (isDefault(next)) delete policy[modelId];
|
||||
else policy[modelId] = next;
|
||||
setSetting(SETTINGS_KEY, JSON.stringify(policy));
|
||||
await setSetting(SETTINGS_KEY, JSON.stringify(policy));
|
||||
return { ...DEFAULT_POLICY, ...policy[modelId] };
|
||||
}
|
||||
|
||||
export function initModelUsageTables(): void {
|
||||
getDb().query(`CREATE TABLE IF NOT EXISTS model_usage (
|
||||
export async function initModelUsageTables(): Promise<void> {
|
||||
// "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,
|
||||
model TEXT NOT NULL,
|
||||
window TEXT NOT NULL,
|
||||
bucket INTEGER NOT NULL,
|
||||
"window" TEXT NOT NULL,
|
||||
bucket BIGINT NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, model, window, bucket)
|
||||
)`).run();
|
||||
PRIMARY KEY (user_id, model, "window", bucket)
|
||||
)`);
|
||||
}
|
||||
|
||||
function buckets(now = Date.now()): { h: number; d: number } {
|
||||
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 {
|
||||
const row = getDb().query(
|
||||
"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;
|
||||
async function windowCount(userId: string, modelId: string, window: "h" | "d", bucket: number): Promise<number> {
|
||||
const row = await db().get<{ n: number }>(
|
||||
'SELECT count AS n FROM model_usage WHERE user_id = ? AND model = ? AND "window" = ? AND bucket = ?',
|
||||
userId, modelId, window, bucket,
|
||||
);
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
||||
export type ModelAccess = { ok: true } | { ok: false; code: 403 | 429; message: string };
|
||||
|
||||
export function checkModelAccess(userId: string, modelId: string): ModelAccess {
|
||||
const p = policyFor(getModelPolicy(), modelId);
|
||||
export async function checkModelAccess(userId: string, modelId: string): Promise<ModelAccess> {
|
||||
const p = policyFor(await getModelPolicy(), modelId);
|
||||
if (!p.enabled) return { ok: false, code: 403, message: "model disabled by administrator" };
|
||||
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" };
|
||||
}
|
||||
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: true };
|
||||
}
|
||||
|
||||
export function recordModelUse(userId: string, modelId: string): void {
|
||||
const d = getDb();
|
||||
export async function recordModelUse(userId: string, modelId: string): Promise<void> {
|
||||
const d = db();
|
||||
const { h, d: day } = buckets();
|
||||
d.query(`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);
|
||||
d.query(`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);
|
||||
d.query("DELETE FROM model_usage WHERE (window = 'h' AND bucket < ?) OR (window = 'd' AND bucket < ?)").run(h - 1, day - 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`, userId, modelId, h);
|
||||
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`, userId, modelId, day);
|
||||
await d.run('DELETE FROM model_usage WHERE ("window" = \'h\' AND bucket < ?) OR ("window" = \'d\' AND bucket < ?)', h - 1, day - 1);
|
||||
}
|
||||
|
||||
+52
-45
@@ -1,5 +1,5 @@
|
||||
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
|
||||
@@ -38,14 +38,14 @@ export interface Report {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export function initReportsTables(): void {
|
||||
const d = getDb();
|
||||
d.query(`CREATE TABLE IF NOT EXISTS reports (
|
||||
export async function initReportsTables(): Promise<void> {
|
||||
const d = db();
|
||||
await d.exec(`CREATE TABLE IF NOT EXISTS reports (
|
||||
id TEXT PRIMARY KEY,
|
||||
reporter_id TEXT NOT NULL,
|
||||
reporter_name TEXT NOT NULL DEFAULT '',
|
||||
conversation_id TEXT NOT NULL DEFAULT '',
|
||||
message_index INTEGER NOT NULL DEFAULT -1,
|
||||
message_index BIGINT NOT NULL DEFAULT -1,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
prompt TEXT NOT NULL DEFAULT '',
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
@@ -53,21 +53,27 @@ export function initReportsTables(): void {
|
||||
details TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
admin_note TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)`).run();
|
||||
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status, updated_at DESC)`).run();
|
||||
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_reporter ON reports(reporter_id, created_at DESC)`).run();
|
||||
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_message ON reports(conversation_id, message_index)`).run();
|
||||
// Prompt snapshot added after the initial schema — keep idempotent.
|
||||
const rcols = d.query("PRAGMA table_info(reports)").all() as { name: string }[];
|
||||
if (!rcols.some((c) => c.name === "prompt")) {
|
||||
d.query("ALTER TABLE reports ADD COLUMN prompt TEXT NOT NULL DEFAULT ''").run();
|
||||
}
|
||||
// Per-user report shadow-ban (unreliable reporters) — keep idempotent.
|
||||
const ucols = d.query("PRAGMA table_info(users)").all() as { name: string }[];
|
||||
if (!ucols.some((c) => c.name === "report_shadowbanned")) {
|
||||
d.query("ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0").run();
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
)`);
|
||||
await d.exec(`CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status, updated_at DESC)`);
|
||||
await d.exec(`CREATE INDEX IF NOT EXISTS idx_reports_reporter ON reports(reporter_id, created_at DESC)`);
|
||||
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.
|
||||
const rcols = await d.all<{ name: string }>("PRAGMA table_info(reports)");
|
||||
if (!rcols.some((c) => c.name === "prompt")) {
|
||||
await d.exec("ALTER TABLE reports ADD COLUMN prompt TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
// Per-user report shadow-ban (unreliable reporters) — keep idempotent.
|
||||
const ucols = await d.all<{ name: string }>("PRAGMA table_info(users)");
|
||||
if (!ucols.some((c) => c.name === "report_shadowbanned")) {
|
||||
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;
|
||||
|
||||
/** 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;
|
||||
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",
|
||||
).get(conversationId, messageIndex) as Report | null;
|
||||
conversationId, messageIndex,
|
||||
);
|
||||
}
|
||||
|
||||
export function createReport(opts: {
|
||||
export async function createReport(opts: {
|
||||
reporterId: string;
|
||||
reporterName: string;
|
||||
conversationId: string;
|
||||
@@ -92,28 +99,28 @@ export function createReport(opts: {
|
||||
clientContent: string;
|
||||
clientPrompt: string;
|
||||
clientModel: string;
|
||||
}): Report {
|
||||
const d = getDb();
|
||||
}): Promise<Report> {
|
||||
const dup = opts.conversationId && opts.messageIndex >= 0
|
||||
? findOpenReport(opts.conversationId, opts.messageIndex)
|
||||
? await findOpenReport(opts.conversationId, opts.messageIndex)
|
||||
: null;
|
||||
if (dup) {
|
||||
const err = new Error("ALREADY_REPORTED") as Error & { report: Report };
|
||||
err.report = dup;
|
||||
throw err;
|
||||
}
|
||||
const open = d.query(
|
||||
const open = await db().get<{ n: number }>(
|
||||
"SELECT COUNT(*) AS n FROM reports WHERE reporter_id = ? AND status IN ('open','reviewing')",
|
||||
).get(opts.reporterId) as { n: number };
|
||||
if (open.n >= MAX_OPEN_REPORTS) throw new Error("TOO_MANY_OPEN");
|
||||
opts.reporterId,
|
||||
);
|
||||
if ((open?.n ?? 0) >= MAX_OPEN_REPORTS) throw new Error("TOO_MANY_OPEN");
|
||||
let content = opts.clientContent;
|
||||
let prompt = opts.clientPrompt;
|
||||
let model = opts.clientModel;
|
||||
if (opts.conversationId) {
|
||||
const conv = getConversation(opts.conversationId);
|
||||
const conv = await getConversation(opts.conversationId);
|
||||
if (conv && conv.user_id === opts.reporterId) {
|
||||
if (conv.model) model = conv.model;
|
||||
const msgs = getMessages(conv.id).filter((m) => m.role !== "system");
|
||||
const msgs = (await getMessages(conv.id)).filter((m) => m.role !== "system");
|
||||
if (opts.messageIndex >= 0) {
|
||||
const at = msgs[opts.messageIndex];
|
||||
if (at) content = at.content;
|
||||
@@ -140,9 +147,8 @@ export function createReport(opts: {
|
||||
created_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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(
|
||||
row.id, row.reporter_id, row.reporter_name, row.conversation_id, row.message_index,
|
||||
row.content, row.prompt, row.model, row.reason, row.details, row.status, row.admin_note,
|
||||
row.created_at, row.updated_at,
|
||||
@@ -150,20 +156,21 @@ export function createReport(opts: {
|
||||
return row;
|
||||
}
|
||||
|
||||
export function listReports(): Report[] {
|
||||
return getDb().query(
|
||||
export async function listReports(): Promise<Report[]> {
|
||||
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",
|
||||
).all() as Report[];
|
||||
);
|
||||
}
|
||||
|
||||
export function getReport(id: string): Report | null {
|
||||
return getDb().query(
|
||||
export async function getReport(id: string): Promise<Report | null> {
|
||||
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 = ?",
|
||||
).get(id) as Report | null;
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
export function setReportStatus(id: string, status: ReportStatus, adminNote: string): Report | null {
|
||||
getDb().query("UPDATE reports SET status = ?, admin_note = ?, updated_at = ? WHERE id = ?").run(
|
||||
export async function setReportStatus(id: string, status: ReportStatus, adminNote: string): Promise<Report | null> {
|
||||
await db().run("UPDATE reports SET status = ?, admin_note = ?, updated_at = ? WHERE id = ?",
|
||||
status, adminNote, Date.now(), 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
|
||||
* unreliable (excluded from the admin badge). Returns the new flag.
|
||||
*/
|
||||
export function setReportShadowbanned(userId: string, shadowbanned: boolean): number {
|
||||
getDb().query("UPDATE users SET report_shadowbanned = ? WHERE id = ?").run(shadowbanned ? 1 : 0, userId);
|
||||
const row = getDb().query("SELECT report_shadowbanned AS v FROM users WHERE id = ?").get(userId) as { v: number } | null;
|
||||
export async function setReportShadowbanned(userId: string, shadowbanned: boolean): Promise<number> {
|
||||
await db().run("UPDATE users SET report_shadowbanned = ? WHERE id = ?", shadowbanned ? 1 : 0, userId);
|
||||
const row = await db().get<{ v: number }>("SELECT report_shadowbanned AS v FROM users WHERE id = ?", userId);
|
||||
return row?.v ?? 0;
|
||||
}
|
||||
|
||||
+5
-5
@@ -62,20 +62,20 @@ export class OcrTool implements PlatformTool {
|
||||
this.available = availableCache;
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return (this.available ?? false) && isOcrToolEnabled();
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return (this.available ?? false) && (await isOcrToolEnabled());
|
||||
}
|
||||
|
||||
unavailableReason(): string | null {
|
||||
async unavailableReason(): Promise<string | null> {
|
||||
if (!(this.available ?? false)) {
|
||||
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;
|
||||
}
|
||||
|
||||
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 maxChars = config.ocrMaxChars;
|
||||
let proc;
|
||||
|
||||
@@ -10,13 +10,15 @@ class ToolRegistry {
|
||||
await this.ocr.init();
|
||||
}
|
||||
|
||||
list(): { name: string; description: string; available: boolean; reason: string | null }[] {
|
||||
return this.tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
available: t.isAvailable(),
|
||||
reason: t.unavailableReason(),
|
||||
}));
|
||||
async list(): Promise<{ name: string; description: string; available: boolean; reason: string | null }[]> {
|
||||
return Promise.all(
|
||||
this.tools.map(async (t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
available: await t.isAvailable(),
|
||||
reason: await t.unavailableReason(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-3
@@ -1,8 +1,9 @@
|
||||
/** 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 {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
isAvailable(): boolean;
|
||||
unavailableReason(): string | null;
|
||||
isAvailable(): Promise<boolean>;
|
||||
unavailableReason(): Promise<string | null>;
|
||||
}
|
||||
|
||||
+33
-24
@@ -1,5 +1,5 @@
|
||||
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).
|
||||
@@ -13,19 +13,23 @@ const DIGITS = 6;
|
||||
const WINDOW = 1;
|
||||
const CHALLENGE_TTL_MS = 5 * 60_000;
|
||||
|
||||
export function initTotpTables(): void {
|
||||
const d = getDb();
|
||||
try {
|
||||
d.query("ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''").run();
|
||||
} catch {
|
||||
// column already exists — fine
|
||||
export async function initTotpTables(): Promise<void> {
|
||||
const d = db();
|
||||
if (d.kind === "sqlite") {
|
||||
try {
|
||||
await d.exec("ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''");
|
||||
} catch {
|
||||
// column already exists — fine
|
||||
}
|
||||
} else {
|
||||
await d.exec("ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
d.query(`CREATE TABLE IF NOT EXISTS totp_challenges (
|
||||
await d.exec(`CREATE TABLE IF NOT EXISTS totp_challenges (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)`).run();
|
||||
created_at BIGINT NOT NULL,
|
||||
expires_at BIGINT NOT NULL
|
||||
)`);
|
||||
}
|
||||
|
||||
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
@@ -104,36 +108,41 @@ export function verifyTotp(secret: string, code: string): boolean {
|
||||
return ok;
|
||||
}
|
||||
|
||||
export function getTotpSecret(userId: string): string {
|
||||
const row = getDb().query("SELECT totp_secret AS s FROM users WHERE id = ?").get(userId) as { s: string } | null;
|
||||
export async function getTotpSecret(userId: string): Promise<string> {
|
||||
const row = await db().get<{ s: string }>("SELECT totp_secret AS s FROM users WHERE id = ?", userId);
|
||||
return row?.s ?? "";
|
||||
}
|
||||
|
||||
export function setTotpSecret(userId: string, secret: string): void {
|
||||
getDb().query("UPDATE users SET totp_secret = ? WHERE id = ?").run(secret, userId);
|
||||
export async function setTotpSecret(userId: string, secret: string): Promise<void> {
|
||||
await db().run("UPDATE users SET totp_secret = ? WHERE id = ?", secret, userId);
|
||||
}
|
||||
|
||||
/** 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 hash = createHash("sha256").update(token).digest("hex");
|
||||
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,
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 row = getDb().query("SELECT user_id, expires_at FROM totp_challenges WHERE token_hash = ?").get(hash) as {
|
||||
user_id: string;
|
||||
expires_at: number;
|
||||
} | null;
|
||||
const row = await db().get<{ user_id: string; expires_at: number }>(
|
||||
"SELECT user_id, expires_at FROM totp_challenges WHERE token_hash = ?", hash,
|
||||
);
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
+20
-19
@@ -1,4 +1,4 @@
|
||||
import { getDb } from "./db.ts";
|
||||
import { db } from "./db.ts";
|
||||
import { AnthropicDriver, DeepSeekDriver, GeminiDriver, OpenAIDriver } from "./drivers/apis.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.
|
||||
*
|
||||
* 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.
|
||||
* - Never log or print keys. Key material only travels server -> provider.
|
||||
* - Scoping is per account: every helper takes userId and all chat/model
|
||||
@@ -48,17 +48,19 @@ export interface UserProviderStatus {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export function getUserProviderKey(userId: string, provider: UserProviderId): string | null {
|
||||
const r = getDb()
|
||||
.query("SELECT api_key FROM user_provider_keys WHERE user_id = ? AND provider = ?")
|
||||
.get(userId, provider) as { api_key: string } | null;
|
||||
export async function getUserProviderKey(userId: string, provider: UserProviderId): Promise<string | null> {
|
||||
const r = await db().get<{ api_key: string }>(
|
||||
"SELECT api_key FROM user_provider_keys WHERE user_id = ? AND provider = ?",
|
||||
userId, provider,
|
||||
);
|
||||
return r?.api_key ?? null;
|
||||
}
|
||||
|
||||
export function listUserProviders(userId: string): UserProviderStatus[] {
|
||||
const rows = getDb()
|
||||
.query("SELECT provider, api_key, updated_at FROM user_provider_keys WHERE user_id = ?")
|
||||
.all(userId) as { provider: string; api_key: string; updated_at: number }[];
|
||||
export async function listUserProviders(userId: string): Promise<UserProviderStatus[]> {
|
||||
const rows = await db().all<{ provider: string; api_key: string; updated_at: number }>(
|
||||
"SELECT provider, api_key, updated_at FROM user_provider_keys WHERE user_id = ?",
|
||||
userId,
|
||||
);
|
||||
const byId = new Map(rows.map((r) => [r.provider, r]));
|
||||
return USER_PROVIDER_IDS.map((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 {
|
||||
getDb()
|
||||
.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",
|
||||
)
|
||||
.run(userId, provider, apiKey, Date.now());
|
||||
export async function setUserProviderKey(userId: string, provider: UserProviderId, apiKey: string): Promise<void> {
|
||||
await db().run(
|
||||
"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(),
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteUserProviderKey(userId: string, provider: UserProviderId): void {
|
||||
getDb().query("DELETE FROM user_provider_keys WHERE user_id = ? AND provider = ?").run(userId, provider);
|
||||
export async function deleteUserProviderKey(userId: string, provider: UserProviderId): Promise<void> {
|
||||
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. */
|
||||
@@ -107,7 +108,7 @@ export function buildUserProviderDriver(provider: UserProviderId, apiKey: string
|
||||
export async function listUserProviderModels(userId: string): Promise<DriverModel[]> {
|
||||
const out: DriverModel[] = [];
|
||||
const jobs = USER_PROVIDER_IDS.map(async (p) => {
|
||||
const key = getUserProviderKey(userId, p);
|
||||
const key = await getUserProviderKey(userId, p);
|
||||
if (!key) return;
|
||||
try {
|
||||
for (const m of await buildUserProviderDriver(p, key).listModels()) {
|
||||
|
||||
Reference in New Issue
Block a user