Files
sogoms/internal/config/schema.go
Pierre 0b1977e0c4 SOGOMS v1.0.7 - 2FA obligatoire et Infrastructure Management
Phase 17g - Double Authentification:
- TOTP avec Google Authenticator/Authy
- QR code pour enrôlement
- Codes de backup (10 codes usage unique)
- Page /admin/security pour gestion 2FA
- Page /admin/users avec Reset 2FA (super_admin)
- 2FA obligatoire pour rôles configurés

Phase 21 - Infrastructure Management:
- SQLite pour données infra (/data/infra.db)
- SSH Pool avec reconnexion auto
- Gestion Incus (list, start, stop, restart, sync)
- Gestion Nginx (test, reload, deploy, sync, certbot)
- Interface admin /admin/infra
- Formulaire ajout serveur
- Page détail serveur avec containers et sites

Fichiers créés:
- internal/infra/ (db, models, migrations, repository, ssh, incus, nginx)
- cmd/sogoms/admin/totp.go
- cmd/sogoms/admin/handlers_2fa.go
- cmd/sogoms/admin/handlers_infra.go
- Templates: 2fa_*, security, users, infra, server_*

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 21:21:11 +01:00

321 lines
8.4 KiB
Go

// Package config - Schema représente la structure de la base de données.
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// Schema représente le schema d'une application.
type Schema struct {
App string `yaml:"app"`
Version string `yaml:"version"`
Tables map[string]*Table `yaml:"tables"`
}
// Table représente une table de la base de données.
type Table struct {
Columns map[string]*Column `yaml:"columns"`
Primary []string `yaml:"primary,omitempty"` // Clé primaire composite
CRUD []string `yaml:"crud"`
Order string `yaml:"order,omitempty"`
SoftDelete bool `yaml:"soft_delete,omitempty"` // Si true, DELETE → UPDATE deleted_at
Cascade bool `yaml:"cascade,omitempty"` // Si true, soft delete en cascade sur enfants
}
// Column représente une colonne d'une table.
type Column struct {
Type string `yaml:"type"`
Length int64 `yaml:"length,omitempty"`
Required bool `yaml:"required,omitempty"`
Primary bool `yaml:"primary,omitempty"`
Auto bool `yaml:"auto,omitempty"`
Unique bool `yaml:"unique,omitempty"`
Default string `yaml:"default,omitempty"`
Foreign string `yaml:"foreign,omitempty"` // table.column
Filter string `yaml:"filter,omitempty"` // "owner" pour filtrage auto
}
// HasCRUD vérifie si une opération CRUD est autorisée pour cette table.
func (t *Table) HasCRUD(op string) bool {
for _, c := range t.CRUD {
if c == op {
return true
}
}
return false
}
// GetOwnerColumn retourne le nom de la colonne avec filter: owner, ou "".
func (t *Table) GetOwnerColumn() string {
for name, col := range t.Columns {
if col.Filter == "owner" {
return name
}
}
return ""
}
// GetPrimaryKey retourne le nom de la clé primaire (simple).
func (t *Table) GetPrimaryKey() string {
// D'abord chercher dans Primary (composite)
if len(t.Primary) == 1 {
return t.Primary[0]
}
// Sinon chercher une colonne avec primary: true
for name, col := range t.Columns {
if col.Primary {
return name
}
}
return "id" // Par défaut
}
// IsCompositePK vérifie si la table a une clé primaire composite.
func (t *Table) IsCompositePK() bool {
return len(t.Primary) > 1
}
// IsSoftDelete vérifie si la table utilise le soft delete.
func (t *Table) IsSoftDelete() bool {
return t.SoftDelete
}
// IsCascade vérifie si le cascade delete est activé.
func (t *Table) IsCascade() bool {
return t.Cascade
}
// ChildRelation représente une relation enfant (FK vers table parent).
type ChildRelation struct {
ChildTable string // Nom de la table enfant
ChildColumn string // Colonne FK dans la table enfant
ParentTable string // Nom de la table parent
}
// GetChildRelations retourne les tables enfants qui ont une FK vers parentTable.
func (s *Schema) GetChildRelations(parentTable string) []ChildRelation {
var children []ChildRelation
for tableName, table := range s.Tables {
if tableName == parentTable {
continue // Skip la table elle-même
}
for colName, col := range table.Columns {
if col.Foreign != "" {
// col.Foreign = "table.column"
parts := strings.Split(col.Foreign, ".")
if len(parts) >= 1 && parts[0] == parentTable {
children = append(children, ChildRelation{
ChildTable: tableName,
ChildColumn: colName,
ParentTable: parentTable,
})
}
}
}
}
return children
}
// GetSelectColumns retourne la liste des colonnes pour un SELECT.
func (t *Table) GetSelectColumns() []string {
cols := make([]string, 0, len(t.Columns))
for name := range t.Columns {
cols = append(cols, name)
}
return cols
}
// GetInsertColumns retourne les colonnes pour un INSERT (exclut auto-increment).
func (t *Table) GetInsertColumns() []string {
cols := make([]string, 0, len(t.Columns))
for name, col := range t.Columns {
if !col.Auto {
cols = append(cols, name)
}
}
return cols
}
// GetUpdateColumns retourne les colonnes pour un UPDATE (exclut PK et auto).
func (t *Table) GetUpdateColumns() []string {
cols := make([]string, 0, len(t.Columns))
pk := t.GetPrimaryKey()
for name, col := range t.Columns {
if name != pk && !col.Auto && col.Filter != "owner" {
cols = append(cols, name)
}
}
return cols
}
// BuildListQuery génère la requête SELECT pour list.
// Filtre automatiquement les enregistrements supprimés si soft_delete est activé.
func (t *Table) BuildListQuery(tableName string) string {
cols := t.GetSelectColumns()
query := fmt.Sprintf("SELECT %s FROM %s", strings.Join(cols, ", "), tableName)
// Construire les conditions WHERE
var conditions []string
// Filtre owner si présent
if ownerCol := t.GetOwnerColumn(); ownerCol != "" {
conditions = append(conditions, fmt.Sprintf("%s = ?", ownerCol))
}
// Filtre soft delete
if t.SoftDelete {
conditions = append(conditions, "deleted_at IS NULL")
}
if len(conditions) > 0 {
query += " WHERE " + strings.Join(conditions, " AND ")
}
// Ajouter ORDER BY si défini
if t.Order != "" {
query += " ORDER BY " + t.Order
}
return query
}
// BuildShowQuery génère la requête SELECT pour show (un seul enregistrement).
// Filtre automatiquement les enregistrements supprimés si soft_delete est activé.
func (t *Table) BuildShowQuery(tableName string) string {
cols := t.GetSelectColumns()
pk := t.GetPrimaryKey()
query := fmt.Sprintf("SELECT %s FROM %s WHERE %s = ?", strings.Join(cols, ", "), tableName, pk)
// Ajouter filtre owner si présent
if ownerCol := t.GetOwnerColumn(); ownerCol != "" {
query += fmt.Sprintf(" AND %s = ?", ownerCol)
}
// Filtre soft delete
if t.SoftDelete {
query += " AND deleted_at IS NULL"
}
return query
}
// BuildInsertQuery génère la requête INSERT.
func (t *Table) BuildInsertQuery(tableName string, data map[string]any) (string, []any) {
cols := make([]string, 0)
placeholders := make([]string, 0)
args := make([]any, 0)
for name, col := range t.Columns {
if col.Auto {
continue // Skip auto-increment
}
if val, ok := data[name]; ok {
cols = append(cols, name)
placeholders = append(placeholders, "?")
args = append(args, val)
} else if col.Required && col.Default == "" {
// Champ requis sans valeur et sans default - sera une erreur DB
continue
}
}
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
tableName,
strings.Join(cols, ", "),
strings.Join(placeholders, ", "))
return query, args
}
// BuildUpdateQuery génère la requête UPDATE.
func (t *Table) BuildUpdateQuery(tableName string, id any, ownerID any, data map[string]any) (string, []any) {
setClauses := make([]string, 0)
args := make([]any, 0)
pk := t.GetPrimaryKey()
ownerCol := t.GetOwnerColumn()
for name := range t.Columns {
// Skip PK, auto, et owner
if name == pk || t.Columns[name].Auto || name == ownerCol {
continue
}
if val, ok := data[name]; ok {
setClauses = append(setClauses, name+" = ?")
args = append(args, val)
}
}
if len(setClauses) == 0 {
return "", nil
}
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s = ?",
tableName,
strings.Join(setClauses, ", "),
pk)
args = append(args, id)
// Ajouter filtre owner
if ownerCol != "" && ownerID != nil {
query += fmt.Sprintf(" AND %s = ?", ownerCol)
args = append(args, ownerID)
}
return query, args
}
// BuildDeleteQuery génère la requête DELETE.
func (t *Table) BuildDeleteQuery(tableName string, id any, ownerID any) (string, []any) {
pk := t.GetPrimaryKey()
query := fmt.Sprintf("DELETE FROM %s WHERE %s = ?", tableName, pk)
args := []any{id}
// Ajouter filtre owner
if ownerCol := t.GetOwnerColumn(); ownerCol != "" && ownerID != nil {
query += fmt.Sprintf(" AND %s = ?", ownerCol)
args = append(args, ownerID)
}
return query, args
}
// ValidateInput valide les données d'entrée selon le schema.
// Retourne les données filtrées (seuls les champs connus sont gardés).
func (t *Table) ValidateInput(data map[string]any) map[string]any {
filtered := make(map[string]any)
for name := range t.Columns {
if val, ok := data[name]; ok {
filtered[name] = val
}
}
return filtered
}
// loadSchema charge le schema depuis config/apps/{app}/schema.yaml.
func loadSchema(configDir, appID string) *Schema {
schemaPath := filepath.Join(configDir, "apps", appID, "schema.yaml")
data, err := os.ReadFile(schemaPath)
if err != nil {
return nil // Pas de schema, c'est OK
}
var schema Schema
if err := yaml.Unmarshal(data, &schema); err != nil {
return nil
}
return &schema
}