// Package db gère la connexion SQLite et l'exécution des migrations. // Il expose une instance unique de base de données utilisée par tous les services. package db import ( "database/sql" "embed" "fmt" "os" "path/filepath" "sort" "strconv" "strings" _ "modernc.org/sqlite" // Pilote SQLite pur Go (sans CGO) ) //go:embed migrations/*.sql var migrationsFS embed.FS // DB encapsule la connexion SQLite et expose les méthodes nécessaires. type DB struct { *sql.DB } // Open ouvre (ou crée) la base de données SQLite au chemin donné et exécute les migrations. func Open(dataDir string) (*DB, error) { if err := os.MkdirAll(dataDir, 0700); err != nil { return nil, fmt.Errorf("création répertoire données : %w", err) } dbPath := filepath.Join(dataDir, "panel.db") // Paramètres SQLite : WAL mode pour les lectures concurrentes, foreign keys activées dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)", dbPath) sqlDB, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("ouverture SQLite : %w", err) } // Limiter les connexions simultanées (SQLite n'est pas conçu pour la concurrence élevée) sqlDB.SetMaxOpenConns(1) sqlDB.SetMaxIdleConns(1) if err := sqlDB.Ping(); err != nil { return nil, fmt.Errorf("connexion SQLite : %w", err) } db := &DB{sqlDB} // Exécuter les migrations manquantes if err := db.migrate(); err != nil { return nil, fmt.Errorf("migrations : %w", err) } return db, nil } // migrate applique les fichiers SQL de migration non encore exécutés. // Les fichiers sont numérotés (001_init.sql, 002_xxx.sql) et appliqués dans l'ordre. func (db *DB) migrate() error { // Créer la table schema_version si elle n'existe pas encore // (nécessaire avant de lire la version actuelle) _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER NOT NULL, applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`) if err != nil { return fmt.Errorf("création schema_version : %w", err) } // Lire la version actuelle var currentVersion int row := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`) if err := row.Scan(¤tVersion); err != nil { return fmt.Errorf("lecture version schéma : %w", err) } // Lister et trier les fichiers de migration entries, err := migrationsFS.ReadDir("migrations") if err != nil { return fmt.Errorf("lecture dossier migrations : %w", err) } type migration struct { version int name string } var migrations []migration for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { continue } // Extraire le numéro de version depuis le nom du fichier (ex: "001_init.sql" → 1) parts := strings.SplitN(entry.Name(), "_", 2) if len(parts) < 1 { continue } v, err := strconv.Atoi(parts[0]) if err != nil { continue } migrations = append(migrations, migration{version: v, name: entry.Name()}) } // Trier par numéro de version croissant sort.Slice(migrations, func(i, j int) bool { return migrations[i].version < migrations[j].version }) // Appliquer les migrations manquantes for _, m := range migrations { if m.version <= currentVersion { continue } content, err := migrationsFS.ReadFile("migrations/" + m.name) if err != nil { return fmt.Errorf("lecture migration %s : %w", m.name, err) } // Exécuter dans une transaction pour garantir l'atomicité tx, err := db.Begin() if err != nil { return fmt.Errorf("transaction migration %s : %w", m.name, err) } // Splitter par ";" pour exécuter chaque statement séparément // (SQLite / database/sql n'exécute qu'un seul statement par Exec) for _, stmt := range strings.Split(string(content), ";") { stmt = strings.TrimSpace(stmt) if stmt == "" { continue } if _, err := tx.Exec(stmt); err != nil { tx.Rollback() return fmt.Errorf("exécution migration %s : %w", m.name, err) } } // Mettre à jour la version (la migration 001 l'insère elle-même, pas besoin de le refaire) if m.version > 1 { if _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES (?)`, m.version); err != nil { tx.Rollback() return fmt.Errorf("mise à jour version après migration %s : %w", m.name, err) } } if err := tx.Commit(); err != nil { return fmt.Errorf("commit migration %s : %w", m.name, err) } } return nil } // GetSetting lit un paramètre depuis la table settings. // Retourne "" et nil si la clé n'existe pas. func (db *DB) GetSetting(key string) (string, bool, error) { var value string var encrypted int err := db.QueryRow(`SELECT value, encrypted FROM settings WHERE key = ?`, key).Scan(&value, &encrypted) if err == sql.ErrNoRows { return "", false, nil } if err != nil { return "", false, err } return value, encrypted == 1, nil } // SetSetting enregistre ou met à jour un paramètre. func (db *DB) SetSetting(key, value string, encrypted bool) error { enc := 0 if encrypted { enc = 1 } _, err := db.Exec(` INSERT INTO settings (key, value, encrypted, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value=excluded.value, encrypted=excluded.encrypted, updated_at=excluded.updated_at `, key, value, enc) return err } // IsInstalled vérifie si l'application a déjà été configurée. func (db *DB) IsInstalled() (bool, error) { v, _, err := db.GetSetting("installed") if err != nil { return false, err } return v == "true", nil }