1import { Database } from "bun:sqlite";2import { mkdirSync } from "node:fs";3import { join } from "node:path";4import { ANY_DEPTH } from "../mirror/store.ts";567const SCHEMA = `8CREATE TABLE IF NOT EXISTS settings (9 key TEXT PRIMARY KEY,10 value TEXT NOT NULL11);12CREATE TABLE IF NOT EXISTS repos (13 name TEXT PRIMARY KEY,14 public INTEGER NOT NULL DEFAULT 0,15 category TEXT NOT NULL DEFAULT '',16 homepage TEXT NOT NULL DEFAULT '',17 languages TEXT NOT NULL DEFAULT '',18 issues_paused INTEGER NOT NULL DEFAULT 0,19 hidden_paths TEXT NOT NULL DEFAULT '',20 -- The hidden list last written to .kepr/hidden/<repo>; NULL while unknown.21 hidden_handed TEXT,22 last_sync_at INTEGER,23 last_sync_error TEXT24);25-- §24: the admin console hides a version or replaces its notes; NULL notes keep the record's.26CREATE TABLE IF NOT EXISTS release_settings (27 repo TEXT NOT NULL,28 version TEXT NOT NULL,29 hidden INTEGER NOT NULL DEFAULT 0,30 notes TEXT,31 PRIMARY KEY (repo, version)32);33-- §22.2: one count per release file; installs are Nix tarball fetches, per repo.34CREATE TABLE IF NOT EXISTS downloads (35 repo TEXT NOT NULL,36 version TEXT NOT NULL,37 platform TEXT NOT NULL,38 count INTEGER NOT NULL DEFAULT 0,39 PRIMARY KEY (repo, version, platform)40);41-- §24's Downloads: the same downloads per repo and UTC day (YYYY-MM-DD), the newest 400 days.42CREATE TABLE IF NOT EXISTS download_days (43 repo TEXT NOT NULL,44 day TEXT NOT NULL,45 count INTEGER NOT NULL DEFAULT 0,46 PRIMARY KEY (repo, day)47);48CREATE TABLE IF NOT EXISTS installs (49 repo TEXT PRIMARY KEY,50 count INTEGER NOT NULL DEFAULT 051);52-- §23. Numbers are per repo and set explicitly, so Kepr 2's carry over on import. The contact53-- email is the operator's alone.54CREATE TABLE IF NOT EXISTS issues (55 id INTEGER PRIMARY KEY,56 repo TEXT NOT NULL,57 number INTEGER NOT NULL,58 title TEXT NOT NULL,59 body TEXT NOT NULL,60 type TEXT NOT NULL CHECK (type IN ('bug', 'idea', 'question')),61 version TEXT NOT NULL DEFAULT '',62 reporter_name TEXT NOT NULL DEFAULT '',63 contact_email TEXT NOT NULL DEFAULT '',64 filed_at INTEGER NOT NULL,65 status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),66 closed_at INTEGER,67 fixed_in TEXT NOT NULL DEFAULT '',68 hidden INTEGER NOT NULL DEFAULT 0,69 ip_hash TEXT NOT NULL DEFAULT '',70 UNIQUE (repo, number)71);72CREATE INDEX IF NOT EXISTS issues_ip ON issues (ip_hash, filed_at);73-- §23: addresses blocked from filing, replying and following, by the hash the rate limit keeps,74-- with the issue they were blocked from.75CREATE TABLE IF NOT EXISTS blocked_reporters (76 id INTEGER PRIMARY KEY,77 ip_hash TEXT NOT NULL UNIQUE,78 repo TEXT NOT NULL,79 number INTEGER NOT NULL,80 blocked_at INTEGER NOT NULL81);82-- The thread after the first post. 'reporter' is anyone from the public, the issue's own reporter83-- or not, under the name they chose; reply_to is the reply this one answers.84CREATE TABLE IF NOT EXISTS issue_entries (85 id INTEGER PRIMARY KEY,86 issue_id INTEGER NOT NULL REFERENCES issues (id),87 author TEXT NOT NULL CHECK (author IN ('reporter', 'operator')),88 name TEXT NOT NULL DEFAULT '',89 body TEXT NOT NULL,90 created_at INTEGER NOT NULL,91 hidden INTEGER NOT NULL DEFAULT 0,92 ip_hash TEXT NOT NULL DEFAULT '',93 reply_to INTEGER94);95CREATE INDEX IF NOT EXISTS issue_entries_issue ON issue_entries (issue_id, id);96-- §23: screenshots and logs, on an issue itself (entry_id NULL) or on one of its replies.97CREATE TABLE IF NOT EXISTS issue_files (98 id INTEGER PRIMARY KEY,99 issue_id INTEGER NOT NULL REFERENCES issues (id),100 entry_id INTEGER REFERENCES issue_entries (id),101 name TEXT NOT NULL,102 type TEXT NOT NULL,103 size INTEGER NOT NULL,104 data BLOB NOT NULL,105 ip_hash TEXT NOT NULL DEFAULT '',106 created_at INTEGER NOT NULL107);108CREATE INDEX IF NOT EXISTS issue_files_issue ON issue_files (issue_id, id);109CREATE INDEX IF NOT EXISTS issue_files_ip ON issue_files (ip_hash, created_at);110-- §24: admin sessions, revocable; only the SHA-256 of the cookie's token is kept.111CREATE TABLE IF NOT EXISTS sessions (112 token_hash TEXT PRIMARY KEY,113 expires_at INTEGER NOT NULL114);115-- June's own words for a repo, edited on its page when signed in; '' falls back to the README.116CREATE TABLE IF NOT EXISTS repo_text (117 repo TEXT PRIMARY KEY,118 description TEXT NOT NULL DEFAULT '',119 details TEXT NOT NULL DEFAULT ''120);121CREATE TABLE IF NOT EXISTS connect_codes (122 code_hash TEXT PRIMARY KEY,123 machine TEXT NOT NULL,124 created_at INTEGER NOT NULL,125 expires_at INTEGER NOT NULL,126 used_at INTEGER127);128-- §24: what Kepr tells the operator in the console, newest 200 kept.129CREATE TABLE IF NOT EXISTS notifications (130 id INTEGER PRIMARY KEY,131 kind TEXT NOT NULL,132 text TEXT NOT NULL,133 href TEXT NOT NULL,134 created_at INTEGER NOT NULL,135 read INTEGER NOT NULL DEFAULT 0136);137-- §24: notifications on their way to the operator's inbox; the address is kepr.toml's.138CREATE TABLE IF NOT EXISTS email_queue (139 id INTEGER PRIMARY KEY,140 subject TEXT NOT NULL,141 href TEXT NOT NULL,142 status TEXT NOT NULL DEFAULT 'waiting' CHECK (status IN ('waiting', 'sent', 'failed')),143 attempts INTEGER NOT NULL DEFAULT 0,144 next_at INTEGER NOT NULL,145 last_error TEXT NOT NULL DEFAULT '',146 created_at INTEGER NOT NULL,147 sent_at INTEGER148);149-- A repo renamed from the console (June, 2026-10-01): its old pages redirect to the new name.150CREATE TABLE IF NOT EXISTS renamed (151 old TEXT PRIMARY KEY,152 new TEXT NOT NULL153);154`;155156157export const NOTIFICATION_KINDS = [158 "issue_filed",159 "issue_followup",160 "sync_failed",161 "backup_failed",162] as const;163export type NotificationKind = (typeof NOTIFICATION_KINDS)[number];164const NOTIFICATIONS_KEPT = 200;165166167const ADDED_COLUMNS: [table: string, column: string, definition: string][] = [168 ["repos", "languages", "TEXT NOT NULL DEFAULT ''"],169 ["repos", "issues_paused", "INTEGER NOT NULL DEFAULT 0"],170 ["issue_entries", "hidden", "INTEGER NOT NULL DEFAULT 0"],171 ["issue_entries", "name", "TEXT NOT NULL DEFAULT ''"],172 ["issue_entries", "ip_hash", "TEXT NOT NULL DEFAULT ''"],173 ["issue_entries", "reply_to", "INTEGER"],174 ["repos", "hidden_paths", "TEXT NOT NULL DEFAULT ''"],175 ["repos", "hidden_handed", "TEXT"],176];177178const DROPPED_COLUMNS: [table: string, column: string][] = [179 ["repos", "sort_order"],180 ["repos", "install1"],181 ["repos", "install2"],182 ["repos", "install3"],183 184 ["issues", "token_hash"],185];186187188export type RepoSettings = {189 name: string;190 category: string;191 homepage: string;192 193 languages: string[];194 195 issuesPaused: boolean;196 description: string;197 details: string;198};199200export type RepoText = "description" | "details";201202type RepoRow = {203 name: string;204 category: string;205 homepage: string;206 languages: string;207 issues_paused: number;208 description: string | null;209 details: string | null;210};211212export function openKeprDb(dataDir: string): Database {213 mkdirSync(dataDir, { recursive: true });214 const db = new Database(join(dataDir, "kepr.db"), { strict: true });215 db.run("PRAGMA journal_mode = WAL");216 db.run("PRAGMA busy_timeout = 5000");217 db.run(SCHEMA);218 219 const has = (table: string, column: string) =>220 db221 .query<{ name: string }, []>(`PRAGMA table_info(${table})`)222 .all()223 .some((existing) => existing.name === column);224 const named = has("issue_entries", "name");225 for (const [table, column, definition] of ADDED_COLUMNS) {226 if (!has(table, column)) db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);227 }228 229 if (!named) {230 db.run(`UPDATE issue_entries SET name = (231 SELECT reporter_name FROM issues WHERE issues.id = issue_entries.issue_id232) WHERE author = 'reporter'`);233 }234 for (const [table, column] of DROPPED_COLUMNS) {235 if (has(table, column)) db.run(`ALTER TABLE ${table} DROP COLUMN ${column}`);236 }237 db.run("CREATE INDEX IF NOT EXISTS issue_entries_ip ON issue_entries (ip_hash, created_at)");238 return db;239}240241export function setting(db: Database, key: string): string | null {242 const row = db243 .query<{ value: string }, [string]>("SELECT value FROM settings WHERE key = ?")244 .get(key);245 return row?.value ?? null;246}247248249export function renamedTo(db: Database, name: string): string | null {250 const query = db.query<{ new: string }, [string]>("SELECT new FROM renamed WHERE old = ?");251 let to: string | null = null;252 for (let row = query.get(name), hops = 0; row && hops < 5; row = query.get(row.new), hops++) {253 to = row.new;254 }255 return to;256}257258export function isRegistered(db: Database, name: string): boolean {259 return db.query("SELECT 1 FROM repos WHERE name = ?").get(name) !== null;260}261262263export function registerRepo(db: Database, name: string): void {264 db.query("INSERT OR IGNORE INTO repos (name) VALUES (?)").run(name);265}266267268269270271export function notify(db: Database, kind: NotificationKind, text: string, href: string): void {272 const now = Math.floor(Date.now() / 1000);273 274 db.transaction(() => {275 if (!setting(db, `notify_off_${kind}`)) {276 db.query("INSERT INTO notifications (kind, text, href, created_at) VALUES (?, ?, ?, ?)").run(277 kind,278 text,279 href,280 now,281 );282 }283 if (setting(db, `email_on_${kind}`)) {284 db.query(285 "INSERT INTO email_queue (subject, href, next_at, created_at) VALUES (?, ?, ?, ?)",286 ).run(text, href, now, now);287 }288 for (const table of ["notifications", "email_queue"]) {289 290 db.query(291 `DELETE FROM ${table}292 WHERE id NOT IN (SELECT id FROM ${table} ORDER BY id DESC LIMIT ?)`,293 ).run(NOTIFICATIONS_KEPT);294 }295 }).immediate();296}297298299export function recordSync(db: Database, name: string, at: number, error: string | null): void {300 const before = db301 .query<{ last_sync_error: string | null }, [string]>(302 "SELECT last_sync_error FROM repos WHERE name = ?",303 )304 .get(name);305 if (error && !before?.last_sync_error) {306 notify(db, "sync_failed", `Sync failed for ${name}: ${error}`, `/admin/repos?repo=${name}`);307 }308 db.query("UPDATE repos SET last_sync_at = ?, last_sync_error = ? WHERE name = ?").run(309 at,310 error,311 name,312 );313}314315export function publicRepoNames(db: Database): string[] {316 return db317 .query<{ name: string }, []>("SELECT name FROM repos WHERE public = 1 ORDER BY name")318 .all()319 .map((row) => row.name);320}321322323export function publicRepo(db: Database, name: string): RepoSettings | null {324 const row = db325 .query<RepoRow, [string]>(326 `SELECT name, category, homepage, languages, issues_paused, description, details327 FROM repos LEFT JOIN repo_text ON repo_text.repo = repos.name328 WHERE name = ? AND public = 1`,329 )330 .get(name);331 if (!row) return null;332 return {333 name: row.name,334 category: row.category,335 homepage: row.homepage,336 languages: row.languages === "" ? [] : row.languages.split(", "),337 issuesPaused: row.issues_paused === 1,338 description: row.description ?? "",339 details: row.details ?? "",340 };341}342343344345346347export function hiddenPaths(db: Database, repo: string): string[] {348 const row = db349 .query<{ hidden_paths: string }, [string]>("SELECT hidden_paths FROM repos WHERE name = ?")350 .get(repo);351 const paths = row?.hidden_paths ? row.hidden_paths.split("\n") : [];352 return [...paths, ...ignoredNames(db).map((name) => `${ANY_DEPTH}${name}`)];353}354355356export function keepState(db: Database, key: string, value: string | null): void {357 if (value === null) db.query("DELETE FROM settings WHERE key = ?").run(key);358 else db.query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(key, value);359}360361362export function hiddenToHand(db: Database, repo: string): string | null {363 const row = db364 .query<{ hidden_paths: string }, [string]>(365 `SELECT hidden_paths FROM repos366 WHERE name = ?367 AND (hidden_handed IS NULL OR hidden_paths != hidden_handed)`,368 )369 .get(repo);370 return row?.hidden_paths ?? null;371}372373374export function recordHiddenHanded(db: Database, repo: string, list: string | null): void {375 db.query("UPDATE repos SET hidden_handed = ? WHERE name = ?").run(list, repo);376}377378379export function ignoredNames(db: Database): string[] {380 return setting(db, "ignored")?.split("\n").filter(Boolean) ?? [];381}382383384export function repoText(db: Database, repo: string): Record<RepoText, string> {385 return (386 db387 .query<Record<RepoText, string>, [string]>(388 "SELECT description, details FROM repo_text WHERE repo = ?",389 )390 .get(repo) ?? { description: "", details: "" }391 );392}393394395export function setRepoText(db: Database, repo: string, field: RepoText, value: string): void {396 db.query("INSERT OR IGNORE INTO repo_text (repo) VALUES (?)").run(repo);397 398 db.query(`UPDATE repo_text SET ${field} = ? WHERE repo = ?`).run(value, repo);399}