package database import ( "database/sql" "fmt" "os" "path/filepath" _ "github.com/mattn/go-sqlite3" ) type DB struct { *sql.DB } // New opens (or creates) the SQLite database, enables WAL mode, and runs migrations. func New(dbPath string) (*DB, error) { dir := filepath.Dir(dbPath) if err := os.MkdirAll(dir, 0755); err != nil { return nil, fmt.Errorf("create db directory: %w", err) } db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=on") if err != nil { return nil, fmt.Errorf("open database: %w", err) } if err := db.Ping(); err != nil { return nil, fmt.Errorf("ping database: %w", err) } // Enforce WAL mode explicitly if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { return nil, fmt.Errorf("set WAL mode: %w", err) } wrapped := &DB{db} if err := wrapped.migrate(); err != nil { return nil, fmt.Errorf("run migrations: %w", err) } return wrapped, nil } func (db *DB) migrate() error { migrations := []string{ // Users table – single user now, designed for future multi-user `CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, totp_secret TEXT DEFAULT '', totp_enabled INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, // Files metadata table `CREATE TABLE IF NOT EXISTS files ( id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT NOT NULL UNIQUE, name TEXT NOT NULL, is_dir INTEGER DEFAULT 0, size INTEGER DEFAULT 0, mime_type TEXT DEFAULT '', checksum TEXT DEFAULT '', is_pinned INTEGER DEFAULT 0, is_trashed INTEGER DEFAULT 0, trashed_at DATETIME, original_path TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, // Index for fast trash and pin queries `CREATE INDEX IF NOT EXISTS idx_files_trashed ON files(is_trashed)`, `CREATE INDEX IF NOT EXISTS idx_files_pinned ON files(is_pinned)`, `CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)`, // File versions table `CREATE TABLE IF NOT EXISTS file_versions ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_path TEXT NOT NULL, version_number INTEGER NOT NULL, version_path TEXT NOT NULL, size INTEGER DEFAULT 0, checksum TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, // Share links table `CREATE TABLE IF NOT EXISTS share_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT NOT NULL UNIQUE, target_path TEXT NOT NULL, is_dir INTEGER DEFAULT 0, password_hash TEXT DEFAULT '', expires_at DATETIME, max_downloads INTEGER DEFAULT 0, download_count INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, `CREATE INDEX IF NOT EXISTS idx_share_links_token ON share_links(token)`, // Audit log table `CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, action TEXT NOT NULL, details TEXT DEFAULT '', ip_address TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, // Settings table (key-value) `CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT DEFAULT '' )`, // FTS5 virtual table for file search `CREATE VIRTUAL TABLE IF NOT EXISTS files_fts USING fts5( name, path, content=files, content_rowid=id )`, // Triggers to keep FTS5 in sync with the files table `CREATE TRIGGER IF NOT EXISTS files_ai AFTER INSERT ON files BEGIN INSERT INTO files_fts(rowid, name, path) VALUES (new.id, new.name, new.path); END`, `CREATE TRIGGER IF NOT EXISTS files_ad AFTER DELETE ON files BEGIN INSERT INTO files_fts(files_fts, rowid, name, path) VALUES('delete', old.id, old.name, old.path); END`, `CREATE TRIGGER IF NOT EXISTS files_au AFTER UPDATE ON files BEGIN INSERT INTO files_fts(files_fts, rowid, name, path) VALUES('delete', old.id, old.name, old.path); INSERT INTO files_fts(rowid, name, path) VALUES (new.id, new.name, new.path); END`, `INSERT OR IGNORE INTO settings (key, value) VALUES ('theme', 'dark')`, `INSERT OR IGNORE INTO settings (key, value) VALUES ('thumbnail_images', 'true')`, `INSERT OR IGNORE INTO settings (key, value) VALUES ('thumbnail_videos', 'true')`, `INSERT OR IGNORE INTO settings (key, value) VALUES ('trash_auto_purge_days', '30')`, } for _, m := range migrations { if _, err := db.Exec(m); err != nil { return fmt.Errorf("migration error: %w\nSQL: %s", err, m) } } return nil } // IsSetupComplete checks if any user account exists. func (db *DB) IsSetupComplete() (bool, error) { var count int err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) if err != nil { return false, err } return count > 0, nil } // GetSetting retrieves a setting value by key. func (db *DB) GetSetting(key string) (string, error) { var value string err := db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value) return value, err } // SetSetting upserts a setting. func (db *DB) SetSetting(key, value string) error { _, err := db.Exec("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", key, value) return err } // AddAuditLog appends an entry to the audit log. func (db *DB) AddAuditLog(action, details, ip string) error { _, err := db.Exec("INSERT INTO audit_log (action, details, ip_address) VALUES (?, ?, ?)", action, details, ip) return err }