import Database from "better-sqlite3"; import path from "path"; import fs from "fs"; import crypto from "crypto"; const DATA_DIR = process.env.NEXREDIRECT_DATA_DIR || path.join(process.cwd(), "data"); const DB_PATH = path.join(DATA_DIR, "nexredirect.db"); let _db: Database.Database | null = null; export function getDb(): Database.Database { if (_db) return _db; fs.mkdirSync(DATA_DIR, { recursive: true }); const db = new Database(DB_PATH); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); ensureSchema(db); _db = db; return db; } function ensureSchema(db: Database.Database) { db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'admin', created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS domain_groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, target_url TEXT NOT NULL, redirect_code INTEGER NOT NULL DEFAULT 302, created_by INTEGER REFERENCES users(id), created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS domains ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain TEXT UNIQUE NOT NULL, status TEXT NOT NULL DEFAULT 'pending', target_url TEXT, group_id INTEGER REFERENCES domain_groups(id) ON DELETE SET NULL, redirect_code INTEGER NOT NULL DEFAULT 302, preserve_path INTEGER NOT NULL DEFAULT 1, include_www INTEGER NOT NULL DEFAULT 1, created_by INTEGER REFERENCES users(id), created_at INTEGER NOT NULL, verified_at INTEGER ); CREATE INDEX IF NOT EXISTS idx_domains_status ON domains(status); CREATE TABLE IF NOT EXISTS hits ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE, ts INTEGER NOT NULL, ip_hash TEXT NOT NULL, country TEXT, user_agent TEXT, referer TEXT, path TEXT ); CREATE INDEX IF NOT EXISTS idx_hits_domain_ts ON hits(domain_id, ts); CREATE INDEX IF NOT EXISTS idx_hits_ts ON hits(ts); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS api_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, token_hash TEXT UNIQUE NOT NULL, scopes TEXT NOT NULL, created_by INTEGER REFERENCES users(id), created_at INTEGER NOT NULL, last_used_at INTEGER, revoked_at INTEGER ); CREATE TABLE IF NOT EXISTS update_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, from_version TEXT, to_version TEXT, ts INTEGER NOT NULL, status TEXT NOT NULL, log TEXT ); `); db.exec(` CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, user_id INTEGER, user_email TEXT, action TEXT NOT NULL, target_type TEXT, target_id TEXT, details TEXT ); CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts); `); runMigrations(db); } export function logAudit(entry: { user_id?: number | null; user_email?: string | null; action: string; target_type?: string; target_id?: string | number; details?: unknown; }) { try { getDb().prepare(`INSERT INTO audit_log (ts, user_id, user_email, action, target_type, target_id, details) VALUES (?, ?, ?, ?, ?, ?, ?)`).run( Date.now(), entry.user_id ?? null, entry.user_email ?? null, entry.action, entry.target_type ?? null, entry.target_id !== undefined ? String(entry.target_id) : null, entry.details === undefined ? null : JSON.stringify(entry.details), ); } catch { // never block the main flow on audit failure } } function getSettingDirect(db: Database.Database, key: string): string | null { const row = db.prepare("SELECT value FROM settings WHERE key = ?").get(key) as { value: string } | undefined; return row?.value ?? null; } function setSettingDirect(db: Database.Database, key: string, value: string) { db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value); } function hasColumn(db: Database.Database, table: string, col: string): boolean { const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; return cols.some((c) => c.name === col); } function runMigrations(db: Database.Database) { // m_301_to_302: switch existing 301-redirects to 302 so browser-cache stops eating hits. if (getSettingDirect(db, "m_301_to_302") !== "done") { db.prepare("UPDATE domains SET redirect_code = 302 WHERE redirect_code = 301").run(); db.prepare("UPDATE domain_groups SET redirect_code = 302 WHERE redirect_code = 301").run(); setSettingDirect(db, "m_301_to_302", "done"); } // sunset_config column: self-healing — always check schema regardless of setting flag. if (!hasColumn(db, "domains", "sunset_config")) { db.exec("ALTER TABLE domains ADD COLUMN sunset_config TEXT"); } setSettingDirect(db, "m_sunset_column", "done"); } export function getSetting(key: string): string | null { const row = getDb().prepare("SELECT value FROM settings WHERE key = ?").get(key) as { value: string } | undefined; return row?.value ?? null; } export function setSetting(key: string, value: string) { getDb() .prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value") .run(key, value); } export function isSetupComplete(): boolean { return getSetting("setup_complete") === "true"; } export function getDailySalt(): string { const today = new Date().toISOString().slice(0, 10); const stored = getSetting("daily_ip_salt"); if (stored) { const [date, salt] = stored.split("|"); if (date === today) return salt; } const salt = crypto.randomBytes(16).toString("hex"); setSetting("daily_ip_salt", `${today}|${salt}`); return salt; } export function hashIp(ip: string): string { return crypto.createHash("sha256").update(ip + getDailySalt()).digest("hex"); } export type SunsetConfig = { enabled: boolean; title?: string; message?: string; button_label?: string; sunset_date?: string; }; export type DomainRow = { id: number; domain: string; status: "pending" | "active" | "error"; target_url: string | null; group_id: number | null; redirect_code: number; preserve_path: number; include_www: number; created_by: number | null; created_at: number; verified_at: number | null; sunset_config: string | null; }; export function parseSunset(row: { sunset_config?: string | null }): SunsetConfig | null { if (!row.sunset_config) return null; try { const parsed = JSON.parse(row.sunset_config) as SunsetConfig; return parsed.enabled ? parsed : null; } catch { return null; } } export type DomainGroupRow = { id: number; name: string; target_url: string; redirect_code: number; created_by: number | null; created_at: number; }; export type UserRow = { id: number; email: string; username: string | null; password_hash: string; role: string; created_at: number; }; export type ApiTokenRow = { id: number; name: string; token_hash: string; scopes: string; created_by: number | null; created_at: number; last_used_at: number | null; revoked_at: number | null; };