Phase 13 : sogoms-cron
- Jobs planifiés avec schedule cron standard
- Types: query_email, http, service
- Actions: list, trigger, status
Phase 16 : Réorganisation config/apps/{app}/
- Tous les fichiers d'une app dans un seul dossier
- Migration prokov vers nouvelle structure
Phase 17 : sogoms-admin
- Interface web d'administration (Go templates + htmx)
- Auth sessions cookies signées HMAC-SHA256
- Rôles super_admin / app_admin avec permissions
Phase 19 : Création d'app via Admin UI
- Formulaire création app avec config DB/auth
- Bouton "Scanner la base" : introspection + schema.yaml
- Rechargement automatique sogoway via SIGHUP
Infrastructure :
- sogoctl : socket de contrôle /run/sogoctl.sock
- sogoway : reload config sur SIGHUP sans restart
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
462 lines
11 KiB
Go
462 lines
11 KiB
Go
// Package config gère le chargement des configurations YAML.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// AppConfig représente la configuration d'une application cliente.
|
|
type AppConfig struct {
|
|
App string `yaml:"app"`
|
|
Version string `yaml:"version"`
|
|
BasePath string `yaml:"base_path"`
|
|
Hosts []string `yaml:"hosts"`
|
|
Database Database `yaml:"database"`
|
|
Auth Auth `yaml:"auth"`
|
|
Routes []Route `yaml:"routes"`
|
|
Queries *Queries // Chargé depuis config/apps/{app}/queries/
|
|
Schema *Schema // Chargé depuis config/apps/{app}/schema.yaml
|
|
}
|
|
|
|
// Queries stocke les requêtes SQL par domaine.
|
|
type Queries struct {
|
|
Auth map[string]any `yaml:",inline"`
|
|
Projects map[string]any `yaml:",inline"`
|
|
Tasks map[string]any `yaml:",inline"`
|
|
Tags map[string]any `yaml:",inline"`
|
|
Statuses map[string]any `yaml:",inline"`
|
|
files map[string]map[string]any // domaine -> clé -> requête
|
|
}
|
|
|
|
// Get retourne une requête par domaine et clé.
|
|
func (q *Queries) Get(domain, key string) string {
|
|
if q == nil || q.files == nil {
|
|
return ""
|
|
}
|
|
if domainMap, ok := q.files[domain]; ok {
|
|
if val, ok := domainMap[key]; ok {
|
|
if s, ok := val.(string); ok {
|
|
return strings.TrimSpace(s)
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// FileCount retourne le nombre de fichiers de queries chargés.
|
|
func (q *Queries) FileCount() int {
|
|
if q == nil || q.files == nil {
|
|
return 0
|
|
}
|
|
return len(q.files)
|
|
}
|
|
|
|
// GetMap retourne une map de requêtes (ex: login_data).
|
|
func (q *Queries) GetMap(domain, key string) map[string]string {
|
|
if q == nil || q.files == nil {
|
|
return nil
|
|
}
|
|
if domainMap, ok := q.files[domain]; ok {
|
|
if val, ok := domainMap[key]; ok {
|
|
if m, ok := val.(map[string]any); ok {
|
|
result := make(map[string]string)
|
|
for k, v := range m {
|
|
if s, ok := v.(string); ok {
|
|
result[k] = strings.TrimSpace(s)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// QueryConfig représente une requête paramétrable.
|
|
type QueryConfig struct {
|
|
Query string
|
|
Filters map[string]string
|
|
Order string
|
|
}
|
|
|
|
// GetQuery retourne une QueryConfig pour un domaine et une clé.
|
|
func (q *Queries) GetQuery(domain, key string) *QueryConfig {
|
|
if q == nil || q.files == nil {
|
|
return nil
|
|
}
|
|
domainMap, ok := q.files[domain]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
val, ok := domainMap[key]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
m, ok := val.(map[string]any)
|
|
if !ok {
|
|
// Ancienne syntaxe : juste une string
|
|
if s, ok := val.(string); ok {
|
|
return &QueryConfig{Query: strings.TrimSpace(s)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
qc := &QueryConfig{
|
|
Filters: make(map[string]string),
|
|
}
|
|
|
|
if query, ok := m["query"].(string); ok {
|
|
qc.Query = strings.TrimSpace(query)
|
|
}
|
|
if order, ok := m["order"].(string); ok {
|
|
qc.Order = strings.TrimSpace(order)
|
|
}
|
|
if filters, ok := m["filters"].(map[string]any); ok {
|
|
for k, v := range filters {
|
|
if s, ok := v.(string); ok {
|
|
qc.Filters[k] = strings.TrimSpace(s)
|
|
}
|
|
}
|
|
}
|
|
|
|
return qc
|
|
}
|
|
|
|
// CUDConfig représente une config pour Create/Update/Delete.
|
|
type CUDConfig struct {
|
|
Table string
|
|
Fields []string
|
|
Filters map[string]string
|
|
}
|
|
|
|
// GetCUD retourne une CUDConfig pour un domaine et une clé (create/update/delete).
|
|
func (q *Queries) GetCUD(domain, key string) *CUDConfig {
|
|
if q == nil || q.files == nil {
|
|
return nil
|
|
}
|
|
domainMap, ok := q.files[domain]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
val, ok := domainMap[key]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
m, ok := val.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
cud := &CUDConfig{
|
|
Filters: make(map[string]string),
|
|
}
|
|
|
|
if table, ok := m["table"].(string); ok {
|
|
cud.Table = table
|
|
}
|
|
if fields, ok := m["fields"].([]any); ok {
|
|
for _, f := range fields {
|
|
if s, ok := f.(string); ok {
|
|
cud.Fields = append(cud.Fields, s)
|
|
}
|
|
}
|
|
}
|
|
if filters, ok := m["filters"].(map[string]any); ok {
|
|
for k, v := range filters {
|
|
if s, ok := v.(string); ok {
|
|
cud.Filters[k] = strings.TrimSpace(s)
|
|
}
|
|
}
|
|
}
|
|
|
|
return cud
|
|
}
|
|
|
|
// GetFilter retourne le filtre pour un rôle donné.
|
|
func (cud *CUDConfig) GetFilter(role string) string {
|
|
if cud == nil {
|
|
return ""
|
|
}
|
|
if f, ok := cud.Filters[role]; ok {
|
|
return f
|
|
}
|
|
if f, ok := cud.Filters["default"]; ok {
|
|
return f
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Build construit la requête SQL finale avec filtres et ordre.
|
|
// role: rôle de l'utilisateur (ou "default")
|
|
// params: map de placeholders à remplacer (:user_id, :id, etc.)
|
|
func (qc *QueryConfig) Build(role string, params map[string]any) (string, []any) {
|
|
if qc == nil {
|
|
return "", nil
|
|
}
|
|
|
|
query := qc.Query
|
|
|
|
// Déterminer le filtre à appliquer
|
|
filter := ""
|
|
if f, ok := qc.Filters[role]; ok {
|
|
filter = f
|
|
} else if f, ok := qc.Filters["default"]; ok {
|
|
filter = f
|
|
}
|
|
|
|
// Construire la requête
|
|
hasWhere := strings.Contains(strings.ToUpper(query), " WHERE ")
|
|
|
|
if filter != "" {
|
|
if hasWhere {
|
|
query += " AND " + filter
|
|
} else {
|
|
query += " WHERE " + filter
|
|
}
|
|
}
|
|
|
|
if qc.Order != "" {
|
|
query += " ORDER BY " + qc.Order
|
|
}
|
|
|
|
// Remplacer les placeholders :name par ? et collecter les args
|
|
var args []any
|
|
for {
|
|
idx := strings.Index(query, ":")
|
|
if idx == -1 {
|
|
break
|
|
}
|
|
// Trouver la fin du placeholder
|
|
end := idx + 1
|
|
for end < len(query) && (query[end] == '_' || (query[end] >= 'a' && query[end] <= 'z') || (query[end] >= 'A' && query[end] <= 'Z') || (query[end] >= '0' && query[end] <= '9')) {
|
|
end++
|
|
}
|
|
placeholder := query[idx+1 : end]
|
|
if val, ok := params[placeholder]; ok {
|
|
args = append(args, val)
|
|
query = query[:idx] + "?" + query[end:]
|
|
} else {
|
|
// Placeholder non trouvé, on laisse tel quel (peut être une erreur)
|
|
break
|
|
}
|
|
}
|
|
|
|
return query, args
|
|
}
|
|
|
|
// Auth contient la configuration d'authentification.
|
|
type Auth struct {
|
|
JWTSecretFile string `yaml:"jwt_secret_file"`
|
|
JWTExpiry string `yaml:"jwt_expiry"`
|
|
jwtSecret string // Chargé depuis le fichier
|
|
}
|
|
|
|
// JWTSecret retourne le secret JWT (chargé depuis le fichier).
|
|
func (a *Auth) JWTSecret() string {
|
|
return a.jwtSecret
|
|
}
|
|
|
|
// Database contient la configuration de connexion à la base de données.
|
|
type Database struct {
|
|
Host string `yaml:"host"`
|
|
Port int `yaml:"port"`
|
|
User string `yaml:"user"`
|
|
PasswordFile string `yaml:"password_file"`
|
|
Name string `yaml:"name"`
|
|
password string // Chargé depuis le fichier
|
|
}
|
|
|
|
// Password retourne le mot de passe (chargé depuis le fichier).
|
|
func (d *Database) Password() string {
|
|
return d.password
|
|
}
|
|
|
|
// DSN retourne la chaîne de connexion MySQL/MariaDB.
|
|
func (d *Database) DSN() string {
|
|
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4",
|
|
d.User, d.password, d.Host, d.Port, d.Name)
|
|
}
|
|
|
|
// Route représente une route API.
|
|
type Route struct {
|
|
Path string `yaml:"path"`
|
|
Method string `yaml:"method"`
|
|
Scenario string `yaml:"scenario"`
|
|
Auth *bool `yaml:"auth,omitempty"`
|
|
}
|
|
|
|
// Registry stocke les configurations des applications.
|
|
type Registry struct {
|
|
configDir string
|
|
apps map[string]*AppConfig // Par app_id
|
|
byHost map[string]*AppConfig // Par hostname
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewRegistry crée un nouveau registre de configurations.
|
|
func NewRegistry(configDir string) *Registry {
|
|
return &Registry{
|
|
configDir: configDir,
|
|
apps: make(map[string]*AppConfig),
|
|
byHost: make(map[string]*AppConfig),
|
|
}
|
|
}
|
|
|
|
// Load charge toutes les configurations depuis le répertoire apps.
|
|
func (r *Registry) Load() error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
appsDir := filepath.Join(r.configDir, "apps")
|
|
entries, err := os.ReadDir(appsDir)
|
|
if err != nil {
|
|
return fmt.Errorf("read apps dir: %w", err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
appID := entry.Name()
|
|
appConfigPath := filepath.Join(appsDir, appID, "app.yaml")
|
|
|
|
// Vérifier que app.yaml existe
|
|
if _, err := os.Stat(appConfigPath); os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
|
|
cfg, err := r.loadAppConfig(appConfigPath, appID)
|
|
if err != nil {
|
|
return fmt.Errorf("load %s: %w", appID, err)
|
|
}
|
|
|
|
r.apps[cfg.App] = cfg
|
|
for _, host := range cfg.Hosts {
|
|
r.byHost[host] = cfg
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// loadAppConfig charge une configuration d'application depuis un fichier YAML.
|
|
func (r *Registry) loadAppConfig(path string, appID string) (*AppConfig, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var cfg AppConfig
|
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Charger le mot de passe DB depuis le fichier
|
|
if cfg.Database.PasswordFile != "" {
|
|
passData, err := os.ReadFile(cfg.Database.PasswordFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read db password file: %w", err)
|
|
}
|
|
cfg.Database.password = strings.TrimSpace(string(passData))
|
|
}
|
|
|
|
// Charger le secret JWT depuis le fichier
|
|
if cfg.Auth.JWTSecretFile != "" {
|
|
secretData, err := os.ReadFile(cfg.Auth.JWTSecretFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read jwt secret file: %w", err)
|
|
}
|
|
cfg.Auth.jwtSecret = strings.TrimSpace(string(secretData))
|
|
}
|
|
|
|
// Port DB par défaut
|
|
if cfg.Database.Port == 0 {
|
|
cfg.Database.Port = 3306
|
|
}
|
|
|
|
// Expiry JWT par défaut
|
|
if cfg.Auth.JWTExpiry == "" {
|
|
cfg.Auth.JWTExpiry = "24h"
|
|
}
|
|
|
|
// Charger les requêtes depuis config/apps/{app}/queries/
|
|
cfg.Queries = r.loadQueries(appID)
|
|
|
|
// Charger le schema depuis config/apps/{app}/schema.yaml
|
|
cfg.Schema = loadSchema(r.configDir, appID)
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
// loadQueries charge les fichiers de requêtes pour une application.
|
|
func (r *Registry) loadQueries(appID string) *Queries {
|
|
queriesDir := filepath.Join(r.configDir, "apps", appID, "queries")
|
|
entries, err := os.ReadDir(queriesDir)
|
|
if err != nil {
|
|
return nil // Pas de répertoire queries, c'est OK
|
|
}
|
|
|
|
q := &Queries{
|
|
files: make(map[string]map[string]any),
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
|
|
continue
|
|
}
|
|
|
|
domain := strings.TrimSuffix(entry.Name(), ".yaml")
|
|
path := filepath.Join(queriesDir, entry.Name())
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
var content map[string]any
|
|
if err := yaml.Unmarshal(data, &content); err != nil {
|
|
continue
|
|
}
|
|
|
|
q.files[domain] = content
|
|
}
|
|
|
|
return q
|
|
}
|
|
|
|
// GetByApp retourne la configuration d'une application par son ID.
|
|
func (r *Registry) GetByApp(appID string) (*AppConfig, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
cfg, ok := r.apps[appID]
|
|
return cfg, ok
|
|
}
|
|
|
|
// GetByHost retourne la configuration d'une application par son hostname.
|
|
func (r *Registry) GetByHost(host string) (*AppConfig, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
cfg, ok := r.byHost[host]
|
|
return cfg, ok
|
|
}
|
|
|
|
// Apps retourne la liste des IDs d'applications chargées.
|
|
func (r *Registry) Apps() []string {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
apps := make([]string, 0, len(r.apps))
|
|
for app := range r.apps {
|
|
apps = append(apps, app)
|
|
}
|
|
return apps
|
|
}
|