SOGOMS v1.0.3 - Admin UI, Cron, Config reload

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>
This commit is contained in:
2025-12-19 20:30:56 +01:00
parent a4694a10d1
commit 65da4efdad
76 changed files with 5305 additions and 80 deletions

View File

@@ -56,6 +56,8 @@ Client → Nginx(:443) → Sogoway(:8080) → Sogoms-* → MariaDB
| `sogoms-db` | Accès MariaDB, pool par application | `/run/sogoms-db.1.sock` | | `sogoms-db` | Accès MariaDB, pool par application | `/run/sogoms-db.1.sock` |
| `sogoms-logs` | Logging centralisé, rotation auto | `/run/sogoms-logs.1.sock` | | `sogoms-logs` | Logging centralisé, rotation auto | `/run/sogoms-logs.1.sock` |
| `sogoms-smtp` | Envoi d'emails, templates YAML | `/run/sogoms-smtp.1.sock` | | `sogoms-smtp` | Envoi d'emails, templates YAML | `/run/sogoms-smtp.1.sock` |
| `sogoms-cron` | Tâches planifiées, jobs périodiques | `/run/sogoms-cron.1.sock` |
| `sogoms-admin` | Interface web d'administration | TCP :9000 |
### sogoctl (Superviseur) ### sogoctl (Superviseur)
@@ -63,6 +65,9 @@ Client → Nginx(:443) → Sogoway(:8080) → Sogoms-* → MariaDB
- Health checks périodiques (socket ou HTTP) - Health checks périodiques (socket ou HTTP)
- Redémarrage automatique en cas de crash - Redémarrage automatique en cas de crash
- Arrêt gracieux sur SIGTERM/SIGINT - Arrêt gracieux sur SIGTERM/SIGINT
- **Socket de contrôle** `/run/sogoctl.sock` pour commandes runtime :
- `reload <service>` : envoie SIGHUP au service (rechargement config)
- `status` : affiche l'état des services
```yaml ```yaml
# config/sogoctl.yaml # config/sogoctl.yaml
@@ -96,6 +101,7 @@ services:
- Authentification JWT (HS256) - Authentification JWT (HS256)
- CRUD générique paramétré par YAML - CRUD générique paramétré par YAML
- Logging des événements (login, register) - Logging des événements (login, register)
- **Rechargement à chaud** : SIGHUP recharge registry + JWT sans restart
### sogoms-db (Base de données) ### sogoms-db (Base de données)
@@ -151,6 +157,118 @@ body_html: |
<p>Bienvenue sur notre plateforme.</p> <p>Bienvenue sur notre plateforme.</p>
``` ```
### sogoms-cron (Tâches planifiées)
Exécute des jobs périodiques définis en YAML avec support cron standard.
Actions disponibles :
- `list` : liste les jobs configurés avec prochain run
- `trigger` : déclenche un job manuellement
- `status` : historique des dernières exécutions
- `health` : statut OK
Types de jobs :
- `query_email` : requête DB + envoi email groupé par utilisateur
- `http` : appel HTTP (GET/POST) vers endpoint interne ou externe
- `service` : appel service interne (sogoms-db, sogoms-smtp, etc.)
Configuration dans `config/apps/{app}/cron.yaml` :
```yaml
timezone: Europe/Paris
retry:
max_attempts: 3
delay: 5m
history_days: 7
jobs:
tasks_today:
schedule: "0 8 * * 1-5" # 8h00 lun-ven
type: query_email
query: |
SELECT u.id AS user_id, u.email, u.name AS user_name,
t.title, p.name AS project_name, s.name AS status_name
FROM users u
INNER JOIN tasks t ON t.user_id = u.id
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN statuses s ON t.status_id = s.id
WHERE t.date_end <= CURDATE()
group_by: user_id
template: tasks_today
enabled: true
```
Format cron : `minute heure jour mois jour_semaine`
- `0 8 * * 1-5` : 8h00 du lundi au vendredi
- `*/15 * * * *` : toutes les 15 minutes
- `0 9 1 * *` : 9h00 le premier de chaque mois
### sogoms-admin (Interface web)
Interface d'administration web pour gérer les applications SOGOMS.
**Rôles :**
- `super_admin` : accès global à toutes les apps et services
- `app_admin` : accès limité aux apps assignées avec permissions fines
**Stack :**
- Backend : Go net/http
- Frontend : Go templates + htmx + Pico.css (embarqués via go:embed)
- Auth : sessions cookies signées (HMAC-SHA256)
**Sécurité :**
- Passwords : bcrypt cost=12
- Sessions : Cookie HttpOnly + Secure + SameSite=Strict
- CSRF : Token par session
- Rate limiting : 5 tentatives/min par IP
**Routes :**
- `GET /admin/login` : page de connexion
- `POST /admin/login` : authentification
- `GET /admin/` : dashboard principal
- `POST /admin/logout` : déconnexion
- `GET /admin/api/apps` : liste apps (htmx partial)
- `GET /admin/api/services/health` : statut services (htmx partial)
**Configuration :**
```yaml
# /secrets/admin_users.yaml
session:
secret_file: /secrets/admin_session_secret
max_age: 3600
cookie_name: sogoms_admin_sid
rate_limit:
login_max: 5
login_window: 60
users:
- username: pierre
password_hash: "$2a$12$..."
role: super_admin
email: pierre@example.com
- username: client1
password_hash: "$2a$12$..."
role: app_admin
apps: [prokov]
permissions:
- schema:read
- queries:read
- cron:read
- logs:read
```
**Permissions disponibles :**
- `schema:read/write/upload` : gestion schema
- `queries:read/write` : requêtes SQL
- `emails:read/write` : templates email
- `cron:read/write/trigger` : jobs cron
- `logs:read` : consultation logs
- `db:introspect` : introspection DB
- `*` : toutes (super_admin)
**Accès externe :** `admin.sogoms.com` via Nginx → :9000
--- ---
## Communication IPC ## Communication IPC
@@ -461,7 +579,15 @@ sogoms/
│ └── sogoms/ │ └── sogoms/
│ ├── db/main.go # Microservice DB │ ├── db/main.go # Microservice DB
│ ├── logs/main.go # Microservice Logs │ ├── logs/main.go # Microservice Logs
── smtp/main.go # Microservice SMTP ── smtp/main.go # Microservice SMTP
│ ├── cron/main.go # Microservice Cron
│ └── admin/ # Interface web admin
│ ├── main.go
│ ├── handlers.go
│ ├── middleware.go
│ ├── session.go
│ ├── services.go
│ └── templates/
├── internal/ ├── internal/
│ ├── protocol/ │ ├── protocol/
│ │ ├── message.go # Structs Request/Response │ │ ├── message.go # Structs Request/Response
@@ -469,30 +595,31 @@ sogoms/
│ │ └── client.go # Client + Pool connexions │ │ └── client.go # Client + Pool connexions
│ ├── config/ │ ├── config/
│ │ └── config.go # Registry, Queries, CUD │ │ └── config.go # Registry, Queries, CUD
│ ├── cron/
│ │ └── scheduler.go # Parser cron, calcul next run
│ ├── auth/ │ ├── auth/
│ │ ├── jwt.go # Génération/validation JWT │ │ ├── jwt.go # Génération/validation JWT
│ │ └── password.go # Hash bcrypt │ │ └── password.go # Hash bcrypt
│ ├── admin/
│ │ ├── config.go # Chargement admin_users.yaml
│ │ ├── permissions.go # Vérification droits
│ │ └── audit.go # Logging actions
│ └── version/ │ └── version/
│ └── version.go # Version, BuildTime │ └── version.go # Version, BuildTime
├── config/ ├── config/
│ ├── sogoctl.yaml │ ├── sogoctl.yaml
── routes/ ── apps/
└── prokov.yaml # Config app (DB, auth, SMTP) └── prokov/
├── queries/ ├── app.yaml # Config app (DB, auth, SMTP)
── prokov/ ── schema.yaml # Schema DB généré
│ ├── cron.yaml # Jobs planifiés
│ ├── queries/ # Requêtes SQL
│ │ ├── auth.yaml │ │ ├── auth.yaml
│ │ ├── projects.yaml │ │ ├── projects.yaml
├── tasks.yaml │ └── ...
── tags.yaml ── emails/ # Templates email
│ │ └── statuses.yaml
│ └── emails/
│ └── prokov/
│ ├── welcome.yaml │ ├── welcome.yaml
│ ├── password_reset.yaml
│ ├── task_assigned.yaml
│ └── tasks_today.yaml │ └── tasks_today.yaml
├── clients/
│ └── prokov.sql # Schéma DB
├── bin/ # Binaires compilés ├── bin/ # Binaires compilés
├── deploy.sh # Script déploiement ├── deploy.sh # Script déploiement
├── VERSION # Numéro de version ├── VERSION # Numéro de version

338
GO-HTMX.md Normal file
View File

@@ -0,0 +1,338 @@
# Architecture Go + htmx (sogoms-admin)
Guide pour comprendre et modifier l'interface d'administration SOGOMS.
## Vue d'ensemble
```
Browser (htmx) ←→ Go Server ←→ Services (sockets)
│ │
│ HTTP GET/POST │
▼ ▼
Templates Handlers
(HTML) (Go)
```
**Stack :**
- **Backend** : Go net/http (pas de framework)
- **Frontend** : Go templates + htmx + Pico.css
- **Principe** : Le serveur renvoie des fragments HTML, htmx les injecte dans le DOM
---
## Structure des fichiers
```
cmd/sogoms/admin/
├── main.go # Point d'entrée, routes, config
├── handlers.go # Handlers HTTP (logique métier)
├── middleware.go # Auth, CSRF, logging, rate limiting
├── session.go # Gestion des sessions
├── services.go # Appels vers microservices (sockets)
└── templates/
├── layout.html # Layout commun (head, nav, footer)
├── login.html # Page de connexion
├── dashboard.html # Dashboard principal
└── partials/ # Fragments htmx
├── apps_list.html
└── services_status.html
```
---
## Flux de données
### 1. Requête initiale (page complète)
```
GET /admin/
main.go:103 ─────────────────────────────────────────────┐
│ mux.HandleFunc("GET /admin/", AuthMiddleware(...)) │
└───────────────────────────────────────────────────────┘
handlers.go:114-138 ─────────────────────────────────────┐
│ func HandleDashboard(w, r) { │
│ data := map[string]any{ │
│ "Title": "Dashboard", │
│ "Apps": accessibleApps, │
│ ... │
│ } │
│ s.render(w, "dashboard.html", data) │
│ } │
└───────────────────────────────────────────────────────┘
templates/dashboard.html ────────────────────────────────┐
│ {{template "layout" .}} │
│ <h1>Dashboard</h1> │
│ <div hx-get="/admin/api/services/health" │
│ hx-trigger="load"> │
│ Chargement... │
│ </div> │
└───────────────────────────────────────────────────────┘
```
### 2. Requête htmx (fragment)
```
hx-get="/admin/api/services/health"
main.go:109 ─────────────────────────────────────────────┐
│ mux.HandleFunc("GET /admin/api/services/health", ...) │
└───────────────────────────────────────────────────────┘
handlers.go:172-193 ─────────────────────────────────────┐
│ func HandleAPIServicesHealth(w, r) { │
│ statuses := s.services.HealthCheck() │
│ data := map[string]any{"Services": statuses} │
│ s.render(w, "partials/services_status.html", data)│
│ } │
└───────────────────────────────────────────────────────┘
templates/partials/services_status.html ─────────────────┐
│ <ul> │
│ {{range .Services}} │
│ <li>{{.Name}} - {{if .Available}}OK{{end}}</li> │
│ {{end}} │
│ </ul> │
└───────────────────────────────────────────────────────┘
htmx remplace le contenu du <div> avec ce fragment
```
---
## Attributs htmx essentiels
| Attribut | Description | Exemple |
|----------|-------------|---------|
| `hx-get` | GET AJAX vers URL | `hx-get="/admin/api/apps"` |
| `hx-post` | POST AJAX vers URL | `hx-post="/admin/api/apps/create"` |
| `hx-trigger` | Quand déclencher | `load`, `click`, `every 30s`, `submit` |
| `hx-target` | Où injecter la réponse | `hx-target="#result"` (défaut: élément courant) |
| `hx-swap` | Comment injecter | `innerHTML` (défaut), `outerHTML`, `beforeend` |
| `hx-indicator` | Spinner pendant chargement | `hx-indicator=".loading"` |
| `hx-confirm` | Confirmation avant action | `hx-confirm="Supprimer ?"` |
### Exemples
```html
<!-- Charger au chargement de la page -->
<div hx-get="/admin/api/apps" hx-trigger="load">
Chargement...
</div>
<!-- Rafraîchir toutes les 30 secondes -->
<div hx-get="/admin/api/services/health"
hx-trigger="load, every 30s">
</div>
<!-- Soumettre un formulaire en AJAX -->
<form hx-post="/admin/api/apps/create"
hx-target="#apps-list"
hx-swap="beforeend">
<input name="name" required>
<button type="submit">Créer</button>
</form>
<!-- Supprimer avec confirmation -->
<button hx-delete="/admin/api/apps/123"
hx-confirm="Supprimer cette app ?"
hx-target="closest li"
hx-swap="outerHTML">
Supprimer
</button>
```
---
## Templates Go
### Syntaxe de base
```html
<!-- Variable -->
{{.Title}}
<!-- Condition -->
{{if .IsSuperAdmin}}
Super Admin
{{else}}
App Admin
{{end}}
<!-- Boucle -->
{{range .Apps}}
<li>{{.Name}}</li>
{{end}}
<!-- Inclusion de template -->
{{template "layout" .}}
<!-- Bloc définissable -->
{{define "content"}}
Contenu ici
{{end}}
```
### Structure layout.html
```html
{{define "layout"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}} - SOGOMS Admin</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@1/css/pico.min.css">
</head>
<body>
<nav>...</nav>
<main class="container">
{{template "content" .}}
</main>
</body>
</html>
{{end}}
```
### Structure page (dashboard.html)
```html
{{define "dashboard.html"}}
{{template "layout" .}}
{{end}}
{{define "content"}}
<h1>Dashboard</h1>
<!-- contenu de la page -->
{{end}}
```
---
## Ajouter une nouvelle fonctionnalité
### Exemple : Liste des jobs cron
**1. Créer le partial** `templates/partials/cron_jobs.html`
```html
{{define "partials/cron_jobs.html"}}
<table>
<thead>
<tr>
<th>Job</th>
<th>Schedule</th>
<th>Prochain run</th>
</tr>
</thead>
<tbody>
{{range .Jobs}}
<tr>
<td>{{.Name}}</td>
<td><code>{{.Schedule}}</code></td>
<td>{{.NextRun}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
```
**2. Ajouter le handler** dans `handlers.go`
```go
func (s *AdminServer) HandleAPICronJobs(w http.ResponseWriter, r *http.Request) {
user := GetUserFromContext(r.Context())
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
jobs, err := s.services.GetCronJobs()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := map[string]any{
"Jobs": jobs,
}
s.render(w, "partials/cron_jobs.html", data)
}
```
**3. Ajouter la route** dans `main.go`
```go
mux.HandleFunc("GET /admin/api/cron/jobs",
AuthMiddleware(sessions, adminCfg, server.HandleAPICronJobs))
```
**4. Utiliser dans le dashboard** `templates/dashboard.html`
```html
<article>
<header><strong>Jobs Cron</strong></header>
<div hx-get="/admin/api/cron/jobs" hx-trigger="load">
Chargement...
</div>
</article>
```
---
## Mode développement
Le flag `-dev` recharge les templates à chaque requête :
```yaml
# config/sogoctl.yaml
sogoms-admin:
args:
- "-templates"
- "/config/admin/templates"
- "-dev" # ← rechargement auto
```
**Workflow dev :**
```bash
# 1. Modifier un template localement
vim cmd/sogoms/admin/templates/dashboard.html
# 2. Déployer les templates (sans recompilation)
./deploy-admin.sh
# 3. Rafraîchir le navigateur → changements visibles
```
**Pour la prod**, retirer `-dev` (templates chargés une fois au démarrage).
---
## Fichiers de configuration
| Fichier | Rôle |
|---------|------|
| `/secrets/admin_users.yaml` | Users, passwords, rôles |
| `/secrets/admin_session_secret` | Clé HMAC pour cookies |
| `/config/sogoctl.yaml` | Args de sogoms-admin |
| `/config/admin/templates/` | Templates HTML (si -templates) |
---
## Ressources
- **htmx** : https://htmx.org/docs/
- **Pico.css** : https://picocss.com/docs/
- **Go templates** : https://pkg.go.dev/html/template

View File

@@ -29,6 +29,8 @@ schema.yaml → SOGOMS → API REST + Auth + CRUD + Push
| `sogoms-db` | Accès MariaDB | Stable | | `sogoms-db` | Accès MariaDB | Stable |
| `sogoms-logs` | Logging centralisé | Stable | | `sogoms-logs` | Logging centralisé | Stable |
| `sogoms-smtp` | Envoi emails, templates | Stable | | `sogoms-smtp` | Envoi emails, templates | Stable |
| `sogoms-cron` | Tâches planifiées | Stable |
| `sogoms-admin` | Interface web administration | Stable |
## Roadmap ## Roadmap
@@ -36,7 +38,6 @@ schema.yaml → SOGOMS → API REST + Auth + CRUD + Push
|-------|---------|-------------| |-------|---------|-------------|
| 11 | sogoms-crypt | Chiffrement données sensibles | | 11 | sogoms-crypt | Chiffrement données sensibles |
| 12 | sogoms-imap/mailproc | Lecture et traitement emails | | 12 | sogoms-imap/mailproc | Lecture et traitement emails |
| 13 | sogoms-cron | Tâches planifiées |
| 14 | sogoms-push | Push temps réel (MQTT) | | 14 | sogoms-push | Push temps réel (MQTT) |
| 15 | sogoms-schema | API auto-générée depuis schema | | 15 | sogoms-schema | API auto-générée depuis schema |

245
TODO.md
View File

@@ -161,18 +161,24 @@ curl https://prokov.unikoffice.com/api/auth/me \
## Phase 13 : Microservice Cron ## Phase 13 : Microservice Cron
- [ ] `cmd/sogoms/cron/main.go` : point d'entrée - [x] `cmd/sogoms/cron/main.go` : point d'entrée
- [ ] Écoute sur Unix socket `/run/sogoms-cron.1.sock` - [x] `internal/cron/scheduler.go` : parser cron + calcul next run
- [ ] Config YAML par application (`config/cron/{app}.yaml`) - [x] Écoute sur Unix socket `/run/sogoms-cron.1.sock`
- [ ] Parser cron schedule (format standard `* * * * *`) - [x] Config YAML par application (`config/apps/{app}/cron.yaml`)
- [ ] Action `list` : liste les jobs configurés - [x] Parser cron schedule (format standard `* * * * *`)
- [ ] Action `trigger` : déclenche un job manuellement - [x] Support timezone (Europe/Paris)
- [ ] Action `status` : statut des dernières exécutions - [x] Retry configurable (max_attempts, delay)
- [ ] Type `service` : appel service interne (sogoms-smtp, sogoms-db, etc.) - [x] Historique des exécutions (configurable, défaut 7 jours)
- [ ] Type `http` : appel HTTP (GET/POST) vers endpoint interne ou externe - [x] Action `list` : liste les jobs configurés avec prochain run
- [ ] Type `query_email` : requête DB + envoi email avec résultat - [x] Action `trigger` : déclenche un job manuellement
- [ ] Logging des exécutions dans sogoms-logs - [x] Action `status` : historique des dernières exécutions
- [ ] Application Prokov : email quotidien `tasks_today` - [x] Type `service` : appel service interne (sogoms-smtp, sogoms-db, etc.)
- [x] Type `http` : appel HTTP (GET/POST) vers endpoint interne ou externe
- [x] Type `query_email` : requête DB + envoi email groupé par user
- [x] Logging des exécutions dans sogoms-logs
- [x] Application Prokov : email quotidien `tasks_today` (8h00 lun-ven)
- [x] Intégration sogoctl.yaml
- [x] Intégration deploy.sh
## Phase 14 : Push Temps Réel (MQTT) ## Phase 14 : Push Temps Réel (MQTT)
@@ -210,32 +216,35 @@ Cette phase transforme SOGOMS en générateur d'API automatique.
### 15a. Définition du Schema ### 15a. Définition du Schema
- [ ] Format `config/schema/{app}.yaml` : tables, fields, relations - [x] Format `config/schemas/{app}.yaml` : tables, fields, relations
- [ ] Types supportés : int, string, text, bool, date, datetime, json - [x] Types supportés : int, string, text, float, date, datetime, json
- [ ] Contraintes : primary, auto, unique, required, default - [x] Contraintes : primary, auto, unique, required, default
- [ ] Relations : foreign key avec `foreign: table.field` - [x] Relations : foreign key avec `foreign: table.field`
- [ ] Sécurité : `filter: owner` pour filtrage auto par user_id - [x] Sécurité : `filter: owner` pour filtrage auto par user_id
- [ ] Auth : `auth: login`, `auth: password` pour détection auto - [ ] Auth : `auth: login`, `auth: password` pour détection auto
- [ ] CRUD : liste des opérations autorisées par table - [x] CRUD : liste des opérations autorisées par table
- [ ] Filtres custom : définition de filtres nommés - [ ] Filtres custom : définition de filtres nommés
- [ ] Ordre par défaut : `order: "position ASC"`
### 15b. sogoms-schema (Générateur) ### 15b. Introspection DB (via API admin)
- [ ] `cmd/sogoms/schema/main.go` : outil CLI - [x] Action `introspect` dans sogoms-db : scan INFORMATION_SCHEMA
- [ ] Commande `generate {app}` : génère queries YAML depuis schema - [x] Endpoint `GET /api/_admin/schema/introspect` : retourne JSON
- [x] Endpoint `POST /api/_admin/schema/generate` : génère schema.yaml
- [x] Détection auto : types, clés primaires/étrangères, contraintes
- [x] Détection pattern `filter: owner` sur colonnes `user_id`
- [ ] Commande `validate {app}` : valide le schema - [ ] Commande `validate {app}` : valide le schema
- [ ] Commande `diff {app}` : compare schema vs DB réelle - [ ] Commande `diff {app}` : compare schema vs DB réelle
- [ ] Commande `migrate {app}` : génère SQL de migration - [ ] Commande `migrate {app}` : génère SQL de migration
- [ ] Commande `init {app}` : crée schema depuis DB existante (reverse)
### 15c. Runtime Dynamique (sogoway) ### 15c. Runtime Dynamique (sogoway)
- [ ] Chargement schema au démarrage - [x] Chargement schema au démarrage
- [ ] Routes CRUD auto-générées depuis schema - [x] Routes CRUD auto-générées depuis schema
- [ ] Validation des inputs selon types/contraintes - [x] Validation des inputs selon types/contraintes
- [ ] Filtrage user_id automatique (filter: owner) - [x] Filtrage user_id automatique (filter: owner)
- [ ] Gestion relations (include, nested) - [ ] Gestion relations (include, nested)
- [ ] Pas de fichiers queries YAML requis (optionnels pour override) - [x] Pas de fichiers queries YAML requis (optionnels pour override)
### 15d. Dictionnaire de Données ### 15d. Dictionnaire de Données
@@ -244,6 +253,115 @@ Cette phase transforme SOGOMS en générateur d'API automatique.
- [ ] Documentation auto-générée - [ ] Documentation auto-générée
- [ ] Utilisable par Flutter pour génération de formulaires - [ ] Utilisable par Flutter pour génération de formulaires
## Phase 16 : Réorganisation Config par Application
Objectif : regrouper tous les fichiers d'une application dans un seul dossier.
### 16a. Nouvelle structure
```
config/
├── apps/
│ └── {app}/
│ ├── app.yaml ← config principale (ex routes/{app}.yaml)
│ ├── schema.yaml ← schema DB généré
│ ├── queries/ ← requêtes SQL
│ │ ├── auth.yaml
│ │ ├── projects.yaml
│ │ └── ...
│ ├── scenarios/ ← orchestrations complexes
│ │ └── auth/
│ │ └── login.yaml
│ └── emails/ ← templates email
│ └── welcome.yaml
└── sogoctl.yaml
```
### 16b. Migration
- [x] Créer `config/apps/prokov/` avec nouvelle structure
- [x] Migrer `config/routes/prokov.yaml``config/apps/prokov/app.yaml`
- [x] Migrer `config/schemas/prokov.yaml``config/apps/prokov/schema.yaml`
- [x] Migrer `config/queries/prokov/``config/apps/prokov/queries/`
- [x] Migrer `config/scenarios/prokov/``config/apps/prokov/scenarios/`
- [x] Migrer `config/emails/prokov/``config/apps/prokov/emails/`
### 16c. Adaptation du code
- [x] `internal/config/config.go` : nouveau chemin de chargement
- [x] `internal/config/config.go` : charger schema.yaml (optionnel)
- [x] `cmd/sogoway/main.go` : adapter handleSchemaGenerate()
- [x] `deploy.sh` : adapter les chemins de déploiement
- [x] Supprimer anciens dossiers après validation
### 16d. Avantages
- Clarté : tout ce qui concerne une app dans un seul dossier
- Portabilité : copier/sauvegarder une app = copier un dossier
- Scalabilité : ajouter une app = créer un dossier dans `apps/`
- Cohérence : plus de répétition du nom d'app partout
## Phase 17 : Interface Web Administration (sogoms-admin)
### 17a. Backend Go
- [x] `internal/admin/config.go` : chargement admin_users.yaml
- [x] `internal/admin/permissions.go` : vérification des droits
- [x] `internal/admin/audit.go` : logging des actions vers sogoms-logs
- [x] `cmd/sogoms/admin/session.go` : sessions en mémoire, cookies signés HMAC-SHA256
- [x] `cmd/sogoms/admin/middleware.go` : auth, CSRF, rate limiting
- [x] `cmd/sogoms/admin/services.go` : appels vers services (db, logs, cron)
- [x] `cmd/sogoms/admin/handlers.go` : handlers HTTP (login, dashboard, logout)
- [x] `cmd/sogoms/admin/main.go` : serveur HTTP :9000
### 17b. Frontend Templates
- [x] `templates/layout.html` : layout commun avec htmx + Pico.css
- [x] `templates/login.html` : page de connexion avec CSRF
- [x] `templates/dashboard.html` : dashboard principal
- [x] `templates/partials/apps_list.html` : liste apps (htmx)
- [x] `templates/partials/services_status.html` : statut services (htmx)
### 17c. Sécurité
- [x] Passwords : bcrypt cost=12
- [x] Sessions : Cookie HttpOnly + Secure + SameSite=Strict
- [x] CSRF : Token par session, vérifié sur POST
- [x] Rate limiting : 5 tentatives/min par IP sur login
- [x] Audit : Toutes actions loggées vers sogoms-logs
### 17d. Rôles et Permissions
- [x] Super-admin : accès global à toutes les apps et services
- [x] App-admin : accès limité aux apps assignées avec permissions fines
- [x] Permissions granulaires : schema:*, queries:*, emails:*, cron:*, logs:*, db:*
### 17e. Intégration
- [x] `config/sogoctl.yaml` : ajout service sogoms-admin
- [x] `deploy.sh` : build et déploiement sogoms-admin
- [x] Configuration Nginx : admin.sogoms.com → :9000
### 17f. Configuration requise
```yaml
# /secrets/admin_users.yaml
session:
secret_file: /secrets/admin_session_secret
max_age: 3600
cookie_name: sogoms_admin_sid
rate_limit:
login_max: 5
login_window: 60
users:
- username: pierre
password_hash: "$2a$12$..."
role: super_admin
email: pierre@example.com
```
## Hors scope V1 ## Hors scope V1
- sogorch (orchestrateur scénarios) - sogorch (orchestrateur scénarios)
@@ -251,3 +369,76 @@ Cette phase transforme SOGOMS en générateur d'API automatique.
- Multi-tenant avancé (workspaces, partage) - Multi-tenant avancé (workspaces, partage)
- Rate limiting - Rate limiting
- Rôles utilisateurs (admin, manager, user) - Rôles utilisateurs (admin, manager, user)
## Phase 19 : Création d'App via Admin UI
Objectif : permettre la création et configuration d'une app directement depuis l'interface admin.
### 19a. Formulaire de création
- [x] Page `/admin/apps/new` : formulaire création app
- [x] Champs : nom app, version, base_path
- [x] Champs hosts : liste des domaines
- [x] Champs database : host, port, user, password, name
- [x] Champs auth : JWT secret (auto-généré ou manuel), expiry
- [ ] Validation : test connexion DB avant création
- [x] Création : génère `config/apps/{app}/app.yaml`
### 19b. Introspection et génération schema
- [x] Bouton "Scanner la base" : appelle introspection DB
- [x] Génération automatique `schema.yaml` depuis INFORMATION_SCHEMA
- [x] Détection : types, clés primaires/étrangères, contraintes
- [x] Détection auto `filter: owner` sur colonnes `user_id`
- [x] Sauvegarde `config/apps/{app}/schema.yaml`
### 19c. Bouton "Update Schema"
- [x] Relecture structure DB (nouvelle introspection)
- [x] Mise à jour schema.yaml (nouvelles tables/colonnes)
- [x] Régénération routes CRUD automatiques
- [x] Rechargement registry après scan
- [x] Rechargement automatique sogoway via SIGHUP (socket sogoctl)
Note : le bouton "Scanner la base" (19b) fait office d'Update Schema.
### 19d. Affichage dans Admin
- [ ] Page détail app : liste tables avec colonnes
- [ ] Page détail app : liste routes générées
- [ ] Page détail app : dictionnaire des données (types, contraintes)
- [ ] Indicateur : schema synchronisé / désynchronisé avec DB
### 19e. Gestion des secrets
- [ ] Génération auto fichiers secrets (`/secrets/{app}_*`)
- [ ] DB password : saisi une fois, stocké dans fichier
- [ ] JWT secret : auto-généré (openssl rand -base64 32)
- [ ] Permissions fichiers : 600
## Phase 18 : Application Geosector (Janvier-Février 2026)
Migration de l'API PHP 8.3 existante vers SOGOMS pour l'application Flutter (Web + mobiles).
### 18a. Préparation
- [ ] Introspection DB MariaDB existante
- [ ] Génération schema.yaml depuis introspection
- [ ] Création `config/apps/geosector/`
- [ ] Analyse des endpoints PHP existants
### 18b. Migration
- [ ] Configuration app.yaml (DB, auth, hosts)
- [ ] Adaptation des queries spécifiques
- [ ] Migration des endpoints custom si nécessaire
- [ ] Configuration jobs cron (si applicable)
- [ ] Configuration emails (si applicable)
### 18c. Tests et bascule
- [ ] Tests avec app Flutter (web)
- [ ] Tests avec app Flutter (iOS/Android)
- [ ] Configuration Nginx geosector.sogoms.com
- [ ] Bascule DNS production
- [ ] Monitoring post-migration

View File

@@ -1 +1 @@
1.0.1 1.0.3

BIN
admin Executable file

Binary file not shown.

View File

@@ -0,0 +1,622 @@
package main
import (
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"strings"
"sogoms.com/internal/admin"
"sogoms.com/internal/auth"
"sogoms.com/internal/config"
)
// AdminServer contient les dépendances des handlers.
type AdminServer struct {
adminCfg *admin.AdminConfig
registry *config.Registry
sessions *SessionStore
rateLimiter *RateLimiter
perms *admin.PermissionChecker
audit *admin.AuditLogger
services *ServicePool
templates *template.Template
templatesDir string
devMode bool
}
// getTemplates retourne les templates, en les rechargeant si devMode est activé.
func (s *AdminServer) getTemplates() *template.Template {
if s.devMode && s.templatesDir != "" {
tmpl, err := loadTemplates(s.templatesDir)
if err != nil {
log.Printf("[admin] reload templates error: %v", err)
return s.templates
}
return tmpl
}
return s.templates
}
// HandleLoginPage affiche la page de login.
func (s *AdminServer) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
// Si déjà authentifié, rediriger vers dashboard
if session, _ := s.sessions.GetSessionFromRequest(r); session != nil {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
// Générer un CSRF token pour le formulaire
csrfToken, _ := generateSecureToken(32)
data := map[string]any{
"Title": "Connexion",
"CSRFToken": csrfToken,
"Error": r.URL.Query().Get("error"),
}
s.render(w, "login.html", data)
}
// HandleLogin traite la soumission du formulaire de login.
func (s *AdminServer) HandleLogin(w http.ResponseWriter, r *http.Request) {
ip := getClientIP(r)
userAgent := r.UserAgent()
// Rate limiting
if !s.rateLimiter.Allow(ip) {
s.audit.LogLogin(false, "", ip, userAgent, "rate_limited")
http.Redirect(w, r, "/admin/login?error=Trop+de+tentatives", http.StatusSeeOther)
return
}
// Parser le formulaire
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/login?error=Formulaire+invalide", http.StatusSeeOther)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
// Enregistrer la tentative
s.rateLimiter.Record(ip)
// Vérifier les credentials
user := s.adminCfg.GetUser(username)
if user == nil {
s.audit.LogLogin(false, username, ip, userAgent, "user_not_found")
http.Redirect(w, r, "/admin/login?error=Identifiants+incorrects", http.StatusSeeOther)
return
}
if !auth.VerifyPassword(user.PasswordHash, password) {
s.audit.LogLogin(false, username, ip, userAgent, "wrong_password")
http.Redirect(w, r, "/admin/login?error=Identifiants+incorrects", http.StatusSeeOther)
return
}
// Créer la session
session, err := s.sessions.Create(username, user.Role, ip, userAgent)
if err != nil {
log.Printf("[admin] failed to create session: %v", err)
http.Redirect(w, r, "/admin/login?error=Erreur+serveur", http.StatusSeeOther)
return
}
// Définir le cookie
s.sessions.SetCookie(w, session)
// Log succès
s.audit.LogLogin(true, username, ip, userAgent, "")
// Rediriger vers dashboard
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}
// HandleLogout déconnecte l'utilisateur.
func (s *AdminServer) HandleLogout(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
if session != nil {
s.audit.LogLogout(session.Username, getClientIP(r))
s.sessions.Delete(session.ID)
}
s.sessions.ClearCookie(w)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
}
// HandleDashboard affiche le dashboard principal.
func (s *AdminServer) HandleDashboard(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Récupérer les apps accessibles
allApps := s.registry.Apps()
accessibleApps := s.perms.GetAccessibleApps(user, allApps)
data := map[string]any{
"Title": "Dashboard",
"User": user,
"Session": session,
"CSRFToken": session.CSRFToken,
"IsSuperAdmin": user.IsSuperAdmin(),
"Apps": accessibleApps,
"Permissions": s.perms.GetUserPermissions(user),
}
s.render(w, "dashboard.html", data)
}
// HandleAPIApps retourne la liste des apps (partial htmx).
func (s *AdminServer) HandleAPIApps(w http.ResponseWriter, r *http.Request) {
user := GetUserFromContext(r.Context())
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
allApps := s.registry.Apps()
accessibleApps := s.perms.GetAccessibleApps(user, allApps)
// Construire les infos des apps
type AppInfo struct {
ID string
Name string
}
apps := make([]AppInfo, 0, len(accessibleApps))
for _, appID := range accessibleApps {
apps = append(apps, AppInfo{
ID: appID,
Name: appID, // On pourrait charger le nom depuis la config
})
}
data := map[string]any{
"Apps": apps,
}
s.render(w, "partials/apps_list.html", data)
}
// HandleAPIServicesHealth retourne le statut des services (partial htmx).
func (s *AdminServer) HandleAPIServicesHealth(w http.ResponseWriter, r *http.Request) {
user := GetUserFromContext(r.Context())
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Seul super_admin peut voir le statut des services
if !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
statuses := s.services.HealthCheck()
data := map[string]any{
"Services": statuses,
}
s.render(w, "partials/services_status.html", data)
}
// AppInfo contient les informations d'une app pour le template.
type AppInfo struct {
App string
Version string
BasePath string
Hosts []string
Database DatabaseInfo
Schema bool
SchemaTableCount int
Queries bool
QueriesCount int
RoutesCount int
}
// DatabaseInfo contient les infos de connexion DB.
type DatabaseInfo struct {
Host string
Port int
User string
Name string
}
// TableInfo contient les infos d'une table pour le template.
type TableInfo struct {
Name string
ColumnCount int
PrimaryKey string
}
// RouteInfo contient les infos d'une route pour le template.
type RouteInfo struct {
Method string
Path string
Handler string
}
// HandleAppsPage affiche la liste des applications.
func (s *AdminServer) HandleAppsPage(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Récupérer les apps accessibles
allApps := s.registry.Apps()
accessibleApps := s.perms.GetAccessibleApps(user, allApps)
// Construire les infos détaillées
apps := make([]AppInfo, 0, len(accessibleApps))
for _, appID := range accessibleApps {
cfg, ok := s.registry.GetByApp(appID)
if !ok {
continue
}
info := AppInfo{
App: cfg.App,
Version: cfg.Version,
BasePath: cfg.BasePath,
Hosts: cfg.Hosts,
Database: DatabaseInfo{
Host: cfg.Database.Host,
Port: cfg.Database.Port,
User: cfg.Database.User,
Name: cfg.Database.Name,
},
}
if cfg.Schema != nil {
info.Schema = true
info.SchemaTableCount = len(cfg.Schema.Tables)
}
if cfg.Queries != nil {
info.Queries = true
info.QueriesCount = cfg.Queries.FileCount()
}
info.RoutesCount = len(cfg.Routes)
apps = append(apps, info)
}
data := map[string]any{
"Title": "Applications",
"User": user,
"Session": session,
"CSRFToken": session.CSRFToken,
"IsSuperAdmin": user.IsSuperAdmin(),
"Apps": apps,
}
s.render(w, "apps.html", data)
}
// HandleAppDetailPage affiche les détails d'une application.
func (s *AdminServer) HandleAppDetailPage(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Récupérer l'app ID depuis l'URL
appID := r.PathValue("appID")
if appID == "" {
http.Redirect(w, r, "/admin/apps", http.StatusSeeOther)
return
}
// Vérifier que l'utilisateur a accès à cette app
allApps := s.registry.Apps()
accessibleApps := s.perms.GetAccessibleApps(user, allApps)
hasAccess := false
for _, a := range accessibleApps {
if a == appID {
hasAccess = true
break
}
}
if !hasAccess {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Récupérer la config de l'app
cfg, ok := s.registry.GetByApp(appID)
if !ok {
http.Error(w, "App not found", http.StatusNotFound)
return
}
// Construire les infos de l'app
appInfo := AppInfo{
App: cfg.App,
Version: cfg.Version,
BasePath: cfg.BasePath,
Hosts: cfg.Hosts,
Database: DatabaseInfo{
Host: cfg.Database.Host,
Port: cfg.Database.Port,
User: cfg.Database.User,
Name: cfg.Database.Name,
},
RoutesCount: len(cfg.Routes),
}
if cfg.Schema != nil {
appInfo.Schema = true
appInfo.SchemaTableCount = len(cfg.Schema.Tables)
}
if cfg.Queries != nil {
appInfo.Queries = true
appInfo.QueriesCount = cfg.Queries.FileCount()
}
// Construire les infos des tables
var tables []TableInfo
if cfg.Schema != nil {
for name, table := range cfg.Schema.Tables {
pk := ""
if len(table.Primary) > 0 {
pk = strings.Join(table.Primary, ", ")
}
tables = append(tables, TableInfo{
Name: name,
ColumnCount: len(table.Columns),
PrimaryKey: pk,
})
}
}
// Construire les infos des routes
var routes []RouteInfo
for _, route := range cfg.Routes {
routes = append(routes, RouteInfo{
Method: route.Method,
Path: route.Path,
Handler: route.Scenario,
})
}
data := map[string]any{
"Title": cfg.App,
"User": user,
"Session": session,
"CSRFToken": session.CSRFToken,
"IsSuperAdmin": user.IsSuperAdmin(),
"App": appInfo,
"Tables": tables,
"Routes": routes,
}
// Flash message depuis URL
if flash := r.URL.Query().Get("flash"); flash != "" {
data["FlashType"] = flash
data["FlashMessage"] = r.URL.Query().Get("msg")
}
s.render(w, "app_detail.html", data)
}
// HandleAPICronJobs retourne la liste des jobs cron (partial htmx).
func (s *AdminServer) HandleAPICronJobs(w http.ResponseWriter, r *http.Request) {
user := GetUserFromContext(r.Context())
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Seul super_admin peut voir les jobs cron
if !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
jobs, err := s.services.GetCronJobs()
if err != nil {
log.Printf("[admin] cron jobs error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data := map[string]any{
"Jobs": jobs,
}
s.render(w, "partials/cron_jobs.html", data)
}
// HandleAppNewPage affiche le formulaire de création d'app.
func (s *AdminServer) HandleAppNewPage(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Seul super_admin peut créer des apps
if !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
data := map[string]any{
"Title": "Nouvelle Application",
"User": user,
"Session": session,
"CSRFToken": session.CSRFToken,
"IsSuperAdmin": user.IsSuperAdmin(),
}
s.render(w, "apps_new.html", data)
}
// HandleAppCreate traite la création d'une nouvelle app.
func (s *AdminServer) HandleAppCreate(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Seul super_admin peut créer des apps
if !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
// Récupérer les valeurs du formulaire
appName := strings.TrimSpace(r.FormValue("app_name"))
version := strings.TrimSpace(r.FormValue("version"))
basePath := strings.TrimSpace(r.FormValue("base_path"))
hostsRaw := strings.TrimSpace(r.FormValue("hosts"))
dbHost := strings.TrimSpace(r.FormValue("db_host"))
dbPort := strings.TrimSpace(r.FormValue("db_port"))
dbUser := strings.TrimSpace(r.FormValue("db_user"))
dbName := strings.TrimSpace(r.FormValue("db_name"))
dbPassword := r.FormValue("db_password")
jwtExpiry := strings.TrimSpace(r.FormValue("jwt_expiry"))
// Validation basique
if appName == "" || basePath == "" || hostsRaw == "" {
http.Error(w, "Champs obligatoires manquants", http.StatusBadRequest)
return
}
if dbHost == "" || dbPort == "" || dbUser == "" || dbName == "" || dbPassword == "" {
http.Error(w, "Configuration base de données incomplète", http.StatusBadRequest)
return
}
// Parser les hosts (un par ligne)
var hosts []string
for _, h := range strings.Split(hostsRaw, "\n") {
h = strings.TrimSpace(h)
if h != "" {
hosts = append(hosts, h)
}
}
if len(hosts) == 0 {
http.Error(w, "Au moins un host requis", http.StatusBadRequest)
return
}
// Créer l'app via le service
err := s.services.CreateApp(appName, version, basePath, hosts, dbHost, dbPort, dbUser, dbName, dbPassword, jwtExpiry)
if err != nil {
log.Printf("[admin] create app error: %v", err)
http.Error(w, "Erreur création app: "+err.Error(), http.StatusInternalServerError)
return
}
// Log l'action
s.audit.LogAction(user.Username, "create_app", appName, map[string]any{
"ip": getClientIP(r),
})
// Rediriger vers la page de l'app
http.Redirect(w, r, "/admin/apps/"+appName, http.StatusSeeOther)
}
// HandleAppScanDB scanne la base de données et génère le schema.yaml.
func (s *AdminServer) HandleAppScanDB(w http.ResponseWriter, r *http.Request) {
session := GetSessionFromContext(r.Context())
user := GetUserFromContext(r.Context())
if session == nil || user == nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Seul super_admin peut scanner les bases
if !user.IsSuperAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
appID := r.PathValue("appID")
if appID == "" {
http.Error(w, "App ID required", http.StatusBadRequest)
return
}
// Vérifier que l'app existe
if _, ok := s.registry.GetByApp(appID); !ok {
http.Error(w, "App not found", http.StatusNotFound)
return
}
// Scanner la base et générer le schema
tableCount, err := s.services.ScanAndGenerateSchema(appID)
if err != nil {
log.Printf("[admin] scan db error: %v", err)
redirectURL := fmt.Sprintf("/admin/apps/%s?flash=error&msg=%s", appID, url.QueryEscape("Erreur scan: "+err.Error()))
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
return
}
// Recharger le registry local
if err := s.registry.Load(); err != nil {
log.Printf("[admin] reload registry error: %v", err)
}
// Demander à sogoway de recharger sa config
if err := s.services.ReloadGateway(); err != nil {
log.Printf("[admin] reload gateway error: %v", err)
// On ne bloque pas, on continue avec le message de succès
} else {
log.Printf("[admin] gateway reloaded successfully")
}
// Log l'action
s.audit.LogAction(user.Username, "scan_db", appID, map[string]any{
"ip": getClientIP(r),
"tables": tableCount,
})
// Rediriger vers la page de l'app avec message de succès
msg := fmt.Sprintf("Scan terminé : %d tables détectées", tableCount)
redirectURL := fmt.Sprintf("/admin/apps/%s?flash=success&msg=%s", appID, url.QueryEscape(msg))
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
}
// render rend un template.
func (s *AdminServer) render(w http.ResponseWriter, name string, data map[string]any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl := s.getTemplates()
if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
log.Printf("[admin] template error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

198
cmd/sogoms/admin/main.go Normal file
View File

@@ -0,0 +1,198 @@
// sogoms-admin : Interface web d'administration pour SOGOMS.
// Gestion des apps, schemas, queries, emails, crons et logs.
package main
import (
"embed"
"flag"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"sogoms.com/internal/admin"
"sogoms.com/internal/config"
"sogoms.com/internal/protocol"
)
//go:embed templates/*.html templates/partials/*.html
var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
var (
port = flag.Int("port", 9000, "HTTP server port")
configDir = flag.String("config", "/config", "Configuration directory")
secretsDir = flag.String("secrets", "/secrets", "Secrets directory")
templatesDir = flag.String("templates", "", "Templates directory (empty = use embedded)")
devMode = flag.Bool("dev", false, "Dev mode: reload templates on each request")
dbSocket = flag.String("db-socket", "/run/sogoms-db.1.sock", "DB service socket")
logsSocket = flag.String("logs-socket", "/run/sogoms-logs.1.sock", "Logs service socket")
cronSocket = flag.String("cron-socket", "/run/sogoms-cron.1.sock", "Cron service socket")
)
func main() {
flag.Parse()
log.SetFlags(log.Ltime | log.Lshortfile)
// Charger la config admin
adminConfigPath := *secretsDir + "/admin_users.yaml"
adminCfg, err := admin.LoadAdminConfig(adminConfigPath)
if err != nil {
log.Fatalf("load admin config: %v", err)
}
log.Printf("[admin] loaded %d admin users", len(adminCfg.Users))
// Charger le registry des apps
registry := config.NewRegistry(*configDir)
if err := registry.Load(); err != nil {
log.Fatalf("load config: %v", err)
}
log.Printf("[admin] loaded apps: %v", registry.Apps())
// Pools de connexion aux services
services := &ServicePool{}
if *dbSocket != "" {
services.DB = protocol.NewPool(*dbSocket, 2)
}
if *logsSocket != "" {
services.Logs = protocol.NewPool(*logsSocket, 2)
}
if *cronSocket != "" {
services.Cron = protocol.NewPool(*cronSocket, 2)
}
// Session store
sessions := NewSessionStore(&adminCfg.Session)
go sessions.Cleanup()
// Rate limiter
rateLimiter := NewRateLimiter(&adminCfg.RateLimit)
// Permission checker
perms := admin.NewPermissionChecker(adminCfg)
// Audit logger
audit := admin.NewAuditLogger(services.Logs)
// Charger les templates
templates, err := loadTemplates(*templatesDir)
if err != nil {
log.Fatalf("load templates: %v", err)
}
if *templatesDir != "" {
log.Printf("[admin] templates loaded from filesystem: %s", *templatesDir)
if *devMode {
log.Printf("[admin] dev mode: templates will reload on each request")
}
} else {
log.Printf("[admin] templates loaded from embedded")
}
// Créer le serveur
server := &AdminServer{
adminCfg: adminCfg,
registry: registry,
sessions: sessions,
rateLimiter: rateLimiter,
perms: perms,
audit: audit,
services: services,
templates: templates,
templatesDir: *templatesDir,
devMode: *devMode,
}
// Router
mux := http.NewServeMux()
// Fichiers statiques (CSS, JS)
staticSubFS, _ := fs.Sub(staticFS, "static")
staticHandler := http.FileServerFS(staticSubFS)
mux.Handle("GET /admin/static/", http.StripPrefix("/admin/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=31536000")
staticHandler.ServeHTTP(w, r)
})))
// Routes publiques
mux.HandleFunc("GET /admin/login", server.HandleLoginPage)
mux.HandleFunc("POST /admin/login", server.HandleLogin)
// Routes protégées
mux.HandleFunc("GET /admin/{$}", AuthMiddleware(sessions, adminCfg, server.HandleDashboard))
mux.HandleFunc("GET /admin/apps", AuthMiddleware(sessions, adminCfg, server.HandleAppsPage))
mux.HandleFunc("GET /admin/apps/new", AuthMiddleware(sessions, adminCfg, server.HandleAppNewPage))
mux.HandleFunc("POST /admin/apps/new", AuthMiddleware(sessions, adminCfg,
CSRFMiddleware(sessions, server.HandleAppCreate)))
mux.HandleFunc("GET /admin/apps/{appID}", AuthMiddleware(sessions, adminCfg, server.HandleAppDetailPage))
mux.HandleFunc("POST /admin/apps/{appID}/scan", AuthMiddleware(sessions, adminCfg,
CSRFMiddleware(sessions, server.HandleAppScanDB)))
mux.HandleFunc("POST /admin/logout", AuthMiddleware(sessions, adminCfg,
CSRFMiddleware(sessions, server.HandleLogout)))
// API htmx (protégées)
mux.HandleFunc("GET /admin/api/apps", AuthMiddleware(sessions, adminCfg, server.HandleAPIApps))
mux.HandleFunc("GET /admin/api/services/health", AuthMiddleware(sessions, adminCfg, server.HandleAPIServicesHealth))
mux.HandleFunc("GET /admin/api/cron/jobs", AuthMiddleware(sessions, adminCfg, server.HandleAPICronJobs))
// Handler avec logging
handler := LoggingMiddleware(mux)
// Démarrer le serveur
addr := fmt.Sprintf(":%d", *port)
httpServer := &http.Server{
Addr: addr,
Handler: handler,
}
go func() {
log.Printf("[admin] sogoms-admin started on %s", addr)
if err := httpServer.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()
// Attendre signal d'arrêt
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Printf("[admin] shutting down...")
httpServer.Close()
}
// loadTemplates charge les templates depuis le filesystem ou embedded.
func loadTemplates(dir string) (*template.Template, error) {
funcMap := template.FuncMap{
"safe": func(s string) template.HTML {
return template.HTML(s)
},
}
if dir != "" {
// Charger depuis le filesystem
tmpl, err := template.New("").Funcs(funcMap).ParseGlob(dir + "/*.html")
if err != nil {
return nil, err
}
// Charger les partials
tmpl, err = tmpl.ParseGlob(dir + "/partials/*.html")
if err != nil {
return nil, err
}
return tmpl, nil
}
// Charger depuis embedded
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesFS, "templates/*.html", "templates/partials/*.html")
if err != nil {
return nil, err
}
return tmpl, nil
}

View File

@@ -0,0 +1,235 @@
package main
import (
"context"
"log"
"net/http"
"sync"
"time"
"sogoms.com/internal/admin"
)
// contextKey est une clé pour le contexte.
type contextKey string
const (
ctxSession contextKey = "session"
ctxUser contextKey = "user"
)
// GetSessionFromContext récupère la session depuis le contexte.
func GetSessionFromContext(ctx context.Context) *Session {
if session, ok := ctx.Value(ctxSession).(*Session); ok {
return session
}
return nil
}
// GetUserFromContext récupère l'utilisateur depuis le contexte.
func GetUserFromContext(ctx context.Context) *admin.AdminUser {
if user, ok := ctx.Value(ctxUser).(*admin.AdminUser); ok {
return user
}
return nil
}
// AuthMiddleware vérifie que l'utilisateur est authentifié.
func AuthMiddleware(sessions *SessionStore, adminCfg *admin.AdminConfig, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session, err := sessions.GetSessionFromRequest(r)
if err != nil {
log.Printf("[admin] auth failed: %v", err)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Récupérer l'utilisateur
user := adminCfg.GetUser(session.Username)
if user == nil {
log.Printf("[admin] user not found: %s", session.Username)
sessions.Delete(session.ID)
sessions.ClearCookie(w)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Prolonger la session (sliding expiration)
sessions.Refresh(session.ID)
// Injecter dans le contexte
ctx := context.WithValue(r.Context(), ctxSession, session)
ctx = context.WithValue(ctx, ctxUser, user)
next(w, r.WithContext(ctx))
}
}
// CSRFMiddleware vérifie le token CSRF pour les requêtes POST/PUT/DELETE.
func CSRFMiddleware(sessions *SessionStore, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Seules les requêtes de modification nécessitent CSRF
if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
next(w, r)
return
}
session, err := sessions.GetSessionFromRequest(r)
if err != nil {
http.Error(w, "Invalid session", http.StatusForbidden)
return
}
// Récupérer le token CSRF (header ou form)
csrfToken := r.Header.Get("X-CSRF-Token")
if csrfToken == "" {
csrfToken = r.FormValue("csrf_token")
}
if csrfToken == "" || csrfToken != session.CSRFToken {
log.Printf("[admin] CSRF validation failed for %s", session.Username)
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
next(w, r)
}
}
// RateLimiter limite les tentatives de login.
type RateLimiter struct {
attempts map[string][]time.Time // IP -> timestamps
config *admin.RateLimitConfig
mu sync.Mutex
}
// NewRateLimiter crée un nouveau rate limiter.
func NewRateLimiter(config *admin.RateLimitConfig) *RateLimiter {
return &RateLimiter{
attempts: make(map[string][]time.Time),
config: config,
}
}
// Allow vérifie si une IP peut tenter un login.
func (r *RateLimiter) Allow(ip string) bool {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
window := time.Duration(r.config.LoginWindow) * time.Second
cutoff := now.Add(-window)
// Nettoyer les anciennes tentatives
var recent []time.Time
for _, t := range r.attempts[ip] {
if t.After(cutoff) {
recent = append(recent, t)
}
}
r.attempts[ip] = recent
return len(recent) < r.config.LoginMax
}
// Record enregistre une tentative.
func (r *RateLimiter) Record(ip string) {
r.mu.Lock()
defer r.mu.Unlock()
r.attempts[ip] = append(r.attempts[ip], time.Now())
}
// Remaining retourne le nombre de tentatives restantes.
func (r *RateLimiter) Remaining(ip string) int {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
window := time.Duration(r.config.LoginWindow) * time.Second
cutoff := now.Add(-window)
var count int
for _, t := range r.attempts[ip] {
if t.After(cutoff) {
count++
}
}
remaining := r.config.LoginMax - count
if remaining < 0 {
remaining = 0
}
return remaining
}
// RateLimitMiddleware applique le rate limiting.
func RateLimitMiddleware(rateLimiter *RateLimiter, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := getClientIP(r)
if !rateLimiter.Allow(ip) {
log.Printf("[admin] rate limit exceeded for IP: %s", ip)
http.Error(w, "Too many attempts. Please try again later.", http.StatusTooManyRequests)
return
}
next(w, r)
}
}
// LoggingMiddleware log toutes les requêtes.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrapper pour capturer le status code
lw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(lw, r)
log.Printf("[admin] %s %s %d %s", r.Method, r.URL.Path, lw.statusCode, time.Since(start))
})
}
// loggingResponseWriter capture le status code.
type loggingResponseWriter struct {
http.ResponseWriter
statusCode int
}
func (lw *loggingResponseWriter) WriteHeader(code int) {
lw.statusCode = code
lw.ResponseWriter.WriteHeader(code)
}
// getClientIP récupère l'IP du client.
func getClientIP(r *http.Request) string {
// Vérifier X-Forwarded-For (proxy/load balancer)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Prendre la première IP
if idx := len(xff); idx > 0 {
for i, c := range xff {
if c == ',' {
return xff[:i]
}
}
return xff
}
}
// Vérifier X-Real-IP
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
// Fallback sur RemoteAddr
ip := r.RemoteAddr
// Enlever le port
for i := len(ip) - 1; i >= 0; i-- {
if ip[i] == ':' {
return ip[:i]
}
}
return ip
}

View File

@@ -0,0 +1,448 @@
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)
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
}
// 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
}
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
}
// 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
}

214
cmd/sogoms/admin/session.go Normal file
View File

@@ -0,0 +1,214 @@
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"strings"
"sync"
"time"
"sogoms.com/internal/admin"
)
// Session représente une session utilisateur.
type Session struct {
ID string
Username string
Role string
CSRFToken string
CreatedAt time.Time
ExpiresAt time.Time
IP string
UserAgent string
}
// IsExpired vérifie si la session a expiré.
func (s *Session) IsExpired() bool {
return time.Now().After(s.ExpiresAt)
}
// SessionStore gère les sessions en mémoire.
type SessionStore struct {
sessions map[string]*Session
config *admin.SessionConfig
mu sync.RWMutex
}
// NewSessionStore crée un nouveau store de sessions.
func NewSessionStore(config *admin.SessionConfig) *SessionStore {
return &SessionStore{
sessions: make(map[string]*Session),
config: config,
}
}
// Create crée une nouvelle session.
func (s *SessionStore) Create(username, role, ip, userAgent string) (*Session, error) {
sessionID, err := generateSecureToken(32)
if err != nil {
return nil, err
}
csrfToken, err := generateSecureToken(32)
if err != nil {
return nil, err
}
now := time.Now()
session := &Session{
ID: sessionID,
Username: username,
Role: role,
CSRFToken: csrfToken,
CreatedAt: now,
ExpiresAt: now.Add(time.Duration(s.config.MaxAge) * time.Second),
IP: ip,
UserAgent: userAgent,
}
s.mu.Lock()
s.sessions[sessionID] = session
s.mu.Unlock()
return session, nil
}
// Get récupère une session par son ID.
func (s *SessionStore) Get(sessionID string) (*Session, bool) {
s.mu.RLock()
session, ok := s.sessions[sessionID]
s.mu.RUnlock()
if !ok || session.IsExpired() {
return nil, false
}
return session, true
}
// Delete supprime une session.
func (s *SessionStore) Delete(sessionID string) {
s.mu.Lock()
delete(s.sessions, sessionID)
s.mu.Unlock()
}
// Refresh prolonge la durée de vie d'une session (sliding expiration).
func (s *SessionStore) Refresh(sessionID string) {
s.mu.Lock()
if session, ok := s.sessions[sessionID]; ok {
session.ExpiresAt = time.Now().Add(time.Duration(s.config.MaxAge) * time.Second)
}
s.mu.Unlock()
}
// Cleanup supprime les sessions expirées.
func (s *SessionStore) Cleanup() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
s.mu.Lock()
now := time.Now()
for id, session := range s.sessions {
if now.After(session.ExpiresAt) {
delete(s.sessions, id)
}
}
s.mu.Unlock()
}
}
// Count retourne le nombre de sessions actives.
func (s *SessionStore) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.sessions)
}
// SetCookie définit le cookie de session dans la réponse.
func (s *SessionStore) SetCookie(w http.ResponseWriter, session *Session) {
// Signer le session ID
signature := s.sign(session.ID)
value := session.ID + "." + signature
http.SetCookie(w, &http.Cookie{
Name: s.config.CookieName,
Value: value,
Path: "/admin",
MaxAge: s.config.MaxAge,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
// ClearCookie supprime le cookie de session.
func (s *SessionStore) ClearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: s.config.CookieName,
Value: "",
Path: "/admin",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
// GetSessionFromRequest extrait et valide la session depuis le cookie.
func (s *SessionStore) GetSessionFromRequest(r *http.Request) (*Session, error) {
cookie, err := r.Cookie(s.config.CookieName)
if err != nil {
return nil, fmt.Errorf("no session cookie")
}
// Séparer ID et signature
parts := strings.SplitN(cookie.Value, ".", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid cookie format")
}
sessionID := parts[0]
signature := parts[1]
// Vérifier la signature
if !s.verify(sessionID, signature) {
return nil, fmt.Errorf("invalid cookie signature")
}
// Récupérer la session
session, ok := s.Get(sessionID)
if !ok {
return nil, fmt.Errorf("session not found or expired")
}
return session, nil
}
// sign signe une donnée avec HMAC-SHA256.
func (s *SessionStore) sign(data string) string {
h := hmac.New(sha256.New, []byte(s.config.Secret))
h.Write([]byte(data))
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
// verify vérifie une signature HMAC.
func (s *SessionStore) verify(data, signature string) bool {
expected := s.sign(data)
return hmac.Equal([]byte(expected), []byte(signature))
}
// generateSecureToken génère un token aléatoire sécurisé.
func generateSecureToken(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}

1
cmd/sogoms/admin/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

4
cmd/sogoms/admin/static/pico.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,116 @@
{{define "app_detail.html"}}
{{template "partials/header.html" .}}
<h1>{{.App.App}}</h1>
<div class="card-grid">
<!-- Infos générales -->
<article>
<header><strong>Informations</strong></header>
<dl>
<dt>Version</dt>
<dd>{{if .App.Version}}{{.App.Version}}{{else}}<em>Non définie</em>{{end}}</dd>
<dt>Base Path</dt>
<dd><code>{{.App.BasePath}}</code></dd>
<dt>Hosts</dt>
<dd>
{{range .App.Hosts}}
<code>{{.}}</code><br>
{{end}}
</dd>
</dl>
</article>
<!-- Database -->
<article>
<header><strong>Base de données</strong></header>
<dl>
<dt>Host</dt>
<dd><code>{{.App.Database.Host}}:{{.App.Database.Port}}</code></dd>
<dt>Database</dt>
<dd><code>{{.App.Database.Name}}</code></dd>
<dt>User</dt>
<dd><code>{{.App.Database.User}}</code></dd>
</dl>
<form method="post" action="/admin/apps/{{.App.App}}/scan" style="margin-top: 1rem;">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="outline">Scanner la base</button>
</form>
</article>
<!-- Stats -->
<article>
<header><strong>Configuration</strong></header>
<dl>
{{if .App.Schema}}
<dt>Tables (schema)</dt>
<dd>{{.App.SchemaTableCount}} tables</dd>
{{end}}
{{if .App.Queries}}
<dt>Fichiers queries</dt>
<dd>{{.App.QueriesCount}} fichiers</dd>
{{end}}
<dt>Routes</dt>
<dd>{{.App.RoutesCount}} routes</dd>
</dl>
</article>
</div>
{{if .App.Schema}}
<!-- Schema / Tables -->
<article>
<header><strong>Schema - Tables</strong></header>
<table>
<thead>
<tr>
<th>Table</th>
<th>Colonnes</th>
<th>Clé primaire</th>
</tr>
</thead>
<tbody>
{{range .Tables}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td>{{.ColumnCount}}</td>
<td><code>{{.PrimaryKey}}</code></td>
</tr>
{{end}}
</tbody>
</table>
</article>
{{end}}
{{if .Routes}}
<!-- Routes -->
<article>
<header><strong>Routes API</strong></header>
<table>
<thead>
<tr>
<th>Méthode</th>
<th>Path</th>
<th>Handler</th>
</tr>
</thead>
<tbody>
{{range .Routes}}
<tr>
<td><code>{{.Method}}</code></td>
<td><code>{{.Path}}</code></td>
<td>{{.Handler}}</td>
</tr>
{{end}}
</tbody>
</table>
</article>
{{end}}
{{template "partials/footer.html" .}}
{{end}}

View File

@@ -0,0 +1,58 @@
{{define "apps.html"}}
{{template "partials/header.html" .}}
<div style="display: flex; justify-content: space-between; align-items: center;">
<h1>Applications</h1>
{{if .IsSuperAdmin}}
<a href="/admin/apps/new" role="button">+ Nouvelle App</a>
{{end}}
</div>
{{if .Apps}}
<div class="apps-list">
{{range .Apps}}
<article>
<header>
<strong>{{.App}}</strong>
{{if .Version}}<small>v{{.Version}}</small>{{end}}
</header>
<dl>
<dt>Hosts</dt>
<dd>
{{range .Hosts}}
<code>{{.}}</code><br>
{{end}}
</dd>
<dt>Base Path</dt>
<dd><code>{{.BasePath}}</code></dd>
<dt>Database</dt>
<dd>
<code>{{.Database.User}}@{{.Database.Host}}:{{.Database.Port}}/{{.Database.Name}}</code>
</dd>
{{if .Schema}}
<dt>Tables (schema)</dt>
<dd>{{.SchemaTableCount}} tables</dd>
{{end}}
{{if .Queries}}
<dt>Queries</dt>
<dd>{{.QueriesCount}} fichiers</dd>
{{end}}
</dl>
<footer>
<a href="/admin/apps/{{.App}}" role="button" class="outline">Détails</a>
</footer>
</article>
{{end}}
</div>
{{else}}
<p>Aucune application configurée.</p>
{{end}}
{{template "partials/footer.html" .}}
{{end}}

View File

@@ -0,0 +1,88 @@
{{define "apps_new.html"}}
{{template "partials/header.html" .}}
<h1>Nouvelle Application</h1>
<form method="post" action="/admin/apps/new">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<!-- Informations générales -->
<article>
<header><strong>Informations</strong></header>
<label for="app_name">Nom de l'application *</label>
<input type="text" id="app_name" name="app_name" required
pattern="[a-z][a-z0-9_]*" placeholder="monapp"
aria-describedby="app_name_help">
<small id="app_name_help">Lettres minuscules, chiffres et underscore uniquement</small>
<label for="version">Version</label>
<input type="text" id="version" name="version" value="1.0" placeholder="1.0">
<label for="base_path">Base Path *</label>
<input type="text" id="base_path" name="base_path" value="/api" required placeholder="/api">
</article>
<!-- Hosts -->
<article>
<header><strong>Hosts</strong></header>
<label for="hosts">Domaines (un par ligne) *</label>
<textarea id="hosts" name="hosts" rows="3" required
placeholder="monapp.example.com&#10;monapp.sogoms.com"></textarea>
</article>
<!-- Database -->
<article>
<header><strong>Base de donn&eacute;es</strong></header>
<div class="grid">
<div>
<label for="db_host">Host *</label>
<input type="text" id="db_host" name="db_host" required placeholder="127.0.0.1">
</div>
<div>
<label for="db_port">Port *</label>
<input type="number" id="db_port" name="db_port" value="3306" required>
</div>
</div>
<div class="grid">
<div>
<label for="db_user">Utilisateur *</label>
<input type="text" id="db_user" name="db_user" required placeholder="monapp_user">
</div>
<div>
<label for="db_name">Base *</label>
<input type="text" id="db_name" name="db_name" required placeholder="monapp">
</div>
</div>
<label for="db_password">Mot de passe *</label>
<input type="password" id="db_password" name="db_password" required>
</article>
<!-- Auth -->
<article>
<header><strong>Authentification JWT</strong></header>
<label for="jwt_expiry">Dur&eacute;e du token</label>
<select id="jwt_expiry" name="jwt_expiry">
<option value="1h">1 heure</option>
<option value="12h">12 heures</option>
<option value="24h" selected>24 heures</option>
<option value="168h">7 jours</option>
<option value="720h">30 jours</option>
</select>
<small>Le secret JWT sera g&eacute;n&eacute;r&eacute; automatiquement</small>
</article>
<!-- Actions -->
<div class="grid">
<a href="/admin/apps" role="button" class="secondary outline">Annuler</a>
<button type="submit">Cr&eacute;er l'application</button>
</div>
</form>
{{template "partials/footer.html" .}}
{{end}}

View File

@@ -0,0 +1,81 @@
{{define "dashboard.html"}}
{{template "partials/header.html" .}}
<h1>Dashboard</h1>
<p class="user-info">
Connect&eacute; en tant que <strong>{{.User.Username}}</strong>
{{if .IsSuperAdmin}}(Super Admin){{else}}(App Admin){{end}}
</p>
<div class="card-grid">
{{if .IsSuperAdmin}}
<!-- Services Status (super admin only) -->
<article>
<header>
<strong>Services</strong>
<span class="htmx-indicator" aria-busy="true"></span>
</header>
<div hx-get="/admin/api/services/health"
hx-trigger="load, every 30s"
hx-indicator=".htmx-indicator">
Chargement...
</div>
</article>
{{end}}
<!-- Applications -->
<article>
<header>
<strong>Applications</strong>
{{if .IsSuperAdmin}}
<small>({{len .Apps}} apps)</small>
{{end}}
</header>
<div hx-get="/admin/api/apps"
hx-trigger="load">
Chargement...
</div>
</article>
{{if .IsSuperAdmin}}
<!-- Quick Stats -->
<article>
<header><strong>Statistiques</strong></header>
<dl>
<dt>Applications</dt>
<dd>{{len .Apps}}</dd>
</dl>
</article>
{{end}}
</div>
{{if .IsSuperAdmin}}
<!-- Jobs Cron (super admin only) -->
<article>
<header>
<strong>Jobs Cron</strong>
<span class="htmx-indicator" aria-busy="true"></span>
</header>
<div hx-get="/admin/api/cron/jobs"
hx-trigger="load"
hx-indicator=".htmx-indicator">
Chargement...
</div>
</article>
{{end}}
{{if not .IsSuperAdmin}}
<!-- Permissions de l'utilisateur -->
<article>
<header><strong>Vos permissions</strong></header>
<ul>
{{range .Permissions}}
<li><code>{{.}}</code></li>
{{end}}
</ul>
</article>
{{end}}
{{template "partials/footer.html" .}}
{{end}}

View File

@@ -0,0 +1,69 @@
{{define "login.html"}}
<!DOCTYPE html>
<html lang="fr" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connexion - SOGOMS Admin</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<style>
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.login-card {
width: 100%;
max-width: 400px;
}
.logo {
text-align: center;
font-size: 2rem;
font-weight: bold;
margin-bottom: 2rem;
}
.logo span {
color: var(--pico-primary);
}
.error-message {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 0.75rem 1rem;
border-radius: var(--pico-border-radius);
margin-bottom: 1rem;
}
</style>
</head>
<body>
<article class="login-card">
<div class="logo">SOGO<span>MS</span> Admin</div>
{{if .Error}}
<div class="error-message">
{{.Error}}
</div>
{{end}}
<form action="/admin/login" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="username">
Nom d'utilisateur
<input type="text" id="username" name="username"
placeholder="Votre identifiant" required autofocus>
</label>
<label for="password">
Mot de passe
<input type="password" id="password" name="password"
placeholder="Votre mot de passe" required>
</label>
<button type="submit">Se connecter</button>
</form>
</article>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,16 @@
{{define "partials/apps_list.html"}}
{{if .Apps}}
<ul>
{{range .Apps}}
<li>
<a href="/admin/apps/{{.ID}}">
<strong>{{.Name}}</strong>
</a>
</li>
{{end}}
</ul>
<p><a href="/admin/apps">Voir toutes les apps &rarr;</a></p>
{{else}}
<p><em>Aucune application accessible</em></p>
{{end}}
{{end}}

View File

@@ -0,0 +1,36 @@
{{define "partials/cron_jobs.html"}}
{{if .Jobs}}
<table>
<thead>
<tr>
<th>App</th>
<th>Job</th>
<th>Type</th>
<th>Schedule</th>
<th>Prochain run</th>
<th>Statut</th>
</tr>
</thead>
<tbody>
{{range .Jobs}}
<tr>
<td><code>{{index . "app_id"}}</code></td>
<td><strong>{{index . "name"}}</strong></td>
<td>{{index . "type"}}</td>
<td><code>{{index . "schedule"}}</code></td>
<td>{{index . "next_run"}}</td>
<td>
{{if index . "enabled"}}
<span class="status-badge status-ok">Actif</span>
{{else}}
<span class="status-badge status-error">Inactif</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>Aucun job cron configuré.</p>
{{end}}
{{end}}

View File

@@ -0,0 +1,61 @@
{{define "partials/flash.html"}}
{{if .FlashMessage}}
<div id="flash-message" class="flash flash-{{.FlashType}}" role="alert">
{{.FlashMessage}}
</div>
<style>
.flash {
position: fixed;
top: 1rem;
left: 50%;
transform: translateX(-50%);
padding: 1rem 2rem;
border-radius: 4px;
font-weight: 500;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
animation: fadeIn 0.3s ease-out;
}
.flash.fade-out {
animation: fadeOut 0.5s ease-out forwards;
}
.flash-success {
background: #d1fae5;
color: #065f46;
border: 1px solid #6ee7b7;
}
.flash-info {
background: #dbeafe;
color: #1e40af;
border: 1px solid #93c5fd;
}
.flash-warning {
background: #fef3c7;
color: #92400e;
border: 1px solid #fcd34d;
}
.flash-error {
background: #fee2e2;
color: #991b1b;
border: 1px solid #fca5a5;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(-20px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
</style>
<script>
setTimeout(function() {
var flash = document.getElementById('flash-message');
if (flash) {
flash.classList.add('fade-out');
setTimeout(function() { flash.remove(); }, 500);
}
}, 4000);
</script>
{{end}}
{{end}}

View File

@@ -0,0 +1,9 @@
{{define "partials/footer.html"}}
</main>
<footer class="container">
<hr>
<small>SOGOMS Admin &copy; 2025</small>
</footer>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,50 @@
{{define "partials/header.html"}}
<!DOCTYPE html>
<html lang="fr" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - SOGOMS Admin</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%231095c1' d='M0,3v8H11V0H3A3,3,0,0,0,0,3Z'/%3E%3Cpath fill='%231095c1' d='M21,0H13V11H24V3A3,3,0,0,0,21,0Z'/%3E%3Cpath fill='%231095c1' d='M0,21a3,3,0,0,0,3,3h8V13H0Z'/%3E%3Cpath fill='%231095c1' d='M13,24h8a3,3,0,0,0,3-3V13H13Z'/%3E%3C/svg%3E">
<link rel="stylesheet" href="/admin/static/pico.min.css">
<script src="/admin/static/htmx.min.js"></script>
<style>
.logo { font-weight: bold; font-size: 1.2rem; display: flex; align-items: center; gap: 0.5rem; }
.logo svg { width: 24px; height: 24px; }
.logo span { color: var(--pico-primary); }
.status-badge { display: inline-block; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.875rem; font-weight: 500; }
.status-ok { background: #10b981; color: white; }
.status-error { background: #ef4444; color: white; }
.card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; }
.user-info { font-size: 0.875rem; color: var(--pico-muted-color); }
.htmx-indicator { opacity: 0; transition: opacity 200ms ease-in; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1; }
</style>
</head>
<body>
<header class="container">
<nav>
<ul>
<li><a href="/admin/" class="logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1095c1" d="M0,3v8H11V0H3A3,3,0,0,0,0,3Z"/><path fill="#1095c1" d="M21,0H13V11H24V3A3,3,0,0,0,21,0Z"/><path fill="#1095c1" d="M0,21a3,3,0,0,0,3,3h8V13H0Z"/><path fill="#1095c1" d="M13,24h8a3,3,0,0,0,3-3V13H13Z"/></svg>SOGO<span>MS</span></a></li>
</ul>
<ul>
{{if .User}}
<li><a href="/admin/"{{if eq .Title "Dashboard"}} aria-current="page"{{end}}>Dashboard</a></li>
{{if .IsSuperAdmin}}
<li><a href="/admin/apps"{{if eq .Title "Applications"}} aria-current="page"{{end}}>Apps</a></li>
{{end}}
<li>
<form action="/admin/logout" method="post" style="margin:0">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="outline secondary" style="margin:0;padding:0.5rem 1rem">
{{.User.Username}} - Logout
</button>
</form>
</li>
{{end}}
</ul>
</nav>
</header>
<main class="container">
{{template "partials/flash.html" .}}
{{end}}

View File

@@ -0,0 +1,17 @@
{{define "partials/services_status.html"}}
<ul>
{{range .Services}}
<li>
<strong>{{.Name}}</strong>
{{if .Available}}
<span class="status-badge status-ok">OK</span>
{{else}}
<span class="status-badge status-error">Erreur</span>
{{end}}
{{if .LatencyMs}}
<small>({{.LatencyMs}}ms)</small>
{{end}}
</li>
{{end}}
</ul>
{{end}}

743
cmd/sogoms/cron/main.go Normal file
View File

@@ -0,0 +1,743 @@
// sogoms-cron : Microservice de tâches planifiées.
// Exécute des jobs périodiques définis dans config/apps/{app}/cron.yaml.
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
"gopkg.in/yaml.v3"
"sogoms.com/internal/config"
"sogoms.com/internal/cron"
"sogoms.com/internal/protocol"
)
var (
socketPath = flag.String("socket", "/run/sogoms-cron.1.sock", "Unix socket path")
configDir = flag.String("config", "/config", "Configuration directory")
dbSocket = flag.String("db-socket", "/run/sogoms-db.1.sock", "DB service socket")
smtpSocket = flag.String("smtp-socket", "/run/sogoms-smtp.1.sock", "SMTP service socket")
logsSocket = flag.String("logs-socket", "/run/sogoms-logs.1.sock", "Logs service socket")
)
// CronConfig représente la configuration cron d'une application.
type CronConfig struct {
Timezone string `yaml:"timezone"`
Retry RetryConfig `yaml:"retry"`
HistoryDays int `yaml:"history_days"`
Jobs map[string]*JobConfig `yaml:"jobs"`
location *time.Location
}
// RetryConfig configure les tentatives en cas d'échec.
type RetryConfig struct {
MaxAttempts int `yaml:"max_attempts"`
Delay string `yaml:"delay"`
delayDur time.Duration
}
// JobConfig représente un job planifié.
type JobConfig struct {
Schedule string `yaml:"schedule"`
Type string `yaml:"type"` // query_email, http, service
Enabled bool `yaml:"enabled"`
// Pour query_email
Query string `yaml:"query"`
GroupBy string `yaml:"group_by"`
Template string `yaml:"template"`
// Pour http
Method string `yaml:"method"`
URL string `yaml:"url"`
Headers map[string]string `yaml:"headers"`
Body string `yaml:"body"`
// Pour service
Service string `yaml:"service"`
Action string `yaml:"action"`
Params map[string]any `yaml:"params"`
// Runtime
schedule *cron.Schedule
nextRun time.Time
}
// JobExecution représente une exécution de job (historique).
type JobExecution struct {
JobName string `json:"job_name"`
AppID string `json:"app_id"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Success bool `json:"success"`
Attempt int `json:"attempt"`
Result string `json:"result"`
Error string `json:"error,omitempty"`
}
// CronManager gère les jobs cron pour toutes les applications.
type CronManager struct {
registry *config.Registry
configDir string
configs map[string]*CronConfig // appID -> config
executions []*JobExecution
historyDays int
dbPool *protocol.Pool
smtpPool *protocol.Pool
logsPool *protocol.Pool
stopCh chan struct{}
mu sync.RWMutex
}
// NewCronManager crée un nouveau gestionnaire cron.
func NewCronManager(registry *config.Registry, configDir string, dbPool, smtpPool, logsPool *protocol.Pool) *CronManager {
return &CronManager{
registry: registry,
configDir: configDir,
configs: make(map[string]*CronConfig),
executions: make([]*JobExecution, 0),
historyDays: 7,
dbPool: dbPool,
smtpPool: smtpPool,
logsPool: logsPool,
stopCh: make(chan struct{}),
}
}
// Load charge les configurations cron pour toutes les applications.
func (m *CronManager) Load() error {
m.mu.Lock()
defer m.mu.Unlock()
for _, appID := range m.registry.Apps() {
cronPath := filepath.Join(m.configDir, "apps", appID, "cron.yaml")
if _, err := os.Stat(cronPath); os.IsNotExist(err) {
continue // Pas de config cron pour cette app
}
cfg, err := m.loadCronConfig(cronPath)
if err != nil {
log.Printf("[cron] warning: cannot load %s: %v", appID, err)
continue
}
// Parser les schedules des jobs
for name, job := range cfg.Jobs {
if !job.Enabled {
continue
}
sched, err := cron.ParseSchedule(job.Schedule, cfg.location)
if err != nil {
log.Printf("[cron] warning: %s/%s invalid schedule: %v", appID, name, err)
job.Enabled = false
continue
}
job.schedule = sched
job.nextRun = sched.Next(time.Now())
}
m.configs[appID] = cfg
log.Printf("[cron] loaded %s: %d jobs", appID, len(cfg.Jobs))
}
return nil
}
// loadCronConfig charge une configuration cron depuis un fichier YAML.
func (m *CronManager) loadCronConfig(path string) (*CronConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg CronConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
// Timezone par défaut
if cfg.Timezone == "" {
cfg.Timezone = "UTC"
}
loc, err := time.LoadLocation(cfg.Timezone)
if err != nil {
return nil, fmt.Errorf("invalid timezone %s: %w", cfg.Timezone, err)
}
cfg.location = loc
// Retry par défaut
if cfg.Retry.MaxAttempts == 0 {
cfg.Retry.MaxAttempts = 3
}
if cfg.Retry.Delay == "" {
cfg.Retry.Delay = "5m"
}
cfg.Retry.delayDur, err = time.ParseDuration(cfg.Retry.Delay)
if err != nil {
cfg.Retry.delayDur = 5 * time.Minute
}
// History par défaut
if cfg.HistoryDays == 0 {
cfg.HistoryDays = 7
}
if cfg.HistoryDays > m.historyDays {
m.historyDays = cfg.HistoryDays
}
return &cfg, nil
}
// Start démarre le scheduler.
func (m *CronManager) Start() {
go m.run()
log.Printf("[cron] scheduler started")
}
// Stop arrête le scheduler.
func (m *CronManager) Stop() {
close(m.stopCh)
}
// run est la boucle principale du scheduler.
func (m *CronManager) run() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
// Vérification initiale
m.checkJobs()
for {
select {
case <-ticker.C:
m.checkJobs()
m.cleanHistory()
case <-m.stopCh:
return
}
}
}
// checkJobs vérifie et exécute les jobs dont l'heure est passée.
func (m *CronManager) checkJobs() {
m.mu.RLock()
defer m.mu.RUnlock()
now := time.Now()
for appID, cfg := range m.configs {
for jobName, job := range cfg.Jobs {
if !job.Enabled || job.schedule == nil {
continue
}
if now.After(job.nextRun) || now.Equal(job.nextRun) {
// Exécuter le job
go m.executeJob(appID, jobName, job, cfg)
// Calculer le prochain run
job.nextRun = job.schedule.Next(now)
}
}
}
}
// executeJob exécute un job avec retry.
func (m *CronManager) executeJob(appID, jobName string, job *JobConfig, cfg *CronConfig) {
var lastErr error
var result string
for attempt := 1; attempt <= cfg.Retry.MaxAttempts; attempt++ {
exec := &JobExecution{
JobName: jobName,
AppID: appID,
StartTime: time.Now(),
Attempt: attempt,
}
result, lastErr = m.runJob(appID, jobName, job)
exec.EndTime = time.Now()
exec.Success = lastErr == nil
exec.Result = result
if lastErr != nil {
exec.Error = lastErr.Error()
}
m.addExecution(exec)
if lastErr == nil {
m.logEvent(appID, "job_success", map[string]any{
"job": jobName,
"attempt": attempt,
"result": result,
})
return
}
m.logEvent(appID, "job_failed", map[string]any{
"job": jobName,
"attempt": attempt,
"error": lastErr.Error(),
})
if attempt < cfg.Retry.MaxAttempts {
time.Sleep(cfg.Retry.delayDur)
}
}
// Échec après tous les retries
m.logEvent(appID, "job_exhausted", map[string]any{
"job": jobName,
"attempts": cfg.Retry.MaxAttempts,
"error": lastErr.Error(),
})
}
// runJob exécute un job selon son type.
func (m *CronManager) runJob(appID, jobName string, job *JobConfig) (string, error) {
switch job.Type {
case "query_email":
return m.runQueryEmail(appID, job)
case "http":
return m.runHTTP(job)
case "service":
return m.runService(appID, job)
default:
return "", fmt.Errorf("unknown job type: %s", job.Type)
}
}
// runQueryEmail exécute une requête DB et envoie des emails groupés.
func (m *CronManager) runQueryEmail(appID string, job *JobConfig) (string, error) {
if m.dbPool == nil {
return "", fmt.Errorf("db service not available")
}
if m.smtpPool == nil {
return "", fmt.Errorf("smtp service not available")
}
// Exécuter la requête
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req := protocol.NewRequest("query", map[string]any{
"app_id": appID,
"query": job.Query,
"args": []any{},
})
resp, err := m.dbPool.Call(ctx, req)
if err != nil {
return "", fmt.Errorf("db query: %w", err)
}
if resp.Status != "success" {
return "", fmt.Errorf("db query failed: %s", resp.Error.Message)
}
// Extraire les résultats
resultMap, ok := resp.Result.(map[string]any)
if !ok {
return "", fmt.Errorf("invalid result format")
}
rows, ok := resultMap["rows"].([]any)
if !ok || len(rows) == 0 {
return "no data", nil
}
// Grouper par user si demandé
grouped := m.groupRows(rows, job.GroupBy)
// Envoyer un email par groupe
sent := 0
for key, groupRows := range grouped {
if err := m.sendGroupEmail(appID, job, key, groupRows); err != nil {
log.Printf("[cron] %s: email error for %s: %v", appID, key, err)
continue
}
sent++
}
return fmt.Sprintf("sent %d emails", sent), nil
}
// groupRows groupe les lignes par une clé.
func (m *CronManager) groupRows(rows []any, groupBy string) map[string][]map[string]any {
grouped := make(map[string][]map[string]any)
for _, row := range rows {
rowMap, ok := row.(map[string]any)
if !ok {
continue
}
key := "default"
if groupBy != "" {
if v, ok := rowMap[groupBy]; ok {
key = fmt.Sprintf("%v", v)
}
}
grouped[key] = append(grouped[key], rowMap)
}
return grouped
}
// sendGroupEmail envoie un email pour un groupe de lignes.
func (m *CronManager) sendGroupEmail(appID string, job *JobConfig, key string, rows []map[string]any) error {
if len(rows) == 0 {
return nil
}
// Extraire l'email du premier row (doit contenir "email")
email, ok := rows[0]["email"].(string)
if !ok {
return fmt.Errorf("no email field in row")
}
// Extraire le nom (optionnel)
name, _ := rows[0]["user_name"].(string)
if name == "" {
name, _ = rows[0]["name"].(string)
}
// Préparer les données du template
now := time.Now()
tasks := make([]map[string]any, 0, len(rows))
for _, row := range rows {
task := map[string]any{
"Name": row["title"],
"Project": row["project_name"],
"Status": row["status_name"],
"StatusColor": row["status_color"],
"DueTime": "",
}
if dt, ok := row["due_date"].(time.Time); ok {
task["DueTime"] = dt.Format("15:04")
}
tasks = append(tasks, task)
}
data := map[string]any{
"Name": name,
"Date": now.Format("02/01/2006"),
"Tasks": tasks,
"TaskCount": len(tasks),
}
// Envoyer via smtp service
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req := protocol.NewRequest("send_template", map[string]any{
"app_id": appID,
"to": email,
"template": job.Template,
"data": data,
})
resp, err := m.smtpPool.Call(ctx, req)
if err != nil {
return err
}
if resp.Status != "success" {
return fmt.Errorf("smtp: %s", resp.Error.Message)
}
return nil
}
// runHTTP exécute une requête HTTP.
func (m *CronManager) runHTTP(job *JobConfig) (string, error) {
method := job.Method
if method == "" {
method = "GET"
}
var body *bytes.Reader
if job.Body != "" {
body = bytes.NewReader([]byte(job.Body))
} else {
body = bytes.NewReader(nil)
}
req, err := http.NewRequest(method, job.URL, body)
if err != nil {
return "", err
}
for k, v := range job.Headers {
req.Header.Set(k, v)
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
return fmt.Sprintf("HTTP %d", resp.StatusCode), nil
}
// runService appelle un service interne.
func (m *CronManager) runService(appID string, job *JobConfig) (string, error) {
var pool *protocol.Pool
switch job.Service {
case "db", "sogoms-db":
pool = m.dbPool
case "smtp", "sogoms-smtp":
pool = m.smtpPool
case "logs", "sogoms-logs":
pool = m.logsPool
default:
return "", fmt.Errorf("unknown service: %s", job.Service)
}
if pool == nil {
return "", fmt.Errorf("service %s not available", job.Service)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
params := make(map[string]any)
for k, v := range job.Params {
params[k] = v
}
params["app_id"] = appID
req := protocol.NewRequest(job.Action, params)
resp, err := pool.Call(ctx, req)
if err != nil {
return "", err
}
if resp.Status != "success" {
return "", fmt.Errorf("%s: %s", resp.Error.Code, resp.Error.Message)
}
result, _ := json.Marshal(resp.Result)
return string(result), nil
}
// addExecution ajoute une exécution à l'historique.
func (m *CronManager) addExecution(exec *JobExecution) {
m.mu.Lock()
defer m.mu.Unlock()
m.executions = append(m.executions, exec)
}
// cleanHistory supprime les exécutions plus vieilles que historyDays.
func (m *CronManager) cleanHistory() {
m.mu.Lock()
defer m.mu.Unlock()
cutoff := time.Now().AddDate(0, 0, -m.historyDays)
var kept []*JobExecution
for _, exec := range m.executions {
if exec.StartTime.After(cutoff) {
kept = append(kept, exec)
}
}
m.executions = kept
}
// logEvent envoie un log au service logs.
func (m *CronManager) logEvent(appID, eventType string, data map[string]any) {
if m.logsPool == nil {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req := protocol.NewRequest("log_event", map[string]any{
"app_id": appID,
"event_type": "cron_" + eventType,
"data": data,
})
m.logsPool.Call(ctx, req)
}()
}
// ListJobs retourne la liste des jobs avec leur prochain run.
func (m *CronManager) ListJobs() []map[string]any {
m.mu.RLock()
defer m.mu.RUnlock()
var jobs []map[string]any
for appID, cfg := range m.configs {
for name, job := range cfg.Jobs {
jobs = append(jobs, map[string]any{
"app_id": appID,
"name": name,
"type": job.Type,
"schedule": job.Schedule,
"enabled": job.Enabled,
"next_run": job.nextRun.Format(time.RFC3339),
})
}
}
return jobs
}
// GetHistory retourne l'historique des exécutions.
func (m *CronManager) GetHistory(appID, jobName string, limit int) []*JobExecution {
m.mu.RLock()
defer m.mu.RUnlock()
var result []*JobExecution
for i := len(m.executions) - 1; i >= 0 && len(result) < limit; i-- {
exec := m.executions[i]
if appID != "" && exec.AppID != appID {
continue
}
if jobName != "" && exec.JobName != jobName {
continue
}
result = append(result, exec)
}
return result
}
// TriggerJob déclenche un job manuellement.
func (m *CronManager) TriggerJob(appID, jobName string) error {
m.mu.RLock()
cfg, ok := m.configs[appID]
if !ok {
m.mu.RUnlock()
return fmt.Errorf("app not found: %s", appID)
}
job, ok := cfg.Jobs[jobName]
if !ok {
m.mu.RUnlock()
return fmt.Errorf("job not found: %s", jobName)
}
m.mu.RUnlock()
go m.executeJob(appID, jobName, job, cfg)
return nil
}
func main() {
flag.Parse()
log.SetFlags(log.Ltime | log.Lshortfile)
// Charger les configurations des apps
registry := config.NewRegistry(*configDir)
if err := registry.Load(); err != nil {
log.Fatalf("load config: %v", err)
}
log.Printf("[cron] loaded apps: %v", registry.Apps())
// Pools de connexion aux services
var dbPool, smtpPool, logsPool *protocol.Pool
if *dbSocket != "" {
dbPool = protocol.NewPool(*dbSocket, 2)
}
if *smtpSocket != "" {
smtpPool = protocol.NewPool(*smtpSocket, 2)
}
if *logsSocket != "" {
logsPool = protocol.NewPool(*logsSocket, 2)
}
// Manager cron
manager := NewCronManager(registry, *configDir, dbPool, smtpPool, logsPool)
if err := manager.Load(); err != nil {
log.Fatalf("load cron config: %v", err)
}
manager.Start()
defer manager.Stop()
// Handler des requêtes IPC
handler := func(ctx context.Context, req *protocol.Request) *protocol.Response {
return handleRequest(ctx, req, manager)
}
// Démarrer le serveur
server := protocol.NewServer(*socketPath, handler)
if err := server.Start(); err != nil {
log.Fatalf("start server: %v", err)
}
log.Printf("[cron] sogoms-cron started on %s", *socketPath)
// Attendre signal d'arrêt
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Printf("[cron] shutting down...")
server.Stop()
}
func handleRequest(ctx context.Context, req *protocol.Request, manager *CronManager) *protocol.Response {
switch req.Action {
case "health":
return protocol.Success(req.ID, map[string]any{"status": "ok"})
case "list":
return handleList(req, manager)
case "trigger":
return handleTrigger(req, manager)
case "status":
return handleStatus(req, manager)
default:
return protocol.Failure(req.ID, "UNKNOWN_ACTION", "unknown action: "+req.Action)
}
}
// handleList retourne la liste des jobs.
func handleList(req *protocol.Request, manager *CronManager) *protocol.Response {
jobs := manager.ListJobs()
return protocol.Success(req.ID, map[string]any{"jobs": jobs})
}
// handleTrigger déclenche un job manuellement.
// Params: app_id, job
func handleTrigger(req *protocol.Request, manager *CronManager) *protocol.Response {
appID, _ := req.Params["app_id"].(string)
jobName, _ := req.Params["job"].(string)
if appID == "" || jobName == "" {
return protocol.Failure(req.ID, "MISSING_PARAMS", "app_id and job are required")
}
if err := manager.TriggerJob(appID, jobName); err != nil {
return protocol.Failure(req.ID, "TRIGGER_ERROR", err.Error())
}
return protocol.Success(req.ID, map[string]any{"triggered": true})
}
// handleStatus retourne l'historique des exécutions.
// Params: app_id (optionnel), job (optionnel), limit (optionnel, défaut 50)
func handleStatus(req *protocol.Request, manager *CronManager) *protocol.Response {
appID, _ := req.Params["app_id"].(string)
jobName, _ := req.Params["job"].(string)
limit := 50
if l, ok := req.Params["limit"].(float64); ok {
limit = int(l)
}
history := manager.GetHistory(appID, jobName, limit)
return protocol.Success(req.ID, map[string]any{"executions": history})
}

View File

@@ -157,7 +157,22 @@ func main() {
} }
func handleRequest(ctx context.Context, req *protocol.Request, dbPool *DBPool) *protocol.Response { func handleRequest(ctx context.Context, req *protocol.Request, dbPool *DBPool) *protocol.Response {
// L'app_id doit être fourni // Health check sans app_id (vérifie juste que le service tourne)
if req.Action == "health" {
if appID, ok := req.Params["app_id"].(string); ok && appID != "" {
// Health check avec app_id : vérifie la connexion DB
if db, err := dbPool.GetDB(appID); err == nil {
if err := db.Ping(); err != nil {
return protocol.Failure(req.ID, "UNHEALTHY", err.Error())
}
return protocol.Success(req.ID, map[string]any{"status": "ok", "app_id": appID})
}
}
// Health check simple : le service tourne
return protocol.Success(req.ID, map[string]any{"status": "ok"})
}
// L'app_id doit être fourni pour les autres actions
appID, ok := req.Params["app_id"].(string) appID, ok := req.Params["app_id"].(string)
if !ok || appID == "" { if !ok || appID == "" {
return protocol.Failure(req.ID, "MISSING_APP_ID", "app_id is required") return protocol.Failure(req.ID, "MISSING_APP_ID", "app_id is required")
@@ -179,8 +194,8 @@ func handleRequest(ctx context.Context, req *protocol.Request, dbPool *DBPool) *
return handleUpdate(req, db, appID) return handleUpdate(req, db, appID)
case "delete": case "delete":
return handleDelete(req, db, appID) return handleDelete(req, db, appID)
case "health": case "introspect":
return handleHealth(req, db) return handleIntrospect(req, db, appID)
default: default:
return protocol.Failure(req.ID, "UNKNOWN_ACTION", "unknown action: "+req.Action) return protocol.Failure(req.ID, "UNKNOWN_ACTION", "unknown action: "+req.Action)
} }
@@ -363,14 +378,6 @@ func handleDelete(req *protocol.Request, db *sql.DB, appID string) *protocol.Res
}) })
} }
// handleHealth vérifie la connexion à la DB.
func handleHealth(req *protocol.Request, db *sql.DB) *protocol.Response {
if err := db.Ping(); err != nil {
return protocol.Failure(req.ID, "UNHEALTHY", err.Error())
}
return protocol.Success(req.ID, map[string]any{"status": "ok"})
}
// extractQueryParams extrait query et args des paramètres. // extractQueryParams extrait query et args des paramètres.
func extractQueryParams(params map[string]any) (string, []any, error) { func extractQueryParams(params map[string]any) (string, []any, error) {
query, ok := params["query"].(string) query, ok := params["query"].(string)
@@ -434,3 +441,214 @@ func scanRows(rows *sql.Rows) ([]map[string]any, error) {
return results, rows.Err() return results, rows.Err()
} }
// handleIntrospect analyse la structure de la base de données.
// Retourne tables, colonnes, clés primaires et étrangères.
func handleIntrospect(req *protocol.Request, db *sql.DB, appID string) *protocol.Response {
// Récupérer le nom de la base
var dbName string
if err := db.QueryRow("SELECT DATABASE()").Scan(&dbName); err != nil {
return protocol.Failure(req.ID, "DB_ERROR", "cannot get database name: "+err.Error())
}
// 1. Récupérer les tables
tablesQuery := `
SELECT TABLE_NAME
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'
ORDER BY TABLE_NAME`
tableRows, err := db.Query(tablesQuery, dbName)
if err != nil {
return protocol.Failure(req.ID, "QUERY_ERROR", err.Error())
}
defer tableRows.Close()
var tableNames []string
for tableRows.Next() {
var name string
if err := tableRows.Scan(&name); err != nil {
return protocol.Failure(req.ID, "SCAN_ERROR", err.Error())
}
tableNames = append(tableNames, name)
}
// 2. Pour chaque table, récupérer les colonnes
columnsQuery := `
SELECT
COLUMN_NAME,
DATA_TYPE,
CHARACTER_MAXIMUM_LENGTH,
IS_NULLABLE,
COLUMN_DEFAULT,
EXTRA,
COLUMN_KEY
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION`
// 3. Récupérer les clés étrangères
fkQuery := `
SELECT
COLUMN_NAME,
REFERENCED_TABLE_NAME,
REFERENCED_COLUMN_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
AND REFERENCED_TABLE_NAME IS NOT NULL`
// 4. Récupérer les contraintes UNIQUE
uniqueQuery := `
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
ON kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
AND kcu.TABLE_SCHEMA = tc.TABLE_SCHEMA
WHERE tc.TABLE_SCHEMA = ? AND tc.TABLE_NAME = ?
AND tc.CONSTRAINT_TYPE = 'UNIQUE'`
tables := make(map[string]any)
for _, tableName := range tableNames {
// Colonnes
colRows, err := db.Query(columnsQuery, dbName, tableName)
if err != nil {
logError(appID, "error", "introspect_columns_failed", map[string]any{"table": tableName, "error": err.Error()})
continue
}
columns := make(map[string]any)
var primaryKeys []string
for colRows.Next() {
var (
colName string
dataType string
maxLength sql.NullInt64
nullable string
colDefault sql.NullString
extra string
colKey string
)
if err := colRows.Scan(&colName, &dataType, &maxLength, &nullable, &colDefault, &extra, &colKey); err != nil {
colRows.Close()
continue
}
col := map[string]any{
"type": mapMySQLType(dataType),
}
// Longueur pour varchar/char
if maxLength.Valid && maxLength.Int64 > 0 {
col["length"] = maxLength.Int64
}
// Nullable
if nullable == "NO" {
col["required"] = true
}
// Default
if colDefault.Valid {
col["default"] = colDefault.String
}
// Auto increment
if strings.Contains(extra, "auto_increment") {
col["auto"] = true
}
// Primary key
if colKey == "PRI" {
col["primary"] = true
primaryKeys = append(primaryKeys, colName)
}
columns[colName] = col
}
colRows.Close()
// Clés étrangères
fkRows, err := db.Query(fkQuery, dbName, tableName)
if err == nil {
for fkRows.Next() {
var colName, refTable, refCol string
if err := fkRows.Scan(&colName, &refTable, &refCol); err != nil {
continue
}
if col, ok := columns[colName].(map[string]any); ok {
col["foreign"] = refTable + "." + refCol
// Détecter le pattern owner (user_id -> users.id)
if colName == "user_id" && refTable == "users" {
col["filter"] = "owner"
}
}
}
fkRows.Close()
}
// Contraintes UNIQUE
uqRows, err := db.Query(uniqueQuery, dbName, tableName)
if err == nil {
for uqRows.Next() {
var colName string
if err := uqRows.Scan(&colName); err != nil {
continue
}
if col, ok := columns[colName].(map[string]any); ok {
col["unique"] = true
}
}
uqRows.Close()
}
table := map[string]any{
"columns": columns,
}
// Clé primaire composite
if len(primaryKeys) > 1 {
table["primary"] = primaryKeys
}
// CRUD par défaut (à affiner manuellement)
table["crud"] = []string{"list", "show", "create", "update", "delete"}
tables[tableName] = table
}
return protocol.Success(req.ID, map[string]any{
"app": appID,
"database": dbName,
"tables": tables,
})
}
// mapMySQLType convertit un type MySQL en type schema simplifié.
func mapMySQLType(mysqlType string) string {
switch strings.ToLower(mysqlType) {
case "tinyint", "smallint", "mediumint", "int", "bigint":
return "int"
case "float", "double", "decimal":
return "float"
case "varchar", "char":
return "string"
case "text", "mediumtext", "longtext":
return "text"
case "tinyint(1)", "boolean", "bool":
return "bool"
case "date":
return "date"
case "datetime", "timestamp":
return "datetime"
case "time":
return "time"
case "json":
return "json"
case "blob", "mediumblob", "longblob":
return "blob"
default:
return mysqlType
}
}

View File

@@ -0,0 +1,44 @@
# Configuration des tâches planifiées pour Prokov
timezone: Europe/Paris
retry:
max_attempts: 3
delay: 5m
history_days: 7
jobs:
# Email quotidien des tâches à faire
tasks_today:
schedule: "0 8 * * 1-5" # 8h00 du lundi au vendredi
type: query_email
enabled: true
# Requête : tâches du jour pour chaque utilisateur
# Retourne les tâches dont la date de fin est aujourd'hui ou dépassée
# et qui ne sont pas dans un statut "terminé" (code >= 100)
query: |
SELECT
u.id AS user_id,
u.email,
u.name AS user_name,
t.id AS task_id,
t.title,
t.priority,
t.date_end,
p.name AS project_name,
s.name AS status_name,
s.color AS status_color
FROM users u
INNER JOIN tasks t ON t.user_id = u.id
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN statuses s ON t.status_id = s.id
WHERE (t.date_end <= CURDATE() OR t.date_start = CURDATE())
AND (s.code IS NULL OR s.code < 100)
ORDER BY u.id, t.priority DESC, t.date_end ASC, t.position ASC
# Grouper par user_id pour envoyer 1 email par utilisateur
group_by: user_id
# Template email à utiliser
template: tasks_today

View File

@@ -54,7 +54,7 @@ CREATE TABLE `projects` (
KEY `idx_projects_parent` (`user_id`,`parent_id`), KEY `idx_projects_parent` (`user_id`,`parent_id`),
CONSTRAINT `projects_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, CONSTRAINT `projects_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `projects_ibfk_2` FOREIGN KEY (`parent_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE CONSTRAINT `projects_ibfk_2` FOREIGN KEY (`parent_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET character_set_client = @saved_cs_client */;
-- --
@@ -95,7 +95,7 @@ CREATE TABLE `tags` (
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `unique_tag_per_user` (`user_id`,`name`), UNIQUE KEY `unique_tag_per_user` (`user_id`,`name`),
CONSTRAINT `tags_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE CONSTRAINT `tags_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET character_set_client = @saved_cs_client */;
-- --
@@ -146,7 +146,7 @@ CREATE TABLE `tasks` (
CONSTRAINT `tasks_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, CONSTRAINT `tasks_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `tasks_ibfk_2` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, CONSTRAINT `tasks_ibfk_2` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE,
CONSTRAINT `tasks_ibfk_3` FOREIGN KEY (`status_id`) REFERENCES `statuses` (`id`) CONSTRAINT `tasks_ibfk_3` FOREIGN KEY (`status_id`) REFERENCES `statuses` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET character_set_client = @saved_cs_client */;
-- --
@@ -161,6 +161,8 @@ CREATE TABLE `users` (
`email` varchar(255) NOT NULL, `email` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL, `password` varchar(255) NOT NULL,
`name` varchar(100) NOT NULL, `name` varchar(100) NOT NULL,
`language` varchar(5) NOT NULL DEFAULT 'fr',
`timezone` varchar(50) NOT NULL DEFAULT 'Europe/Paris',
`role_id` int(10) unsigned NOT NULL DEFAULT 1, `role_id` int(10) unsigned NOT NULL DEFAULT 1,
`created_at` timestamp NULL DEFAULT current_timestamp(), `created_at` timestamp NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
@@ -199,4 +201,4 @@ CREATE TABLE `users_roles` (
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*M!100616 SET NOTE_VERBOSITY=@OLD_NOTE_VERBOSITY */; /*M!100616 SET NOTE_VERBOSITY=@OLD_NOTE_VERBOSITY */;
-- Dump completed on 2025-12-16 10:58:46 -- Dump completed on 2025-12-18 10:24:52

View File

@@ -0,0 +1,255 @@
app: prokov
tables:
project_tags:
columns:
project_id:
foreign: projects.id
primary: true
required: true
type: int
tag_id:
foreign: tags.id
primary: true
required: true
type: int
crud: []
primary:
- project_id
- tag_id
projects:
columns:
created_at:
default: current_timestamp()
type: datetime
description:
default: "NULL"
length: 65535
type: text
id:
auto: true
primary: true
required: true
type: int
name:
length: 100
required: true
type: string
parent_id:
default: "NULL"
foreign: projects.id
type: int
position:
default: "0"
type: int
updated_at:
default: current_timestamp()
type: datetime
user_id:
filter: owner
foreign: users.id
required: true
type: int
crud:
- list
- show
- create
- update
- delete
statuses:
columns:
code:
required: true
type: int
color:
default: '''#6B7280'''
length: 7
type: string
created_at:
default: current_timestamp()
type: datetime
id:
auto: true
primary: true
required: true
type: int
name:
length: 50
required: true
type: string
position:
default: "0"
type: int
project_id:
default: "NULL"
type: int
user_id:
filter: owner
foreign: users.id
required: true
type: int
crud:
- list
- show
- create
- update
- delete
tags:
columns:
color:
default: '''#3B82F6'''
length: 7
type: string
created_at:
default: current_timestamp()
type: datetime
id:
auto: true
primary: true
required: true
type: int
name:
length: 50
required: true
type: string
unique: true
user_id:
filter: owner
foreign: users.id
required: true
type: int
unique: true
crud:
- list
- show
- create
- update
- delete
task_tags:
columns:
tag_id:
foreign: tags.id
primary: true
required: true
type: int
task_id:
foreign: tasks.id
primary: true
required: true
type: int
crud: []
primary:
- task_id
- tag_id
tasks:
columns:
billing:
default: "0.00"
type: float
created_at:
default: current_timestamp()
type: datetime
date_end:
default: "NULL"
type: date
date_start:
default: "NULL"
type: date
description:
default: "NULL"
length: 65535
type: text
id:
auto: true
primary: true
required: true
type: int
position:
default: "0"
type: int
priority:
default: "5"
type: int
project_id:
foreign: projects.id
required: true
type: int
status_id:
foreign: statuses.id
required: true
type: int
time_estimated:
default: "0"
type: int
time_spent:
default: "0"
type: int
title:
length: 255
required: true
type: string
updated_at:
default: current_timestamp()
type: datetime
user_id:
filter: owner
foreign: users.id
required: true
type: int
crud:
- list
- show
- create
- update
- delete
users:
columns:
created_at:
default: current_timestamp()
type: datetime
email:
length: 255
required: true
type: string
unique: true
id:
auto: true
primary: true
required: true
type: int
name:
length: 100
required: true
type: string
password:
length: 255
required: true
type: string
role_id:
default: "1"
foreign: users_roles.id
required: true
type: int
updated_at:
default: current_timestamp()
type: datetime
crud:
- list
- show
- create
- update
- delete
users_roles:
columns:
created_at:
default: current_timestamp()
type: datetime
id:
primary: true
required: true
type: int
name:
length: 50
required: true
type: string
crud: []
version: "1.0"

View File

@@ -43,6 +43,49 @@ services:
depends_on: depends_on:
- sogoms-logs - sogoms-logs
sogoms-cron:
binary: /opt/sogoms/bin/sogoms-cron
args:
- "-config"
- "/config"
- "-socket"
- "/run/sogoms-cron.1.sock"
- "-db-socket"
- "/run/sogoms-db.1.sock"
- "-smtp-socket"
- "/run/sogoms-smtp.1.sock"
- "-logs-socket"
- "/run/sogoms-logs.1.sock"
health_socket: /run/sogoms-cron.1.sock
depends_on:
- sogoms-db
- sogoms-smtp
- sogoms-logs
sogoms-admin:
binary: /opt/sogoms/bin/sogoms-admin
args:
- "-config"
- "/config"
- "-secrets"
- "/secrets"
- "-templates"
- "/config/admin/templates"
- "-dev"
- "-port"
- "9000"
- "-db-socket"
- "/run/sogoms-db.1.sock"
- "-logs-socket"
- "/run/sogoms-logs.1.sock"
- "-cron-socket"
- "/run/sogoms-cron.1.sock"
health_url: http://localhost:9000/admin/login
depends_on:
- sogoms-db
- sogoms-logs
- sogoms-cron
sogoway: sogoway:
binary: /opt/sogoms/bin/sogoway binary: /opt/sogoms/bin/sogoway
args: args:

BIN
cron Executable file

Binary file not shown.

BIN
db Executable file

Binary file not shown.

70
deploy-admin.sh Executable file
View File

@@ -0,0 +1,70 @@
#!/bin/bash
# Script de déploiement des templates admin pour SOGOMS
# Déploie uniquement les templates HTML sans recompilation
set -euo pipefail
# Configuration SSH
JUMP_USER="root"
JUMP_HOST="195.154.80.116"
JUMP_PORT="22"
JUMP_KEY="/home/pierre/.ssh/id_rsa_mbpi"
# Configuration Incus
INCUS_PROJECT="default"
INCUS_CONTAINER="gw3"
# Chemin des templates sur le container
REMOTE_TEMPLATES="/config/admin/templates"
# Couleurs
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m'
echo_step() { echo -e "${GREEN}==>${NC} $1"; }
echo_info() { echo -e "${BLUE}Info:${NC} $1"; }
# Répertoire du script
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TEMPLATES_DIR="${SCRIPT_DIR}/cmd/sogoms/admin/templates"
if [ ! -d "$TEMPLATES_DIR" ]; then
echo "Error: templates directory not found: $TEMPLATES_DIR"
exit 1
fi
# Commandes SSH/SCP
SSH_CMD="ssh -i ${JUMP_KEY} -p ${JUMP_PORT} ${JUMP_USER}@${JUMP_HOST}"
SCP_CMD="scp -i ${JUMP_KEY} -P ${JUMP_PORT}"
# Créer archive des templates
echo_step "Creating templates archive..."
TIMESTAMP=$(date +%s)
ARCHIVE="sogoms-templates-${TIMESTAMP}.tar.gz"
tar -czf "/tmp/${ARCHIVE}" -C "${TEMPLATES_DIR}" .
echo_info "Archive: $(du -h /tmp/${ARCHIVE} | cut -f1)"
# Copier vers IN3
echo_step "Copying to jump server..."
$SCP_CMD "/tmp/${ARCHIVE}" "${JUMP_USER}@${JUMP_HOST}:/tmp/"
# Déployer sur gw3
echo_step "Deploying templates to ${INCUS_CONTAINER}..."
$SSH_CMD "
incus project switch ${INCUS_PROJECT}
incus file push /tmp/${ARCHIVE} ${INCUS_CONTAINER}/tmp/
incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_TEMPLATES}/partials
incus exec ${INCUS_CONTAINER} -- tar -xzf /tmp/${ARCHIVE} -C ${REMOTE_TEMPLATES}/
incus exec ${INCUS_CONTAINER} -- rm -f /tmp/${ARCHIVE}
rm -f /tmp/${ARCHIVE}
echo 'Templates deployed to ${REMOTE_TEMPLATES}'
incus exec ${INCUS_CONTAINER} -- ls -la ${REMOTE_TEMPLATES}/
"
# Cleanup local
rm -f "/tmp/${ARCHIVE}"
echo_step "Done! Templates deployed."
echo_info "Dev mode: templates reload automatically on each request."

View File

@@ -60,8 +60,8 @@ if [ ! -d "cmd/sogoms/db" ] || [ ! -d "cmd/sogoway" ] || [ ! -d "cmd/sogoctl" ];
echo_error "Source directories missing - are you in the sogoms directory?" echo_error "Source directories missing - are you in the sogoms directory?"
fi fi
if [ ! -d "config/routes" ]; then if [ ! -d "config/apps" ]; then
echo_error "config/routes missing" echo_error "config/apps missing"
fi fi
# Commande SSH vers IN3 # Commande SSH vers IN3
@@ -80,10 +80,12 @@ mkdir -p bin
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-db ./cmd/sogoms/db || echo_error "Failed to build sogoms-db" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-db ./cmd/sogoms/db || echo_error "Failed to build sogoms-db"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-logs ./cmd/sogoms/logs || echo_error "Failed to build sogoms-logs" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-logs ./cmd/sogoms/logs || echo_error "Failed to build sogoms-logs"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-smtp ./cmd/sogoms/smtp || echo_error "Failed to build sogoms-smtp" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-smtp ./cmd/sogoms/smtp || echo_error "Failed to build sogoms-smtp"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-cron ./cmd/sogoms/cron || echo_error "Failed to build sogoms-cron"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-admin ./cmd/sogoms/admin || echo_error "Failed to build sogoms-admin"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoway ./cmd/sogoway || echo_error "Failed to build sogoway" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoway ./cmd/sogoway || echo_error "Failed to build sogoway"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoctl ./cmd/sogoctl || echo_error "Failed to build sogoctl" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoctl ./cmd/sogoctl || echo_error "Failed to build sogoctl"
echo_info "Built: sogoms-db, sogoms-logs, sogoms-smtp, sogoway, sogoctl (v${VERSION})" echo_info "Built: sogoms-db, sogoms-logs, sogoms-smtp, sogoms-cron, sogoms-admin, sogoway, sogoctl (v${VERSION})"
# Étape 2: Créer les archives # Étape 2: Créer les archives
echo_step "Creating archives..." echo_step "Creating archives..."
@@ -119,10 +121,10 @@ $SSH_CMD "
echo '📁 Deploying binaries...' echo '📁 Deploying binaries...'
incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_BIN} incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_BIN}
incus exec ${INCUS_CONTAINER} -- tar -xzvf /tmp/${BIN_ARCHIVE} -C ${REMOTE_BIN}/ incus exec ${INCUS_CONTAINER} -- tar -xzvf /tmp/${BIN_ARCHIVE} -C ${REMOTE_BIN}/
incus exec ${INCUS_CONTAINER} -- chmod 755 ${REMOTE_BIN}/sogoms-db ${REMOTE_BIN}/sogoms-logs ${REMOTE_BIN}/sogoms-smtp ${REMOTE_BIN}/sogoway ${REMOTE_BIN}/sogoctl incus exec ${INCUS_CONTAINER} -- chmod 755 ${REMOTE_BIN}/sogoms-db ${REMOTE_BIN}/sogoms-logs ${REMOTE_BIN}/sogoms-smtp ${REMOTE_BIN}/sogoms-cron ${REMOTE_BIN}/sogoms-admin ${REMOTE_BIN}/sogoway ${REMOTE_BIN}/sogoctl
echo '📁 Deploying config...' echo '📁 Deploying config...'
incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_CONFIG}/routes ${REMOTE_CONFIG}/scenarios ${REMOTE_CONFIG}/queries ${REMOTE_CONFIG}/emails incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_CONFIG}/apps
incus exec ${INCUS_CONTAINER} -- tar -xzf /tmp/${CONFIG_ARCHIVE} -C ${REMOTE_CONFIG}/ incus exec ${INCUS_CONTAINER} -- tar -xzf /tmp/${CONFIG_ARCHIVE} -C ${REMOTE_CONFIG}/
echo '📁 Setting up run and log directories...' echo '📁 Setting up run and log directories...'
@@ -186,7 +188,8 @@ echo_info " Deployment time: $(date)"
echo "" echo ""
echo_warning "Next steps on gw3:" echo_warning "Next steps on gw3:"
echo_info " 1. Edit /secrets/prokov_db_pass with real DB password" echo_info " 1. Edit /secrets/prokov_db_pass with real DB password"
echo_info " 2. Start services: /opt/sogoms/bin/sogoctl" echo_info " 2. Create /secrets/admin_users.yaml and /secrets/admin_session_secret"
echo_info " 3. Start services: /opt/sogoms/bin/sogoctl"
echo "" echo ""
echo_info "To connect: ssh in3 -t 'incus exec $INCUS_CONTAINER -- sh'" echo_info "To connect: ssh in3 -t 'incus exec $INCUS_CONTAINER -- sh'"

105
internal/admin/audit.go Normal file
View File

@@ -0,0 +1,105 @@
package admin
import (
"context"
"time"
"sogoms.com/internal/protocol"
)
// AuditEvent représente un type d'événement audit.
type AuditEvent string
// Événements audit.
const (
AuditLoginSuccess AuditEvent = "login_success"
AuditLoginFailed AuditEvent = "login_failed"
AuditLogout AuditEvent = "logout"
AuditSessionExpired AuditEvent = "session_expired"
AuditActionPerformed AuditEvent = "action_performed"
AuditPermissionDenied AuditEvent = "permission_denied"
)
// AuditLogger enregistre les événements admin vers sogoms-logs.
type AuditLogger struct {
logsPool *protocol.Pool
appID string // "admin" pour les logs admin
}
// NewAuditLogger crée un nouveau logger d'audit.
func NewAuditLogger(logsPool *protocol.Pool) *AuditLogger {
return &AuditLogger{
logsPool: logsPool,
appID: "admin",
}
}
// Log enregistre un événement d'audit (non-bloquant).
func (a *AuditLogger) Log(event AuditEvent, username string, data map[string]any) {
if a.logsPool == nil {
return
}
if data == nil {
data = make(map[string]any)
}
data["username"] = username
data["event"] = string(event)
data["timestamp"] = time.Now().Format(time.RFC3339)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req := protocol.NewRequest("log_event", map[string]any{
"app_id": a.appID,
"event_type": "audit_" + string(event),
"data": data,
})
a.logsPool.Call(ctx, req)
}()
}
// LogLogin enregistre une tentative de connexion.
func (a *AuditLogger) LogLogin(success bool, username, ip, userAgent string, reason string) {
event := AuditLoginSuccess
if !success {
event = AuditLoginFailed
}
data := map[string]any{
"ip": ip,
"user_agent": userAgent,
}
if reason != "" {
data["reason"] = reason
}
a.Log(event, username, data)
}
// LogLogout enregistre une déconnexion.
func (a *AuditLogger) LogLogout(username, ip string) {
a.Log(AuditLogout, username, map[string]any{
"ip": ip,
})
}
// LogAction enregistre une action effectuée.
func (a *AuditLogger) LogAction(username, action, appID string, details map[string]any) {
data := map[string]any{
"action": action,
"app_id": appID,
"details": details,
}
a.Log(AuditActionPerformed, username, data)
}
// LogPermissionDenied enregistre un refus de permission.
func (a *AuditLogger) LogPermissionDenied(username, action, appID, permission string) {
a.Log(AuditPermissionDenied, username, map[string]any{
"action": action,
"app_id": appID,
"permission": permission,
})
}

112
internal/admin/config.go Normal file
View File

@@ -0,0 +1,112 @@
// Package admin gère la configuration et les permissions de l'interface d'administration.
package admin
import (
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// AdminConfig représente la configuration complète de l'admin.
type AdminConfig struct {
Session SessionConfig `yaml:"session"`
RateLimit RateLimitConfig `yaml:"rate_limit"`
Users []AdminUser `yaml:"users"`
}
// SessionConfig configure les sessions.
type SessionConfig struct {
SecretFile string `yaml:"secret_file"`
MaxAge int `yaml:"max_age"` // secondes
CookieName string `yaml:"cookie_name"`
Secret string `yaml:"-"` // chargé depuis fichier
}
// RateLimitConfig configure le rate limiting.
type RateLimitConfig struct {
LoginMax int `yaml:"login_max"`
LoginWindow int `yaml:"login_window"` // secondes
}
// AdminUser représente un utilisateur admin.
type AdminUser struct {
Username string `yaml:"username"`
PasswordHash string `yaml:"password_hash"`
Role string `yaml:"role"`
Email string `yaml:"email"`
Apps []string `yaml:"apps,omitempty"` // pour app_admin
Permissions []string `yaml:"permissions,omitempty"` // pour app_admin
}
// IsSuperAdmin retourne true si l'utilisateur est super_admin.
func (u *AdminUser) IsSuperAdmin() bool {
return u.Role == "super_admin"
}
// LoadAdminConfig charge la configuration admin depuis un fichier YAML.
func LoadAdminConfig(path string) (*AdminConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read admin config: %w", err)
}
var cfg AdminConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse admin config: %w", err)
}
// Valeurs par défaut
if cfg.Session.MaxAge == 0 {
cfg.Session.MaxAge = 3600 // 1 heure
}
if cfg.Session.CookieName == "" {
cfg.Session.CookieName = "sogoms_admin_sid"
}
if cfg.RateLimit.LoginMax == 0 {
cfg.RateLimit.LoginMax = 5
}
if cfg.RateLimit.LoginWindow == 0 {
cfg.RateLimit.LoginWindow = 60
}
// Charger le secret de session depuis le fichier
if cfg.Session.SecretFile != "" {
secretData, err := os.ReadFile(cfg.Session.SecretFile)
if err != nil {
return nil, fmt.Errorf("read session secret: %w", err)
}
cfg.Session.Secret = strings.TrimSpace(string(secretData))
}
// Valider
if len(cfg.Users) == 0 {
return nil, fmt.Errorf("no users defined")
}
if cfg.Session.Secret == "" {
return nil, fmt.Errorf("session secret is required")
}
return &cfg, nil
}
// GetUser retourne un utilisateur par son username.
func (cfg *AdminConfig) GetUser(username string) *AdminUser {
for i := range cfg.Users {
if cfg.Users[i].Username == username {
return &cfg.Users[i]
}
}
return nil
}
// GetUserByEmail retourne un utilisateur par son email.
func (cfg *AdminConfig) GetUserByEmail(email string) *AdminUser {
for i := range cfg.Users {
if cfg.Users[i].Email == email {
return &cfg.Users[i]
}
}
return nil
}

View File

@@ -0,0 +1,131 @@
package admin
// Permission représente une permission granulaire.
type Permission string
// Permissions disponibles.
const (
PermSchemaRead Permission = "schema:read"
PermSchemaWrite Permission = "schema:write"
PermSchemaUpload Permission = "schema:upload"
PermQueriesRead Permission = "queries:read"
PermQueriesWrite Permission = "queries:write"
PermEmailsRead Permission = "emails:read"
PermEmailsWrite Permission = "emails:write"
PermCronRead Permission = "cron:read"
PermCronTrigger Permission = "cron:trigger"
PermCronWrite Permission = "cron:write"
PermLogsRead Permission = "logs:read"
PermDBIntrospect Permission = "db:introspect"
PermAll Permission = "*"
)
// PermissionChecker vérifie les droits d'un utilisateur.
type PermissionChecker struct {
config *AdminConfig
}
// NewPermissionChecker crée un nouveau vérificateur de permissions.
func NewPermissionChecker(config *AdminConfig) *PermissionChecker {
return &PermissionChecker{config: config}
}
// HasPermission vérifie si l'utilisateur a une permission pour une app.
func (pc *PermissionChecker) HasPermission(user *AdminUser, appID string, perm Permission) bool {
if user == nil {
return false
}
// Super admin a toutes les permissions
if user.IsSuperAdmin() {
return true
}
// Vérifier l'accès à l'app
if !pc.CanAccessApp(user, appID) {
return false
}
// Vérifier la permission
for _, p := range user.Permissions {
if Permission(p) == PermAll || Permission(p) == perm {
return true
}
}
return false
}
// CanAccessApp vérifie si l'utilisateur peut accéder à une app.
func (pc *PermissionChecker) CanAccessApp(user *AdminUser, appID string) bool {
if user == nil {
return false
}
// Super admin a accès à tout
if user.IsSuperAdmin() {
return true
}
// App admin : vérifier la liste des apps autorisées
for _, app := range user.Apps {
if app == appID {
return true
}
}
return false
}
// GetAccessibleApps retourne les apps accessibles par l'utilisateur.
func (pc *PermissionChecker) GetAccessibleApps(user *AdminUser, allApps []string) []string {
if user == nil {
return nil
}
// Super admin voit tout
if user.IsSuperAdmin() {
return allApps
}
// App admin : filtrer sur ses apps autorisées
var accessible []string
for _, app := range allApps {
if pc.CanAccessApp(user, app) {
accessible = append(accessible, app)
}
}
return accessible
}
// GetUserPermissions retourne les permissions effectives d'un utilisateur.
func (pc *PermissionChecker) GetUserPermissions(user *AdminUser) []Permission {
if user == nil {
return nil
}
// Super admin a toutes les permissions
if user.IsSuperAdmin() {
return []Permission{
PermSchemaRead, PermSchemaWrite, PermSchemaUpload,
PermQueriesRead, PermQueriesWrite,
PermEmailsRead, PermEmailsWrite,
PermCronRead, PermCronTrigger, PermCronWrite,
PermLogsRead,
PermDBIntrospect,
}
}
// Convertir les permissions string en Permission
perms := make([]Permission, 0, len(user.Permissions))
for _, p := range user.Permissions {
perms = append(perms, Permission(p))
}
return perms
}

View File

@@ -41,6 +41,11 @@ func NewJWT(secret string, expiration time.Duration) *JWT {
} }
} }
// Secret retourne le secret utilisé (pour comparaison lors du reload).
func (j *JWT) Secret() string {
return string(j.secret)
}
// Generate génère un nouveau token JWT. // Generate génère un nouveau token JWT.
func (j *JWT) Generate(userID int64, email, name, appID string) (string, error) { func (j *JWT) Generate(userID int64, email, name, appID string) (string, error) {
now := time.Now() now := time.Now()

View File

@@ -20,7 +20,8 @@ type AppConfig struct {
Database Database `yaml:"database"` Database Database `yaml:"database"`
Auth Auth `yaml:"auth"` Auth Auth `yaml:"auth"`
Routes []Route `yaml:"routes"` Routes []Route `yaml:"routes"`
Queries *Queries // Chargé depuis config/queries/{app}/ 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. // Queries stocke les requêtes SQL par domaine.
@@ -48,6 +49,14 @@ func (q *Queries) Get(domain, key string) string {
return "" 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). // GetMap retourne une map de requêtes (ex: login_data).
func (q *Queries) GetMap(domain, key string) map[string]string { func (q *Queries) GetMap(domain, key string) map[string]string {
if q == nil || q.files == nil { if q == nil || q.files == nil {
@@ -301,26 +310,33 @@ func NewRegistry(configDir string) *Registry {
} }
} }
// Load charge toutes les configurations depuis le répertoire routes. // Load charge toutes les configurations depuis le répertoire apps.
func (r *Registry) Load() error { func (r *Registry) Load() error {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
routesDir := filepath.Join(r.configDir, "routes") appsDir := filepath.Join(r.configDir, "apps")
entries, err := os.ReadDir(routesDir) entries, err := os.ReadDir(appsDir)
if err != nil { if err != nil {
return fmt.Errorf("read routes dir: %w", err) return fmt.Errorf("read apps dir: %w", err)
} }
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { if !entry.IsDir() {
continue continue
} }
path := filepath.Join(routesDir, entry.Name()) appID := entry.Name()
cfg, err := r.loadAppConfig(path) 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 { if err != nil {
return fmt.Errorf("load %s: %w", entry.Name(), err) return fmt.Errorf("load %s: %w", appID, err)
} }
r.apps[cfg.App] = cfg r.apps[cfg.App] = cfg
@@ -333,7 +349,7 @@ func (r *Registry) Load() error {
} }
// loadAppConfig charge une configuration d'application depuis un fichier YAML. // loadAppConfig charge une configuration d'application depuis un fichier YAML.
func (r *Registry) loadAppConfig(path string) (*AppConfig, error) { func (r *Registry) loadAppConfig(path string, appID string) (*AppConfig, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -372,15 +388,18 @@ func (r *Registry) loadAppConfig(path string) (*AppConfig, error) {
cfg.Auth.JWTExpiry = "24h" cfg.Auth.JWTExpiry = "24h"
} }
// Charger les requêtes depuis config/queries/{app}/ // Charger les requêtes depuis config/apps/{app}/queries/
cfg.Queries = r.loadQueries(cfg.App) cfg.Queries = r.loadQueries(appID)
// Charger le schema depuis config/apps/{app}/schema.yaml
cfg.Schema = loadSchema(r.configDir, appID)
return &cfg, nil return &cfg, nil
} }
// loadQueries charge les fichiers de requêtes pour une application. // loadQueries charge les fichiers de requêtes pour une application.
func (r *Registry) loadQueries(appID string) *Queries { func (r *Registry) loadQueries(appID string) *Queries {
queriesDir := filepath.Join(r.configDir, "queries", appID) queriesDir := filepath.Join(r.configDir, "apps", appID, "queries")
entries, err := os.ReadDir(queriesDir) entries, err := os.ReadDir(queriesDir)
if err != nil { if err != nil {
return nil // Pas de répertoire queries, c'est OK return nil // Pas de répertoire queries, c'est OK

255
internal/config/schema.go Normal file
View File

@@ -0,0 +1,255 @@
// 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"`
}
// 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
}
// 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.
func (t *Table) BuildListQuery(tableName string) string {
cols := t.GetSelectColumns()
query := fmt.Sprintf("SELECT %s FROM %s", strings.Join(cols, ", "), tableName)
// Ajouter filtre owner si présent
if ownerCol := t.GetOwnerColumn(); ownerCol != "" {
query += fmt.Sprintf(" WHERE %s = ?", ownerCol)
}
// 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).
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)
}
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
}

238
internal/cron/scheduler.go Normal file
View File

@@ -0,0 +1,238 @@
// Package cron fournit un scheduler pour les tâches planifiées.
// Supporte le format cron standard (* * * * *) avec timezone.
package cron
import (
"fmt"
"strconv"
"strings"
"time"
)
// Schedule représente une expression cron parsée.
type Schedule struct {
Minute []int // 0-59
Hour []int // 0-23
DayOfMonth []int // 1-31
Month []int // 1-12
DayOfWeek []int // 0-6 (0=dimanche)
Location *time.Location
}
// ParseSchedule parse une expression cron standard.
// Format: "minute hour day month weekday"
// Exemples:
// - "0 8 * * 1-5" : 8h00 du lundi au vendredi
// - "*/15 * * * *" : toutes les 15 minutes
// - "0 9 1 * *" : 9h00 le premier de chaque mois
func ParseSchedule(expr string, location *time.Location) (*Schedule, error) {
if location == nil {
location = time.UTC
}
parts := strings.Fields(expr)
if len(parts) != 5 {
return nil, fmt.Errorf("invalid cron expression: expected 5 fields, got %d", len(parts))
}
s := &Schedule{Location: location}
var err error
// Minute (0-59)
s.Minute, err = parseField(parts[0], 0, 59)
if err != nil {
return nil, fmt.Errorf("minute: %w", err)
}
// Hour (0-23)
s.Hour, err = parseField(parts[1], 0, 23)
if err != nil {
return nil, fmt.Errorf("hour: %w", err)
}
// Day of month (1-31)
s.DayOfMonth, err = parseField(parts[2], 1, 31)
if err != nil {
return nil, fmt.Errorf("day of month: %w", err)
}
// Month (1-12)
s.Month, err = parseField(parts[3], 1, 12)
if err != nil {
return nil, fmt.Errorf("month: %w", err)
}
// Day of week (0-6, 0=Sunday)
s.DayOfWeek, err = parseField(parts[4], 0, 6)
if err != nil {
return nil, fmt.Errorf("day of week: %w", err)
}
return s, nil
}
// parseField parse un champ cron individuel.
// Supporte: *, */n, n, n-m, n,m,o
func parseField(field string, min, max int) ([]int, error) {
var values []int
// Gérer les listes (ex: "1,3,5")
for _, part := range strings.Split(field, ",") {
part = strings.TrimSpace(part)
// Step (*/n ou n-m/s)
step := 1
if idx := strings.Index(part, "/"); idx != -1 {
stepStr := part[idx+1:]
var err error
step, err = strconv.Atoi(stepStr)
if err != nil || step < 1 {
return nil, fmt.Errorf("invalid step: %s", stepStr)
}
part = part[:idx]
}
// Range ou valeur
var rangeMin, rangeMax int
if part == "*" {
rangeMin, rangeMax = min, max
} else if idx := strings.Index(part, "-"); idx != -1 {
var err error
rangeMin, err = strconv.Atoi(part[:idx])
if err != nil {
return nil, fmt.Errorf("invalid range start: %s", part[:idx])
}
rangeMax, err = strconv.Atoi(part[idx+1:])
if err != nil {
return nil, fmt.Errorf("invalid range end: %s", part[idx+1:])
}
} else {
val, err := strconv.Atoi(part)
if err != nil {
return nil, fmt.Errorf("invalid value: %s", part)
}
rangeMin, rangeMax = val, val
}
// Valider les bornes
if rangeMin < min || rangeMax > max || rangeMin > rangeMax {
return nil, fmt.Errorf("value out of range [%d-%d]: %d-%d", min, max, rangeMin, rangeMax)
}
// Générer les valeurs
for v := rangeMin; v <= rangeMax; v += step {
values = append(values, v)
}
}
if len(values) == 0 {
return nil, fmt.Errorf("no values")
}
return values, nil
}
// Next calcule la prochaine exécution après 'from'.
func (s *Schedule) Next(from time.Time) time.Time {
// Convertir dans la timezone du schedule
t := from.In(s.Location)
// Commencer à la minute suivante
t = t.Truncate(time.Minute).Add(time.Minute)
// Limite de recherche (1 an)
limit := t.Add(366 * 24 * time.Hour)
for t.Before(limit) {
// Vérifier le mois
if !contains(s.Month, int(t.Month())) {
// Passer au premier jour du mois suivant
t = time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, s.Location)
continue
}
// Vérifier le jour du mois ET le jour de la semaine
// En cron standard, si les deux sont spécifiés (non-*), c'est un OR
dayMatch := contains(s.DayOfMonth, t.Day())
weekdayMatch := contains(s.DayOfWeek, int(t.Weekday()))
// Si les deux champs sont "*", les deux sont vrais
// Si l'un est spécifié et pas l'autre, seul celui spécifié compte
// Si les deux sont spécifiés, c'est OR (comportement cron standard)
bothWildcard := len(s.DayOfMonth) == 31 && len(s.DayOfWeek) == 7
if bothWildcard {
// Les deux sont *, donc on accepte tous les jours
} else if len(s.DayOfMonth) == 31 {
// Jour du mois est *, seul le jour de semaine compte
if !weekdayMatch {
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, s.Location)
continue
}
} else if len(s.DayOfWeek) == 7 {
// Jour de semaine est *, seul le jour du mois compte
if !dayMatch {
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, s.Location)
continue
}
} else {
// Les deux sont spécifiés : OR
if !dayMatch && !weekdayMatch {
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, s.Location)
continue
}
}
// Vérifier l'heure
if !contains(s.Hour, t.Hour()) {
// Passer à l'heure suivante
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, s.Location)
continue
}
// Vérifier la minute
if !contains(s.Minute, t.Minute()) {
// Passer à la minute suivante
t = t.Add(time.Minute)
continue
}
// Trouvé !
return t
}
// Pas trouvé dans l'année, retourner zero
return time.Time{}
}
// NextN retourne les N prochaines exécutions.
func (s *Schedule) NextN(from time.Time, n int) []time.Time {
var times []time.Time
t := from
for i := 0; i < n; i++ {
next := s.Next(t)
if next.IsZero() {
break
}
times = append(times, next)
t = next
}
return times
}
// contains vérifie si une valeur est dans une liste.
func contains(list []int, val int) bool {
for _, v := range list {
if v == val {
return true
}
}
return false
}
// LoadLocation charge une timezone par nom (ex: "Europe/Paris").
func LoadLocation(name string) (*time.Location, error) {
if name == "" || name == "UTC" {
return time.UTC, nil
}
return time.LoadLocation(name)
}

2
sogoms.svg Normal file
View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24" width="512" height="512"><path d="M0,3v8H11V0H3A3,3,0,0,0,0,3Z"/><path d="M21,0H13V11H24V3A3,3,0,0,0,21,0Z"/><path d="M0,21a3,3,0,0,0,3,3h8V13H0Z"/><path d="M13,24h8a3,3,0,0,0,3-3V13H13Z"/></svg>

After

Width:  |  Height:  |  Size: 328 B