- fetchMe: handle ALL non-ok responses (not just 401) by calling tryRefresh → avoids user=null when backend returns 404/500/any error - DOMContentLoaded guard: check isAuthenticated instead of localStorage token → immediate redirect if fetchMe+tryRefresh both fail, no more flash of dashboard - Cookie Secure flag: check X-Forwarded-Proto header for Traefik/proxy setup → cookie gets Secure=true when behind TLS-terminating reverse proxy - db.go migrate(): split SQL by ; and exec each statement separately → fixes SQLite multi-statement limitation (only first stmt was executed) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
193 lines
5.4 KiB
Go
193 lines
5.4 KiB
Go
// 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
|
|
}
|