// 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) } // Réparer les colonnes manquantes (bases créées avant le fix multi-statements) if err := db.repairSchema(); err != nil { return nil, fmt.Errorf("réparation schéma : %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 } // repairSchema ajoute les colonnes manquantes dans les bases créées avant le fix // multi-statements des migrations. Migration 002 était partiellement appliquée // (seul user_agent ajouté) sur les bases existantes. func (db *DB) repairSchema() error { type col struct { table, name, def string } needed := []col{ {"refresh_tokens", "user_agent", "TEXT NOT NULL DEFAULT ''"}, {"refresh_tokens", "ip", "TEXT NOT NULL DEFAULT ''"}, {"refresh_tokens", "last_used_at", "DATETIME"}, // Migration 005 : colonnes de navigation des modules (ajout idempotent) {"modules", "nav_href", "TEXT NOT NULL DEFAULT ''"}, {"modules", "nav_icon", "TEXT NOT NULL DEFAULT ''"}, {"modules", "nav_color", "TEXT NOT NULL DEFAULT ''"}, {"modules", "nav_label_key", "TEXT NOT NULL DEFAULT ''"}, {"modules", "repo_url", "TEXT NOT NULL DEFAULT ''"}, } for _, c := range needed { if err := db.ensureColumn(c.table, c.name, c.def); err != nil { return err } } return nil } // ensureColumn ajoute une colonne à une table si elle n'existe pas déjà. func (db *DB) ensureColumn(table, column, definition string) error { rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) if err != nil { return fmt.Errorf("PRAGMA table_info(%s) : %w", table, err) } defer rows.Close() for rows.Next() { var cid int var name, colType string var notNull, pk int var dflt sql.NullString if err := rows.Scan(&cid, &name, &colType, ¬Null, &dflt, &pk); err != nil { return err } if name == column { return nil // déjà présente } } if err := rows.Err(); err != nil { return err } _, err = db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition)) return err }