package db import ( "database/sql" "os" "path/filepath" _ "modernc.org/sqlite" ) type DB = sql.DB func Open(path string) (*DB, error) { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return nil, err } database, err := sql.Open("sqlite", path+"?_journal_mode=WAL&_busy_timeout=5000") if err != nil { return nil, err } if err := database.Ping(); err != nil { return nil, err } return database, nil } func Migrate(database *DB) error { _, err := database.Exec(schema) if err != nil { return err } // Add columns that may not exist yet (idempotent) database.Exec("ALTER TABLE users ADD COLUMN totp_pending TEXT") database.Exec("ALTER TABLE users ADD COLUMN audit_log_enabled INTEGER DEFAULT 0") database.Exec(`CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, action TEXT NOT NULL, detail TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) )`) return nil } var schema = ` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, totp_secret TEXT, encryption_enabled INTEGER DEFAULT 0, encryption_salt BLOB, recovery_key_hash TEXT, is_admin INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS files ( id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), path TEXT NOT NULL, title TEXT, encrypted INTEGER DEFAULT 0, encrypted_content BLOB, encrypted_file_key BLOB, sync_flagged INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(owner_id, path) ); CREATE TABLE IF NOT EXISTS virtual_tree ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), parent_id TEXT REFERENCES virtual_tree(id), name TEXT NOT NULL, type TEXT NOT NULL, target_file_id TEXT REFERENCES files(id), sort_order INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS permissions ( id TEXT PRIMARY KEY, file_id TEXT NOT NULL REFERENCES files(id), user_id TEXT NOT NULL REFERENCES users(id), level TEXT NOT NULL, granted_by TEXT NOT NULL REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(file_id, user_id) ); CREATE TABLE IF NOT EXISTS git_remotes ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), name TEXT NOT NULL, url TEXT NOT NULL, auth_token TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, name) ); CREATE TABLE IF NOT EXISTS collab_state ( file_id TEXT PRIMARY KEY REFERENCES files(id), yjs_state BLOB, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), token_hash TEXT NOT NULL, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS api_tokens ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), name TEXT NOT NULL, token_hash TEXT NOT NULL, last_used_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS build_jobs ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), status TEXT NOT NULL DEFAULT 'pending', spec_content TEXT NOT NULL, gitea_url TEXT, gitea_token TEXT, gitea_org TEXT, repo_name TEXT NOT NULL, model TEXT, log TEXT DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS daemon_state ( id TEXT PRIMARY KEY DEFAULT 'singleton', last_heartbeat TEXT, status TEXT DEFAULT 'offline' ); `