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>
962 lines
23 KiB
Go
962 lines
23 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
"sogoms.com/internal/protocol"
|
|
)
|
|
|
|
// ServicePool centralise les connexions vers les microservices.
|
|
type ServicePool struct {
|
|
DB *protocol.Pool
|
|
Logs *protocol.Pool
|
|
Cron *protocol.Pool
|
|
}
|
|
|
|
// ServiceStatus représente le statut d'un service.
|
|
type ServiceStatus struct {
|
|
Name string `json:"name"`
|
|
Available bool `json:"available"`
|
|
LatencyMs int64 `json:"latency_ms"`
|
|
}
|
|
|
|
// HealthCheck vérifie l'état de tous les services.
|
|
func (sp *ServicePool) HealthCheck() []ServiceStatus {
|
|
statuses := make([]ServiceStatus, 0, 3)
|
|
|
|
// Check sogoms-db
|
|
if sp.DB != nil {
|
|
statuses = append(statuses, sp.checkService("sogoms-db", sp.DB))
|
|
}
|
|
|
|
// Check sogoms-logs
|
|
if sp.Logs != nil {
|
|
statuses = append(statuses, sp.checkService("sogoms-logs", sp.Logs))
|
|
}
|
|
|
|
// Check sogoms-cron
|
|
if sp.Cron != nil {
|
|
statuses = append(statuses, sp.checkService("sogoms-cron", sp.Cron))
|
|
}
|
|
|
|
return statuses
|
|
}
|
|
|
|
// checkService vérifie un service individuel.
|
|
func (sp *ServicePool) checkService(name string, pool *protocol.Pool) ServiceStatus {
|
|
status := ServiceStatus{
|
|
Name: name,
|
|
Available: false,
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
start := time.Now()
|
|
req := protocol.NewRequest("health", nil)
|
|
resp, err := pool.Call(ctx, req)
|
|
|
|
status.LatencyMs = time.Since(start).Milliseconds()
|
|
|
|
if err == nil && resp != nil && resp.Status == "success" {
|
|
status.Available = true
|
|
}
|
|
|
|
return status
|
|
}
|
|
|
|
// GetCronJobs récupère la liste des jobs cron.
|
|
func (sp *ServicePool) GetCronJobs() ([]map[string]any, error) {
|
|
if sp.Cron == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
req := protocol.NewRequest("list", nil)
|
|
resp, err := sp.Cron.Call(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if resp.Status != "success" {
|
|
return nil, nil
|
|
}
|
|
|
|
// Extraire les jobs
|
|
if result, ok := resp.Result.(map[string]any); ok {
|
|
if jobs, ok := result["jobs"].([]any); ok {
|
|
jobList := make([]map[string]any, 0, len(jobs))
|
|
for _, j := range jobs {
|
|
if job, ok := j.(map[string]any); ok {
|
|
jobList = append(jobList, job)
|
|
}
|
|
}
|
|
return jobList, nil
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// TriggerCronJob déclenche un job cron manuellement.
|
|
func (sp *ServicePool) TriggerCronJob(appID, jobName string) error {
|
|
if sp.Cron == nil {
|
|
return nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
req := protocol.NewRequest("trigger", map[string]any{
|
|
"app_id": appID,
|
|
"job": jobName,
|
|
})
|
|
_, err := sp.Cron.Call(ctx, req)
|
|
return err
|
|
}
|
|
|
|
// GetCronHistory récupère l'historique des exécutions cron.
|
|
func (sp *ServicePool) GetCronHistory(appID string, limit int) ([]map[string]any, error) {
|
|
if sp.Cron == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
params := map[string]any{"limit": limit}
|
|
if appID != "" {
|
|
params["app_id"] = appID
|
|
}
|
|
|
|
req := protocol.NewRequest("status", params)
|
|
resp, err := sp.Cron.Call(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if resp.Status != "success" {
|
|
return nil, nil
|
|
}
|
|
|
|
// Extraire les exécutions
|
|
if result, ok := resp.Result.(map[string]any); ok {
|
|
if execs, ok := result["executions"].([]any); ok {
|
|
execList := make([]map[string]any, 0, len(execs))
|
|
for _, e := range execs {
|
|
if exec, ok := e.(map[string]any); ok {
|
|
execList = append(execList, exec)
|
|
}
|
|
}
|
|
return execList, nil
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// CreateApp crée une nouvelle application avec sa configuration.
|
|
func (sp *ServicePool) CreateApp(appName, version, basePath string, hosts []string, dbHost, dbPort, dbUser, dbName, dbPassword, jwtExpiry string) error {
|
|
configDir := "/config"
|
|
secretsDir := "/secrets"
|
|
|
|
// Créer le dossier de l'app
|
|
appDir := filepath.Join(configDir, "apps", appName)
|
|
if err := os.MkdirAll(appDir, 0755); err != nil {
|
|
return fmt.Errorf("mkdir app: %w", err)
|
|
}
|
|
|
|
// Générer le JWT secret
|
|
jwtSecret := make([]byte, 32)
|
|
if _, err := rand.Read(jwtSecret); err != nil {
|
|
return fmt.Errorf("generate jwt secret: %w", err)
|
|
}
|
|
jwtSecretB64 := base64.StdEncoding.EncodeToString(jwtSecret)
|
|
|
|
// Créer les fichiers secrets
|
|
dbPassFile := filepath.Join(secretsDir, appName+"_db_pass")
|
|
if err := os.WriteFile(dbPassFile, []byte(dbPassword), 0600); err != nil {
|
|
return fmt.Errorf("write db password: %w", err)
|
|
}
|
|
|
|
jwtSecretFile := filepath.Join(secretsDir, appName+"_jwt_secret")
|
|
if err := os.WriteFile(jwtSecretFile, []byte(jwtSecretB64), 0600); err != nil {
|
|
return fmt.Errorf("write jwt secret: %w", err)
|
|
}
|
|
|
|
// Générer app.yaml
|
|
hostsYAML := ""
|
|
for _, h := range hosts {
|
|
hostsYAML += fmt.Sprintf(" - %s\n", h)
|
|
}
|
|
|
|
appYAML := fmt.Sprintf(`# Application %s
|
|
# Générée automatiquement par sogoms-admin
|
|
|
|
app: %s
|
|
version: "%s"
|
|
base_path: %s
|
|
|
|
hosts:
|
|
%s
|
|
database:
|
|
host: %s
|
|
port: %s
|
|
user: %s
|
|
password_file: %s
|
|
name: %s
|
|
|
|
auth:
|
|
jwt_secret_file: %s
|
|
jwt_expiry: %s
|
|
|
|
logs:
|
|
retention_days: 30
|
|
|
|
routes: []
|
|
`, appName, appName, version, basePath, hostsYAML, dbHost, dbPort, dbUser, dbPassFile, dbName, jwtSecretFile, jwtExpiry)
|
|
|
|
appYAMLFile := filepath.Join(appDir, "app.yaml")
|
|
if err := os.WriteFile(appYAMLFile, []byte(appYAML), 0644); err != nil {
|
|
return fmt.Errorf("write app.yaml: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ScanAndGenerateSchema introspect la DB et génère schema.yaml.
|
|
// Retourne le nombre de tables détectées.
|
|
func (sp *ServicePool) ScanAndGenerateSchema(appID string) (int, error) {
|
|
if sp.DB == nil {
|
|
return 0, fmt.Errorf("db service not available")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
// Appeler l'introspection
|
|
req := protocol.NewRequest("introspect", map[string]any{
|
|
"app_id": appID,
|
|
})
|
|
resp, err := sp.DB.Call(ctx, req)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("introspect call: %w", err)
|
|
}
|
|
|
|
if resp.Status != "success" {
|
|
if resp.Error != nil {
|
|
return 0, fmt.Errorf("introspect failed: %s", resp.Error.Message)
|
|
}
|
|
return 0, fmt.Errorf("introspect failed")
|
|
}
|
|
|
|
// Extraire les tables du résultat
|
|
result, ok := resp.Result.(map[string]any)
|
|
if !ok {
|
|
return 0, fmt.Errorf("invalid introspect result")
|
|
}
|
|
|
|
tablesRaw, ok := result["tables"].(map[string]any)
|
|
if !ok {
|
|
return 0, fmt.Errorf("no tables in result")
|
|
}
|
|
|
|
tableCount := len(tablesRaw)
|
|
|
|
// Construire le schema
|
|
schema := map[string]any{
|
|
"app": appID,
|
|
"tables": convertTablesToSchema(tablesRaw),
|
|
}
|
|
|
|
// Sérialiser en YAML
|
|
yamlData, err := yaml.Marshal(schema)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("yaml marshal: %w", err)
|
|
}
|
|
|
|
// Écrire le fichier
|
|
schemaFile := filepath.Join("/config", "apps", appID, "schema.yaml")
|
|
if err := os.WriteFile(schemaFile, yamlData, 0644); err != nil {
|
|
return 0, fmt.Errorf("write schema.yaml: %w", err)
|
|
}
|
|
|
|
return tableCount, nil
|
|
}
|
|
|
|
// convertTablesToSchema convertit les données d'introspection en format schema.
|
|
func convertTablesToSchema(tablesRaw map[string]any) map[string]any {
|
|
tables := make(map[string]any)
|
|
|
|
// Trier les tables par nom pour un output cohérent
|
|
tableNames := make([]string, 0, len(tablesRaw))
|
|
for name := range tablesRaw {
|
|
tableNames = append(tableNames, name)
|
|
}
|
|
sort.Strings(tableNames)
|
|
|
|
// Première passe : créer toutes les tables
|
|
for _, tableName := range tableNames {
|
|
tableData, ok := tablesRaw[tableName].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
table := make(map[string]any)
|
|
|
|
// Colonnes
|
|
if columnsRaw, ok := tableData["columns"].(map[string]any); ok {
|
|
columns := make(map[string]any)
|
|
|
|
// Trier les colonnes
|
|
colNames := make([]string, 0, len(columnsRaw))
|
|
for name := range columnsRaw {
|
|
colNames = append(colNames, name)
|
|
}
|
|
sort.Strings(colNames)
|
|
|
|
for _, colName := range colNames {
|
|
colData, ok := columnsRaw[colName].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
col := make(map[string]any)
|
|
|
|
// Type
|
|
if t, ok := colData["type"].(string); ok {
|
|
col["type"] = t
|
|
}
|
|
|
|
// Longueur
|
|
if l, ok := colData["length"].(float64); ok && l > 0 {
|
|
col["length"] = int(l)
|
|
}
|
|
|
|
// Primary
|
|
if p, ok := colData["primary"].(bool); ok && p {
|
|
col["primary"] = true
|
|
}
|
|
|
|
// Auto increment
|
|
if a, ok := colData["auto"].(bool); ok && a {
|
|
col["auto"] = true
|
|
}
|
|
|
|
// Required (NOT NULL)
|
|
if r, ok := colData["required"].(bool); ok && r {
|
|
col["required"] = true
|
|
}
|
|
|
|
// Default
|
|
if d, ok := colData["default"].(string); ok && d != "" {
|
|
col["default"] = d
|
|
}
|
|
|
|
// Unique
|
|
if u, ok := colData["unique"].(bool); ok && u {
|
|
col["unique"] = true
|
|
}
|
|
|
|
// Foreign key
|
|
if fk, ok := colData["foreign"].(string); ok && fk != "" {
|
|
col["foreign"] = fk
|
|
}
|
|
|
|
// Détecter filter: owner pour les colonnes user_id
|
|
if colName == "user_id" {
|
|
col["filter"] = "owner"
|
|
}
|
|
|
|
columns[colName] = col
|
|
}
|
|
table["columns"] = columns
|
|
}
|
|
|
|
// Primary keys
|
|
if pk, ok := tableData["primary"].([]any); ok && len(pk) > 0 {
|
|
pkStrings := make([]string, 0, len(pk))
|
|
for _, p := range pk {
|
|
if s, ok := p.(string); ok {
|
|
pkStrings = append(pkStrings, s)
|
|
}
|
|
}
|
|
table["primary"] = pkStrings
|
|
}
|
|
|
|
// Détecter soft_delete (colonne deleted_at)
|
|
if hasSoftDelete(tableData) {
|
|
table["soft_delete"] = true
|
|
}
|
|
|
|
// CRUD par défaut (sauf tables de liaison)
|
|
if hasUserID(tableData) {
|
|
table["crud"] = []string{"list", "show", "create", "update", "delete"}
|
|
} else {
|
|
table["crud"] = []string{}
|
|
}
|
|
|
|
tables[tableName] = table
|
|
}
|
|
|
|
// Deuxième passe : détecter cascade sur les tables parent
|
|
// Une table parent a cascade si elle a soft_delete ET des tables enfants avec soft_delete
|
|
for parentName, parentTable := range tables {
|
|
parent, ok := parentTable.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Si la table parent n'a pas soft_delete, pas de cascade
|
|
if sd, ok := parent["soft_delete"].(bool); !ok || !sd {
|
|
continue
|
|
}
|
|
|
|
// Chercher si des tables enfants ont une FK vers cette table
|
|
hasChildWithSoftDelete := false
|
|
for childName, childTable := range tables {
|
|
if childName == parentName {
|
|
continue
|
|
}
|
|
child, ok := childTable.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Vérifier si l'enfant a soft_delete
|
|
childSD, _ := child["soft_delete"].(bool)
|
|
if !childSD {
|
|
continue
|
|
}
|
|
|
|
// Vérifier si l'enfant a une FK vers le parent
|
|
if cols, ok := child["columns"].(map[string]any); ok {
|
|
for _, colData := range cols {
|
|
if col, ok := colData.(map[string]any); ok {
|
|
if fk, ok := col["foreign"].(string); ok {
|
|
// fk = "table.column"
|
|
if strings.HasPrefix(fk, parentName+".") {
|
|
hasChildWithSoftDelete = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if hasChildWithSoftDelete {
|
|
break
|
|
}
|
|
}
|
|
|
|
if hasChildWithSoftDelete {
|
|
parent["cascade"] = true
|
|
}
|
|
}
|
|
|
|
return tables
|
|
}
|
|
|
|
// hasUserID vérifie si une table a une colonne user_id.
|
|
func hasUserID(tableData map[string]any) bool {
|
|
if columnsRaw, ok := tableData["columns"].(map[string]any); ok {
|
|
_, hasIt := columnsRaw["user_id"]
|
|
return hasIt
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hasSoftDelete vérifie si une table a une colonne deleted_at (TIMESTAMP/DATETIME).
|
|
func hasSoftDelete(tableData map[string]any) bool {
|
|
if columnsRaw, ok := tableData["columns"].(map[string]any); ok {
|
|
if col, ok := columnsRaw["deleted_at"].(map[string]any); ok {
|
|
if colType, ok := col["type"].(string); ok && colType == "datetime" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// UpdateLoginData met à jour le bloc login_data dans auth.yaml
|
|
// en se basant sur le schema généré (tables avec filter: owner).
|
|
func UpdateLoginData(appID string) error {
|
|
// 1. Lire le schema.yaml
|
|
schemaPath := filepath.Join("/config", "apps", appID, "schema.yaml")
|
|
schemaData, err := os.ReadFile(schemaPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read schema: %w", err)
|
|
}
|
|
|
|
var schema map[string]any
|
|
if err := yaml.Unmarshal(schemaData, &schema); err != nil {
|
|
return fmt.Errorf("parse schema: %w", err)
|
|
}
|
|
|
|
// 2. Identifier les tables avec user_id (filter: owner)
|
|
tablesRaw, ok := schema["tables"].(map[string]any)
|
|
if !ok {
|
|
return nil // Pas de tables, rien à faire
|
|
}
|
|
|
|
loginData := make(map[string]string)
|
|
tableNames := make([]string, 0, len(tablesRaw))
|
|
for name := range tablesRaw {
|
|
tableNames = append(tableNames, name)
|
|
}
|
|
sort.Strings(tableNames)
|
|
|
|
for _, tableName := range tableNames {
|
|
tableRaw := tablesRaw[tableName]
|
|
table, ok := tableRaw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Vérifier si la table a une colonne avec filter: owner
|
|
columns, ok := table["columns"].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
hasOwnerFilter := false
|
|
for _, colRaw := range columns {
|
|
col, ok := colRaw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if filter, ok := col["filter"].(string); ok && filter == "owner" {
|
|
hasOwnerFilter = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !hasOwnerFilter {
|
|
continue
|
|
}
|
|
|
|
// Collecter les noms de colonnes (sauf user_id)
|
|
colNames := make([]string, 0, len(columns))
|
|
hasPosition := false
|
|
for colName := range columns {
|
|
if colName == "user_id" {
|
|
continue // On n'inclut pas user_id dans le SELECT
|
|
}
|
|
colNames = append(colNames, colName)
|
|
if colName == "position" {
|
|
hasPosition = true
|
|
}
|
|
}
|
|
sort.Strings(colNames)
|
|
|
|
// Mettre id en premier si présent
|
|
for i, name := range colNames {
|
|
if name == "id" {
|
|
colNames = append([]string{"id"}, append(colNames[:i], colNames[i+1:]...)...)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Construire la requête
|
|
query := fmt.Sprintf("SELECT %s\nFROM %s WHERE user_id = ?",
|
|
strings.Join(colNames, ", "), tableName)
|
|
|
|
// Ajouter ORDER BY si position existe
|
|
if hasPosition {
|
|
query += " ORDER BY position"
|
|
}
|
|
|
|
loginData[tableName] = query
|
|
}
|
|
|
|
if len(loginData) == 0 {
|
|
return nil // Pas de tables owner, rien à générer
|
|
}
|
|
|
|
// 3. Lire auth.yaml existant
|
|
authPath := filepath.Join("/config", "apps", appID, "queries", "auth.yaml")
|
|
var existingData map[string]any
|
|
|
|
if data, err := os.ReadFile(authPath); err == nil {
|
|
if err := yaml.Unmarshal(data, &existingData); err != nil {
|
|
existingData = make(map[string]any)
|
|
}
|
|
} else {
|
|
existingData = make(map[string]any)
|
|
}
|
|
|
|
// 4. Mettre à jour seulement login_data
|
|
existingData["login_data"] = loginData
|
|
|
|
// 5. Réécrire le fichier avec commentaire
|
|
queriesDir := filepath.Dir(authPath)
|
|
if err := os.MkdirAll(queriesDir, 0755); err != nil {
|
|
return fmt.Errorf("create queries dir: %w", err)
|
|
}
|
|
|
|
yamlData, err := yaml.Marshal(existingData)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal auth.yaml: %w", err)
|
|
}
|
|
|
|
// Ajouter un header
|
|
header := "# Requêtes d'authentification\n# login_data généré automatiquement depuis schema.yaml\n\n"
|
|
finalData := []byte(header + string(yamlData))
|
|
|
|
if err := os.WriteFile(authPath, finalData, 0644); err != nil {
|
|
return fmt.Errorf("write auth.yaml: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GenerateRoutesFromSchema génère les routes CRUD dans app.yaml basées sur schema.yaml.
|
|
func GenerateRoutesFromSchema(appID string) error {
|
|
// 1. Lire le schema.yaml
|
|
schemaPath := filepath.Join("/config", "apps", appID, "schema.yaml")
|
|
schemaData, err := os.ReadFile(schemaPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read schema: %w", err)
|
|
}
|
|
|
|
var schema map[string]any
|
|
if err := yaml.Unmarshal(schemaData, &schema); err != nil {
|
|
return fmt.Errorf("parse schema: %w", err)
|
|
}
|
|
|
|
// 2. Lire app.yaml existant
|
|
appPath := filepath.Join("/config", "apps", appID, "app.yaml")
|
|
appData, err := os.ReadFile(appPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read app.yaml: %w", err)
|
|
}
|
|
|
|
var appConfig map[string]any
|
|
if err := yaml.Unmarshal(appData, &appConfig); err != nil {
|
|
return fmt.Errorf("parse app.yaml: %w", err)
|
|
}
|
|
|
|
// 3. Extraire les tables avec CRUD
|
|
tablesRaw, ok := schema["tables"].(map[string]any)
|
|
if !ok {
|
|
return nil // Pas de tables
|
|
}
|
|
|
|
// Trier les tables
|
|
tableNames := make([]string, 0, len(tablesRaw))
|
|
for name := range tablesRaw {
|
|
tableNames = append(tableNames, name)
|
|
}
|
|
sort.Strings(tableNames)
|
|
|
|
// 4. Générer les routes
|
|
routes := []map[string]any{
|
|
// Routes auth par défaut
|
|
{"path": "/auth/register", "method": "POST", "scenario": appID + "/auth/register", "auth": false},
|
|
{"path": "/auth/login", "method": "POST", "scenario": appID + "/auth/login", "auth": false},
|
|
{"path": "/auth/logout", "method": "POST", "scenario": appID + "/auth/logout"},
|
|
{"path": "/auth/me", "method": "GET", "scenario": appID + "/auth/me"},
|
|
}
|
|
|
|
for _, tableName := range tableNames {
|
|
tableRaw := tablesRaw[tableName]
|
|
table, ok := tableRaw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Vérifier si CRUD est défini
|
|
crudRaw, ok := table["crud"].([]any)
|
|
if !ok || len(crudRaw) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Convertir en map pour lookup rapide
|
|
crudOps := make(map[string]bool)
|
|
for _, op := range crudRaw {
|
|
if opStr, ok := op.(string); ok {
|
|
crudOps[opStr] = true
|
|
}
|
|
}
|
|
|
|
// Générer les routes pour cette table
|
|
if crudOps["list"] {
|
|
routes = append(routes, map[string]any{
|
|
"path": "/" + tableName,
|
|
"method": "GET",
|
|
"scenario": appID + "/" + tableName + "/list",
|
|
})
|
|
}
|
|
if crudOps["create"] {
|
|
routes = append(routes, map[string]any{
|
|
"path": "/" + tableName,
|
|
"method": "POST",
|
|
"scenario": appID + "/" + tableName + "/create",
|
|
})
|
|
}
|
|
if crudOps["show"] {
|
|
routes = append(routes, map[string]any{
|
|
"path": "/" + tableName + "/{id}",
|
|
"method": "GET",
|
|
"scenario": appID + "/" + tableName + "/show",
|
|
})
|
|
}
|
|
if crudOps["update"] {
|
|
routes = append(routes, map[string]any{
|
|
"path": "/" + tableName + "/{id}",
|
|
"method": "PUT",
|
|
"scenario": appID + "/" + tableName + "/update",
|
|
})
|
|
}
|
|
if crudOps["delete"] {
|
|
routes = append(routes, map[string]any{
|
|
"path": "/" + tableName + "/{id}",
|
|
"method": "DELETE",
|
|
"scenario": appID + "/" + tableName + "/delete",
|
|
})
|
|
}
|
|
}
|
|
|
|
// 5. Mettre à jour app.yaml
|
|
appConfig["routes"] = routes
|
|
|
|
// 6. Réécrire le fichier
|
|
yamlData, err := yaml.Marshal(appConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal app.yaml: %w", err)
|
|
}
|
|
|
|
header := fmt.Sprintf("# Application %s\n# Routes générées automatiquement depuis schema.yaml\n\n", appID)
|
|
if err := os.WriteFile(appPath, []byte(header+string(yamlData)), 0644); err != nil {
|
|
return fmt.Errorf("write app.yaml: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GenerateQueriesFromSchema génère les fichiers queries/*.yaml basés sur schema.yaml.
|
|
func GenerateQueriesFromSchema(appID string) error {
|
|
// 1. Lire le schema.yaml
|
|
schemaPath := filepath.Join("/config", "apps", appID, "schema.yaml")
|
|
schemaData, err := os.ReadFile(schemaPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read schema: %w", err)
|
|
}
|
|
|
|
var schema map[string]any
|
|
if err := yaml.Unmarshal(schemaData, &schema); err != nil {
|
|
return fmt.Errorf("parse schema: %w", err)
|
|
}
|
|
|
|
// 2. Créer le dossier queries
|
|
queriesDir := filepath.Join("/config", "apps", appID, "queries")
|
|
if err := os.MkdirAll(queriesDir, 0755); err != nil {
|
|
return fmt.Errorf("create queries dir: %w", err)
|
|
}
|
|
|
|
// 3. Extraire les tables
|
|
tablesRaw, ok := schema["tables"].(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
for tableName, tableRaw := range tablesRaw {
|
|
table, ok := tableRaw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Vérifier si CRUD est défini
|
|
crudRaw, ok := table["crud"].([]any)
|
|
if !ok || len(crudRaw) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Générer le fichier queries pour cette table
|
|
if err := generateTableQueries(queriesDir, tableName, table); err != nil {
|
|
return fmt.Errorf("generate queries for %s: %w", tableName, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// generateTableQueries génère le fichier queries pour une table.
|
|
func generateTableQueries(queriesDir, tableName string, table map[string]any) error {
|
|
columns, ok := table["columns"].(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
// Collecter les colonnes
|
|
colNames := make([]string, 0, len(columns))
|
|
createFields := make([]string, 0, len(columns))
|
|
updateFields := make([]string, 0, len(columns))
|
|
hasPosition := false
|
|
hasUserID := false
|
|
|
|
for colName, colRaw := range columns {
|
|
col, ok := colRaw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
colNames = append(colNames, colName)
|
|
|
|
if colName == "position" {
|
|
hasPosition = true
|
|
}
|
|
if colName == "user_id" {
|
|
hasUserID = true
|
|
}
|
|
|
|
// Exclure les colonnes auto-générées du CREATE
|
|
isAuto, _ := col["auto"].(bool)
|
|
isPrimary, _ := col["primary"].(bool)
|
|
isAutoGenerated := colName == "created_at" || colName == "updated_at" || colName == "deleted_at"
|
|
|
|
if !isAuto && !isAutoGenerated {
|
|
createFields = append(createFields, colName)
|
|
}
|
|
|
|
// Exclure id, user_id et auto-générées de l'UPDATE
|
|
if !isPrimary && !isAuto && !isAutoGenerated && colName != "user_id" {
|
|
updateFields = append(updateFields, colName)
|
|
}
|
|
}
|
|
|
|
sort.Strings(colNames)
|
|
sort.Strings(createFields)
|
|
sort.Strings(updateFields)
|
|
|
|
// Mettre id en premier dans les colonnes SELECT
|
|
selectCols := make([]string, 0, len(colNames))
|
|
for _, name := range colNames {
|
|
if name == "id" {
|
|
selectCols = append([]string{"id"}, selectCols...)
|
|
} else if name != "user_id" { // Exclure user_id du SELECT
|
|
selectCols = append(selectCols, name)
|
|
}
|
|
}
|
|
|
|
// Construire le contenu YAML
|
|
queries := make(map[string]any)
|
|
|
|
// LIST
|
|
listQuery := fmt.Sprintf("SELECT %s\nFROM %s", strings.Join(selectCols, ", "), tableName)
|
|
orderBy := ""
|
|
if hasPosition {
|
|
orderBy = "position ASC"
|
|
}
|
|
|
|
listConfig := map[string]any{
|
|
"query": listQuery,
|
|
}
|
|
if hasUserID {
|
|
listConfig["filters"] = map[string]string{
|
|
"default": "user_id = :user_id",
|
|
"admin": "",
|
|
}
|
|
}
|
|
if orderBy != "" {
|
|
listConfig["order"] = orderBy
|
|
}
|
|
queries["list"] = listConfig
|
|
|
|
// SHOW
|
|
showQuery := fmt.Sprintf("SELECT %s\nFROM %s WHERE id = :id", strings.Join(selectCols, ", "), tableName)
|
|
showConfig := map[string]any{
|
|
"query": showQuery,
|
|
}
|
|
if hasUserID {
|
|
showConfig["filters"] = map[string]string{
|
|
"default": "user_id = :user_id",
|
|
"admin": "",
|
|
}
|
|
}
|
|
queries["show"] = showConfig
|
|
|
|
// CREATE
|
|
queries["create"] = map[string]any{
|
|
"table": tableName,
|
|
"fields": createFields,
|
|
}
|
|
|
|
// UPDATE
|
|
updateConfig := map[string]any{
|
|
"table": tableName,
|
|
"fields": updateFields,
|
|
}
|
|
if hasUserID {
|
|
updateConfig["filters"] = map[string]string{
|
|
"default": "user_id = :user_id",
|
|
"admin": "",
|
|
}
|
|
}
|
|
queries["update"] = updateConfig
|
|
|
|
// DELETE
|
|
deleteConfig := map[string]any{
|
|
"table": tableName,
|
|
}
|
|
if hasUserID {
|
|
deleteConfig["filters"] = map[string]string{
|
|
"default": "user_id = :user_id",
|
|
"admin": "",
|
|
}
|
|
}
|
|
queries["delete"] = deleteConfig
|
|
|
|
// Sérialiser
|
|
yamlData, err := yaml.Marshal(queries)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
header := fmt.Sprintf("# Requêtes CRUD %s\n# Généré automatiquement depuis schema.yaml\n\n", tableName)
|
|
queryFile := filepath.Join(queriesDir, tableName+".yaml")
|
|
|
|
return os.WriteFile(queryFile, []byte(header+string(yamlData)), 0644)
|
|
}
|
|
|
|
// ReloadGateway demande à sogoctl de recharger sogoway.
|
|
func (sp *ServicePool) ReloadGateway() error {
|
|
conn, err := net.DialTimeout("unix", "/run/sogoctl.sock", 2*time.Second)
|
|
if err != nil {
|
|
return fmt.Errorf("connect to sogoctl: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Envoyer la commande reload
|
|
_, err = conn.Write([]byte("reload sogoway\n"))
|
|
if err != nil {
|
|
return fmt.Errorf("send command: %w", err)
|
|
}
|
|
|
|
// Lire la réponse
|
|
buf := make([]byte, 256)
|
|
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
|
n, err := conn.Read(buf)
|
|
if err != nil {
|
|
return fmt.Errorf("read response: %w", err)
|
|
}
|
|
|
|
response := string(buf[:n])
|
|
if strings.HasPrefix(response, "error:") {
|
|
return fmt.Errorf("%s", strings.TrimPrefix(response, "error: "))
|
|
}
|
|
|
|
return nil
|
|
}
|