import { mkdirSync } from "node:fs"; import { unlink } from "node:fs/promises"; import { dirname, isAbsolute, resolve } from "node:path"; import { Database } from "bun:sqlite"; import { config } from "./config"; export type ProfileRow = { email: string; display_name: string; avatar_path: string | null; role: "admin" | "user"; totp_secret: string | null; totp_enabled: number; created_at: number; updated_at: number; }; type AdminRow = { email: string; password_hash: string; created_at: number; updated_at: number; }; type ForwardingRow = { email: string; target_email: string | null; enabled: number; updated_at: number; }; mkdirSync(dirname(config.dbPath), { recursive: true }); mkdirSync(config.avatarDir, { recursive: true }); const db = new Database(config.dbPath, { create: true }); db.exec("PRAGMA journal_mode = WAL;"); db.exec("PRAGMA foreign_keys = ON;"); db.exec(` CREATE TABLE IF NOT EXISTS user_profiles ( email TEXT PRIMARY KEY, display_name TEXT NOT NULL, avatar_path TEXT, role TEXT NOT NULL CHECK(role IN ('admin', 'user')), totp_secret TEXT, totp_enabled INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS app_admins ( email TEXT PRIMARY KEY, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS forwarding_rules ( email TEXT PRIMARY KEY, target_email TEXT, enabled INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL ); `); const defaultDisplayName = (email: string): string => email.split("@")[0] ?? email; const toAbsoluteAvatarPath = (avatarPath: string | null): string | null => avatarPath ? (isAbsolute(avatarPath) ? avatarPath : resolve(process.cwd(), avatarPath)) : null; const getProfileStatement = db.query("SELECT * FROM user_profiles WHERE email = ?1"); const insertProfileStatement = db.query(` INSERT OR IGNORE INTO user_profiles ( email, display_name, avatar_path, role, totp_secret, totp_enabled, created_at, updated_at ) VALUES (?1, ?2, NULL, ?3, NULL, 0, ?4, ?4) `); const updateProfileStatement = db.query(` UPDATE user_profiles SET display_name = ?1, updated_at = ?2 WHERE email = ?3 `); const updateAvatarStatement = db.query(` UPDATE user_profiles SET avatar_path = ?1, updated_at = ?2 WHERE email = ?3 `); const updateTotpStatement = db.query(` UPDATE user_profiles SET totp_secret = ?1, totp_enabled = ?2, updated_at = ?3 WHERE email = ?4 `); const getAdminStatement = db.query("SELECT * FROM app_admins WHERE email = ?1"); const upsertAdminStatement = db.query(` INSERT INTO app_admins (email, password_hash, created_at, updated_at) VALUES (?1, ?2, ?3, ?3) ON CONFLICT(email) DO UPDATE SET password_hash = excluded.password_hash, updated_at = excluded.updated_at `); const getForwardingStatement = db.query("SELECT * FROM forwarding_rules WHERE email = ?1"); const upsertForwardingStatement = db.query(` INSERT INTO forwarding_rules (email, target_email, enabled, updated_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(email) DO UPDATE SET target_email = excluded.target_email, enabled = excluded.enabled, updated_at = excluded.updated_at `); export const getProfile = (email: string): ProfileRow | null => (getProfileStatement.get(email) as ProfileRow | null) ?? null; export const ensureProfile = (email: string, role: "admin" | "user" = "user"): ProfileRow => { const now = Date.now(); insertProfileStatement.run(email, defaultDisplayName(email), role, now); return getProfile(email)!; }; export const updateProfile = (email: string, displayName: string): ProfileRow => { updateProfileStatement.run(displayName, Date.now(), email); return getProfile(email)!; }; export const setAvatarPath = (email: string, avatarPath: string | null): ProfileRow => { updateAvatarStatement.run(avatarPath, Date.now(), email); return getProfile(email)!; }; export const setTotpSecret = (email: string, secret: string | null, enabled: boolean): ProfileRow => { updateTotpStatement.run(secret, enabled ? 1 : 0, Date.now(), email); return getProfile(email)!; }; export const getAdmin = (email: string): AdminRow | null => (getAdminStatement.get(email) as AdminRow | null) ?? null; export const upsertAdmin = (email: string, passwordHash: string): void => { upsertAdminStatement.run(email, passwordHash, Date.now()); ensureProfile(email, "admin"); }; export const getForwardingRule = (email: string): ForwardingRow | null => (getForwardingStatement.get(email) as ForwardingRow | null) ?? null; export const upsertForwardingRule = (email: string, targetEmail: string | null, enabled: boolean): ForwardingRow => { const now = Date.now(); upsertForwardingStatement.run(email, targetEmail, enabled ? 1 : 0, now); return getForwardingRule(email)!; }; export const deleteAvatarFile = async (avatarPath: string | null): Promise => { if (!avatarPath) { return; } const absolutePath = toAbsoluteAvatarPath(avatarPath); if (!absolutePath || !absolutePath.startsWith(config.avatarDir)) { return; } try { await unlink(absolutePath); } catch { // Best-effort cleanup of replaced avatars. } };