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: emails on their way out. An empty recipient is the operator, kepr.toml's address, sent the138-- subject and the link; an issue's follower (§23) is sent the body, unsubscribe as its header.139CREATE TABLE IF NOT EXISTS email_queue (140 id INTEGER PRIMARY KEY,141 subject TEXT NOT NULL,142 href TEXT NOT NULL,143 recipient TEXT NOT NULL DEFAULT '',144 body TEXT NOT NULL DEFAULT '',145 unsubscribe TEXT NOT NULL DEFAULT '',146 status TEXT NOT NULL DEFAULT 'waiting' CHECK (status IN ('waiting', 'sent', 'failed')),147 attempts INTEGER NOT NULL DEFAULT 0,148 next_at INTEGER NOT NULL,149 last_error TEXT NOT NULL DEFAULT '',150 created_at INTEGER NOT NULL,151 sent_at INTEGER152);153-- §23: addresses following an issue by email; nothing is sent to one until it is confirmed.154CREATE TABLE IF NOT EXISTS issue_followers (155 id INTEGER PRIMARY KEY,156 issue_id INTEGER NOT NULL REFERENCES issues (id),157 email TEXT NOT NULL,158 confirmed INTEGER NOT NULL DEFAULT 0,159 ip_hash TEXT NOT NULL DEFAULT '',160 created_at INTEGER NOT NULL,161 UNIQUE (issue_id, email)162);163CREATE INDEX IF NOT EXISTS issue_followers_ip ON issue_followers (ip_hash, created_at);164-- A repo renamed from the console (June, 2026-10-01): its old pages redirect to the new name.165CREATE TABLE IF NOT EXISTS renamed (166 old TEXT PRIMARY KEY,167 new TEXT NOT NULL168);169`;170171172export const NOTIFICATION_KINDS = [173 "issue_filed",174 "issue_followup",175 "sync_failed",176 "backup_failed",177] as const;178export type NotificationKind = (typeof NOTIFICATION_KINDS)[number];179const NOTIFICATIONS_KEPT = 200;180181182const ADDED_COLUMNS: [table: string, column: string, definition: string][] = [183 ["repos", "languages", "TEXT NOT NULL DEFAULT ''"],184 ["repos", "issues_paused", "INTEGER NOT NULL DEFAULT 0"],185 ["issue_entries", "hidden", "INTEGER NOT NULL DEFAULT 0"],186 ["issue_entries", "name", "TEXT NOT NULL DEFAULT ''"],187 ["issue_entries", "ip_hash", "TEXT NOT NULL DEFAULT ''"],188 ["issue_entries", "reply_to", "INTEGER"],189 ["repos", "hidden_paths", "TEXT NOT NULL DEFAULT ''"],190 ["repos", "hidden_handed", "TEXT"],191 ["email_queue", "recipient", "TEXT NOT NULL DEFAULT ''"],192 ["email_queue", "body", "TEXT NOT NULL DEFAULT ''"],193 ["email_queue", "unsubscribe", "TEXT NOT NULL DEFAULT ''"],194];195196const DROPPED_COLUMNS: [table: string, column: string][] = [197 ["repos", "sort_order"],198 ["repos", "install1"],199 ["repos", "install2"],200 ["repos", "install3"],201 202 ["issues", "token_hash"],203];204205206export type RepoSettings = {207 name: string;208 category: string;209 homepage: string;210 211 languages: string[];212 213 issuesPaused: boolean;214 description: string;215 details: string;216};217218export type RepoText = "description" | "details";219220type RepoRow = {221 name: string;222 category: string;223 homepage: string;224 languages: string;225 issues_paused: number;226 description: string | null;227 details: string | null;228};229230export function openKeprDb(dataDir: string): Database {231 mkdirSync(dataDir, { recursive: true });232 const db = new Database(join(dataDir, "kepr.db"), { strict: true });233 db.run("PRAGMA journal_mode = WAL");234 db.run("PRAGMA busy_timeout = 5000");235 db.run(SCHEMA);236 237 const has = (table: string, column: string) =>238 db239 .query<{ name: string }, []>(`PRAGMA table_info(${table})`)240 .all()241 .some((existing) => existing.name === column);242 const named = has("issue_entries", "name");243 for (const [table, column, definition] of ADDED_COLUMNS) {244 if (!has(table, column)) db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);245 }246 247 if (!named) {248 db.run(`UPDATE issue_entries SET name = (249 SELECT reporter_name FROM issues WHERE issues.id = issue_entries.issue_id250) WHERE author = 'reporter'`);251 }252 for (const [table, column] of DROPPED_COLUMNS) {253 if (has(table, column)) db.run(`ALTER TABLE ${table} DROP COLUMN ${column}`);254 }255 db.run("CREATE INDEX IF NOT EXISTS issue_entries_ip ON issue_entries (ip_hash, created_at)");256 return db;257}258259export function setting(db: Database, key: string): string | null {260 const row = db261 .query<{ value: string }, [string]>("SELECT value FROM settings WHERE key = ?")262 .get(key);263 return row?.value ?? null;264}265266267export function renamedTo(db: Database, name: string): string | null {268 const query = db.query<{ new: string }, [string]>("SELECT new FROM renamed WHERE old = ?");269 let to: string | null = null;270 for (let row = query.get(name), hops = 0; row && hops < 5; row = query.get(row.new), hops++) {271 to = row.new;272 }273 return to;274}275276export function isRegistered(db: Database, name: string): boolean {277 return db.query("SELECT 1 FROM repos WHERE name = ?").get(name) !== null;278}279280281export function registerRepo(db: Database, name: string): void {282 db.query("INSERT OR IGNORE INTO repos (name) VALUES (?)").run(name);283}284285286287288289export function notify(db: Database, kind: NotificationKind, text: string, href: string): void {290 const now = Math.floor(Date.now() / 1000);291 292 db.transaction(() => {293 if (!setting(db, `notify_off_${kind}`)) {294 db.query("INSERT INTO notifications (kind, text, href, created_at) VALUES (?, ?, ?, ?)").run(295 kind,296 text,297 href,298 now,299 );300 db.query(301 `DELETE FROM notifications302 WHERE id NOT IN (SELECT id FROM notifications ORDER BY id DESC LIMIT ?)`,303 ).run(NOTIFICATIONS_KEPT);304 }305 if (setting(db, `email_on_${kind}`)) queueEmail(db, { subject: text, href });306 }).immediate();307}308309export type Email = {310 subject: string;311 href: string;312 to?: string;313 text?: string;314 unsubscribe?: string;315};316317318319320321export function queueEmail(db: Database, email: Email): void {322 const now = Math.floor(Date.now() / 1000);323 db.query(324 `INSERT INTO email_queue (subject, href, recipient, body, unsubscribe, next_at, created_at)325 VALUES (?, ?, ?, ?, ?, ?, ?)`,326 ).run(327 email.subject,328 email.href,329 email.to ?? "",330 email.text ?? "",331 email.unsubscribe ?? "",332 now,333 now,334 );335 db.query(336 `DELETE FROM email_queue337 WHERE status != 'waiting' AND id NOT IN (SELECT id FROM email_queue ORDER BY id DESC LIMIT ?)`,338 ).run(NOTIFICATIONS_KEPT);339}340341342export function recordSync(db: Database, name: string, at: number, error: string | null): void {343 const before = db344 .query<{ last_sync_error: string | null }, [string]>(345 "SELECT last_sync_error FROM repos WHERE name = ?",346 )347 .get(name);348 if (error && !before?.last_sync_error) {349 notify(db, "sync_failed", `Sync failed for ${name}: ${error}`, `/admin/repos?repo=${name}`);350 }351 db.query("UPDATE repos SET last_sync_at = ?, last_sync_error = ? WHERE name = ?").run(352 at,353 error,354 name,355 );356}357358export function publicRepoNames(db: Database): string[] {359 return db360 .query<{ name: string }, []>("SELECT name FROM repos WHERE public = 1 ORDER BY name")361 .all()362 .map((row) => row.name);363}364365366export function publicRepo(db: Database, name: string): RepoSettings | null {367 const row = db368 .query<RepoRow, [string]>(369 `SELECT name, category, homepage, languages, issues_paused, description, details370 FROM repos LEFT JOIN repo_text ON repo_text.repo = repos.name371 WHERE name = ? AND public = 1`,372 )373 .get(name);374 if (!row) return null;375 return {376 name: row.name,377 category: row.category,378 homepage: row.homepage,379 languages: row.languages === "" ? [] : row.languages.split(", "),380 issuesPaused: row.issues_paused === 1,381 description: row.description ?? "",382 details: row.details ?? "",383 };384}385386387388389390export function hiddenPaths(db: Database, repo: string): string[] {391 const row = db392 .query<{ hidden_paths: string }, [string]>("SELECT hidden_paths FROM repos WHERE name = ?")393 .get(repo);394 const paths = row?.hidden_paths ? row.hidden_paths.split("\n") : [];395 return [...paths, ...ignoredNames(db).map((name) => `${ANY_DEPTH}${name}`)];396}397398399export function keepState(db: Database, key: string, value: string | null): void {400 if (value === null) db.query("DELETE FROM settings WHERE key = ?").run(key);401 else db.query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(key, value);402}403404405export function hiddenToHand(db: Database, repo: string): string | null {406 const row = db407 .query<{ hidden_paths: string }, [string]>(408 `SELECT hidden_paths FROM repos409 WHERE name = ?410 AND (hidden_handed IS NULL OR hidden_paths != hidden_handed)`,411 )412 .get(repo);413 return row?.hidden_paths ?? null;414}415416417export function recordHiddenHanded(db: Database, repo: string, list: string | null): void {418 db.query("UPDATE repos SET hidden_handed = ? WHERE name = ?").run(list, repo);419}420421422export function ignoredNames(db: Database): string[] {423 return setting(db, "ignored")?.split("\n").filter(Boolean) ?? [];424}425426427export function repoText(db: Database, repo: string): Record<RepoText, string> {428 return (429 db430 .query<Record<RepoText, string>, [string]>(431 "SELECT description, details FROM repo_text WHERE repo = ?",432 )433 .get(repo) ?? { description: "", details: "" }434 );435}436437438export function setRepoText(db: Database, repo: string, field: RepoText, value: string): void {439 db.query("INSERT OR IGNORE INTO repo_text (repo) VALUES (?)").run(repo);440 441 db.query(`UPDATE repo_text SET ${field} = ? WHERE repo = ?`).run(value, repo);442}