All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m18s
184 lines
5.4 KiB
Go
184 lines
5.4 KiB
Go
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
|
||
}
|