diff --git a/DOCTECH.md b/DOCTECH.md index 61b5be6..7be9467 100644 --- a/DOCTECH.md +++ b/DOCTECH.md @@ -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-logs` | Logging centralisé, rotation auto | `/run/sogoms-logs.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) @@ -63,6 +65,9 @@ Client → Nginx(:443) → Sogoway(:8080) → Sogoms-* → MariaDB - Health checks périodiques (socket ou HTTP) - Redémarrage automatique en cas de crash - Arrêt gracieux sur SIGTERM/SIGINT +- **Socket de contrôle** `/run/sogoctl.sock` pour commandes runtime : + - `reload ` : envoie SIGHUP au service (rechargement config) + - `status` : affiche l'état des services ```yaml # config/sogoctl.yaml @@ -96,6 +101,7 @@ services: - Authentification JWT (HS256) - CRUD générique paramétré par YAML - Logging des événements (login, register) +- **Rechargement à chaud** : SIGHUP recharge registry + JWT sans restart ### sogoms-db (Base de données) @@ -151,6 +157,118 @@ body_html: |

Bienvenue sur notre plateforme.

``` +### 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 @@ -461,7 +579,15 @@ sogoms/ │ └── sogoms/ │ ├── db/main.go # Microservice DB │ ├── 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/ │ ├── protocol/ │ │ ├── message.go # Structs Request/Response @@ -469,30 +595,31 @@ sogoms/ │ │ └── client.go # Client + Pool connexions │ ├── config/ │ │ └── config.go # Registry, Queries, CUD +│ ├── cron/ +│ │ └── scheduler.go # Parser cron, calcul next run │ ├── auth/ │ │ ├── jwt.go # Génération/validation JWT │ │ └── password.go # Hash bcrypt +│ ├── admin/ +│ │ ├── config.go # Chargement admin_users.yaml +│ │ ├── permissions.go # Vérification droits +│ │ └── audit.go # Logging actions │ └── version/ │ └── version.go # Version, BuildTime ├── config/ │ ├── sogoctl.yaml -│ ├── routes/ -│ │ └── prokov.yaml # Config app (DB, auth, SMTP) -│ ├── queries/ -│ │ └── prokov/ -│ │ ├── auth.yaml -│ │ ├── projects.yaml -│ │ ├── tasks.yaml -│ │ ├── tags.yaml -│ │ └── statuses.yaml -│ └── emails/ +│ └── apps/ │ └── prokov/ -│ ├── welcome.yaml -│ ├── password_reset.yaml -│ ├── task_assigned.yaml -│ └── tasks_today.yaml -├── clients/ -│ └── prokov.sql # Schéma DB +│ ├── app.yaml # Config app (DB, auth, SMTP) +│ ├── schema.yaml # Schema DB généré +│ ├── cron.yaml # Jobs planifiés +│ ├── queries/ # Requêtes SQL +│ │ ├── auth.yaml +│ │ ├── projects.yaml +│ │ └── ... +│ └── emails/ # Templates email +│ ├── welcome.yaml +│ └── tasks_today.yaml ├── bin/ # Binaires compilés ├── deploy.sh # Script déploiement ├── VERSION # Numéro de version diff --git a/GO-HTMX.md b/GO-HTMX.md new file mode 100644 index 0000000..392a2c5 --- /dev/null +++ b/GO-HTMX.md @@ -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" .}} │ +│

Dashboard

│ +│
│ +│ Chargement... │ +│
│ +└───────────────────────────────────────────────────────┘ +``` + +### 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 ─────────────────┐ +│ │ +└───────────────────────────────────────────────────────┘ + │ + ▼ +htmx remplace le contenu du
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 + +
+ Chargement... +
+ + +
+
+ + +
+ + +
+ + + +``` + +--- + +## Templates Go + +### Syntaxe de base + +```html + +{{.Title}} + + +{{if .IsSuperAdmin}} + Super Admin +{{else}} + App Admin +{{end}} + + +{{range .Apps}} +
  • {{.Name}}
  • +{{end}} + + +{{template "layout" .}} + + +{{define "content"}} + Contenu ici +{{end}} +``` + +### Structure layout.html + +```html +{{define "layout"}} + + + + {{.Title}} - SOGOMS Admin + + + + + +
    + {{template "content" .}} +
    + + +{{end}} +``` + +### Structure page (dashboard.html) + +```html +{{define "dashboard.html"}} +{{template "layout" .}} +{{end}} + +{{define "content"}} +

    Dashboard

    + +{{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"}} + + + + + + + + + + {{range .Jobs}} + + + + + + {{end}} + +
    JobScheduleProchain run
    {{.Name}}{{.Schedule}}{{.NextRun}}
    +{{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 +
    +
    Jobs Cron
    +
    + Chargement... +
    +
    +``` + +--- + +## 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 diff --git a/README.md b/README.md index b536044..93a94fa 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ schema.yaml → SOGOMS → API REST + Auth + CRUD + Push | `sogoms-db` | Accès MariaDB | Stable | | `sogoms-logs` | Logging centralisé | Stable | | `sogoms-smtp` | Envoi emails, templates | Stable | +| `sogoms-cron` | Tâches planifiées | Stable | +| `sogoms-admin` | Interface web administration | Stable | ## Roadmap @@ -36,7 +38,6 @@ schema.yaml → SOGOMS → API REST + Auth + CRUD + Push |-------|---------|-------------| | 11 | sogoms-crypt | Chiffrement données sensibles | | 12 | sogoms-imap/mailproc | Lecture et traitement emails | -| 13 | sogoms-cron | Tâches planifiées | | 14 | sogoms-push | Push temps réel (MQTT) | | 15 | sogoms-schema | API auto-générée depuis schema | diff --git a/TODO.md b/TODO.md index d1d7dac..1e43f9e 100755 --- a/TODO.md +++ b/TODO.md @@ -161,18 +161,24 @@ curl https://prokov.unikoffice.com/api/auth/me \ ## Phase 13 : Microservice Cron -- [ ] `cmd/sogoms/cron/main.go` : point d'entrée -- [ ] Écoute sur Unix socket `/run/sogoms-cron.1.sock` -- [ ] Config YAML par application (`config/cron/{app}.yaml`) -- [ ] Parser cron schedule (format standard `* * * * *`) -- [ ] Action `list` : liste les jobs configurés -- [ ] Action `trigger` : déclenche un job manuellement -- [ ] Action `status` : statut des dernières exécutions -- [ ] Type `service` : appel service interne (sogoms-smtp, sogoms-db, etc.) -- [ ] Type `http` : appel HTTP (GET/POST) vers endpoint interne ou externe -- [ ] Type `query_email` : requête DB + envoi email avec résultat -- [ ] Logging des exécutions dans sogoms-logs -- [ ] Application Prokov : email quotidien `tasks_today` +- [x] `cmd/sogoms/cron/main.go` : point d'entrée +- [x] `internal/cron/scheduler.go` : parser cron + calcul next run +- [x] Écoute sur Unix socket `/run/sogoms-cron.1.sock` +- [x] Config YAML par application (`config/apps/{app}/cron.yaml`) +- [x] Parser cron schedule (format standard `* * * * *`) +- [x] Support timezone (Europe/Paris) +- [x] Retry configurable (max_attempts, delay) +- [x] Historique des exécutions (configurable, défaut 7 jours) +- [x] Action `list` : liste les jobs configurés avec prochain run +- [x] Action `trigger` : déclenche un job manuellement +- [x] Action `status` : historique des dernières exécutions +- [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) @@ -210,32 +216,35 @@ Cette phase transforme SOGOMS en générateur d'API automatique. ### 15a. Définition du Schema -- [ ] Format `config/schema/{app}.yaml` : tables, fields, relations -- [ ] Types supportés : int, string, text, bool, date, datetime, json -- [ ] Contraintes : primary, auto, unique, required, default -- [ ] Relations : foreign key avec `foreign: table.field` -- [ ] Sécurité : `filter: owner` pour filtrage auto par user_id +- [x] Format `config/schemas/{app}.yaml` : tables, fields, relations +- [x] Types supportés : int, string, text, float, date, datetime, json +- [x] Contraintes : primary, auto, unique, required, default +- [x] Relations : foreign key avec `foreign: table.field` +- [x] Sécurité : `filter: owner` pour filtrage auto par user_id - [ ] 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 +- [ ] 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 -- [ ] Commande `generate {app}` : génère queries YAML depuis schema +- [x] Action `introspect` dans sogoms-db : scan INFORMATION_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 `diff {app}` : compare schema vs DB réelle - [ ] Commande `migrate {app}` : génère SQL de migration -- [ ] Commande `init {app}` : crée schema depuis DB existante (reverse) ### 15c. Runtime Dynamique (sogoway) -- [ ] Chargement schema au démarrage -- [ ] Routes CRUD auto-générées depuis schema -- [ ] Validation des inputs selon types/contraintes -- [ ] Filtrage user_id automatique (filter: owner) +- [x] Chargement schema au démarrage +- [x] Routes CRUD auto-générées depuis schema +- [x] Validation des inputs selon types/contraintes +- [x] Filtrage user_id automatique (filter: owner) - [ ] 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 @@ -244,6 +253,115 @@ Cette phase transforme SOGOMS en générateur d'API automatique. - [ ] Documentation auto-générée - [ ] 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 - sogorch (orchestrateur scénarios) @@ -251,3 +369,76 @@ Cette phase transforme SOGOMS en générateur d'API automatique. - Multi-tenant avancé (workspaces, partage) - Rate limiting - 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 diff --git a/VERSION b/VERSION index 7dea76e..21e8796 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.1 +1.0.3 diff --git a/admin b/admin new file mode 100755 index 0000000..40b2c1f Binary files /dev/null and b/admin differ diff --git a/cmd/sogoms/admin/handlers.go b/cmd/sogoms/admin/handlers.go new file mode 100644 index 0000000..1773f1b --- /dev/null +++ b/cmd/sogoms/admin/handlers.go @@ -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) + } +} diff --git a/cmd/sogoms/admin/main.go b/cmd/sogoms/admin/main.go new file mode 100644 index 0000000..9064af9 --- /dev/null +++ b/cmd/sogoms/admin/main.go @@ -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 +} diff --git a/cmd/sogoms/admin/middleware.go b/cmd/sogoms/admin/middleware.go new file mode 100644 index 0000000..83a7fb9 --- /dev/null +++ b/cmd/sogoms/admin/middleware.go @@ -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 +} diff --git a/cmd/sogoms/admin/services.go b/cmd/sogoms/admin/services.go new file mode 100644 index 0000000..a05e6c5 --- /dev/null +++ b/cmd/sogoms/admin/services.go @@ -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 +} diff --git a/cmd/sogoms/admin/session.go b/cmd/sogoms/admin/session.go new file mode 100644 index 0000000..48f8c4d --- /dev/null +++ b/cmd/sogoms/admin/session.go @@ -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 +} diff --git a/cmd/sogoms/admin/static/htmx.min.js b/cmd/sogoms/admin/static/htmx.min.js new file mode 100644 index 0000000..59937d7 --- /dev/null +++ b/cmd/sogoms/admin/static/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
    ");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/cmd/sogoms/admin/static/pico.min.css b/cmd/sogoms/admin/static/pico.min.css new file mode 100644 index 0000000..e10ec26 --- /dev/null +++ b/cmd/sogoms/admin/static/pico.min.css @@ -0,0 +1,4 @@ +@charset "UTF-8";/*! + * Pico CSS ✨ v2.1.1 (https://picocss.com) + * Copyright 2019-2025 - Licensed under MIT + */:host,:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,Helvetica,Arial,"Helvetica Neue",sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:var(--pico-spacing);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-grid-column-gap:var(--pico-spacing);--pico-grid-row-gap:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:host,:root{--pico-font-size:106.25%}}@media (min-width:768px){:host,:root{--pico-font-size:112.5%}}@media (min-width:1024px){:host,:root{--pico-font-size:118.75%}}@media (min-width:1280px){:host,:root{--pico-font-size:125%}}@media (min-width:1536px){:host,:root{--pico-font-size:131.25%}}a{--pico-text-decoration:underline}a.contrast,a.secondary{--pico-text-decoration:underline}small{--pico-font-size:0.875em}h1,h2,h3,h4,h5,h6{--pico-font-weight:700}h1{--pico-font-size:2rem;--pico-line-height:1.125;--pico-typography-spacing-top:3rem}h2{--pico-font-size:1.75rem;--pico-line-height:1.15;--pico-typography-spacing-top:2.625rem}h3{--pico-font-size:1.5rem;--pico-line-height:1.175;--pico-typography-spacing-top:2.25rem}h4{--pico-font-size:1.25rem;--pico-line-height:1.2;--pico-typography-spacing-top:1.874rem}h5{--pico-font-size:1.125rem;--pico-line-height:1.225;--pico-typography-spacing-top:1.6875rem}h6{--pico-font-size:1rem;--pico-line-height:1.25;--pico-typography-spacing-top:1.5rem}tfoot td,tfoot th,thead td,thead th{--pico-font-weight:600;--pico-border-width:0.1875rem}code,kbd,pre,samp{--pico-font-family:var(--pico-font-family-monospace)}kbd{--pico-font-weight:bolder}:where(select,textarea),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-outline-width:0.0625rem}[type=search]{--pico-border-radius:5rem}[type=checkbox],[type=radio]{--pico-border-width:0.125rem}[type=checkbox][role=switch]{--pico-border-width:0.1875rem}details.dropdown summary:not([role=button]){--pico-outline-width:0.0625rem}nav details.dropdown summary:focus-visible{--pico-outline-width:0.125rem}[role=search]{--pico-border-radius:5rem}[role=group]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus),[role=search]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[role=group]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus),[role=search]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=submit],[role=search] button{--pico-form-element-spacing-horizontal:2rem}details summary[role=button]:not(.outline)::after{filter:brightness(0) invert(1)}[aria-busy=true]:not(input,select,textarea):is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0) invert(1)}:host(:not([data-theme=dark])),:root:not([data-theme=dark]),[data-theme=light]{color-scheme:light;--pico-background-color:#fff;--pico-color:#373c44;--pico-text-selection-color:rgba(2, 154, 232, 0.25);--pico-muted-color:#646b79;--pico-muted-border-color:rgb(231, 234, 239.5);--pico-primary:#0172ad;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 114, 173, 0.5);--pico-primary-hover:#015887;--pico-primary-hover-background:#02659a;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(2, 154, 232, 0.5);--pico-primary-inverse:#fff;--pico-secondary:#5d6b89;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(93, 107, 137, 0.5);--pico-secondary-hover:#48536b;--pico-secondary-hover-background:#48536b;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(93, 107, 137, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#181c25;--pico-contrast-background:#181c25;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(24, 28, 37, 0.5);--pico-contrast-hover:#000;--pico-contrast-hover-background:#000;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-secondary-hover);--pico-contrast-focus:rgba(93, 107, 137, 0.25);--pico-contrast-inverse:#fff;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(129, 145, 181, 0.01698),0.0335rem 0.067rem 0.402rem rgba(129, 145, 181, 0.024),0.0625rem 0.125rem 0.75rem rgba(129, 145, 181, 0.03),0.1125rem 0.225rem 1.35rem rgba(129, 145, 181, 0.036),0.2085rem 0.417rem 2.502rem rgba(129, 145, 181, 0.04302),0.5rem 1rem 6rem rgba(129, 145, 181, 0.06),0 0 0 0.0625rem rgba(129, 145, 181, 0.015);--pico-h1-color:#2d3138;--pico-h2-color:#373c44;--pico-h3-color:#424751;--pico-h4-color:#4d535e;--pico-h5-color:#5c6370;--pico-h6-color:#646b79;--pico-mark-background-color:rgb(252.5, 230.5, 191.5);--pico-mark-color:#0f1114;--pico-ins-color:rgb(28.5, 105.5, 84);--pico-del-color:rgb(136, 56.5, 53);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(243, 244.5, 246.75);--pico-code-color:#646b79;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(251, 251.5, 252.25);--pico-form-element-selected-background-color:#dfe3eb;--pico-form-element-border-color:#cfd5e2;--pico-form-element-color:#23262c;--pico-form-element-placeholder-color:var(--pico-muted-color);--pico-form-element-active-background-color:#fff;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(183.5, 105.5, 106.5);--pico-form-element-invalid-active-border-color:rgb(200.25, 79.25, 72.25);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:rgb(76, 154.5, 137.5);--pico-form-element-valid-active-border-color:rgb(39, 152.75, 118.75);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#bfc7d9;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#dfe3eb;--pico-range-active-border-color:#bfc7d9;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:var(--pico-background-color);--pico-card-border-color:var(--pico-muted-border-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(251, 251.5, 252.25);--pico-dropdown-background-color:#fff;--pico-dropdown-border-color:#eff1f4;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#eff1f4;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(232, 234, 237, 0.75);--pico-progress-background-color:#dfe3eb;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 154.5, 137.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200.25, 79.25, 72.25)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme=dark])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme=dark]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),[data-theme=light] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}@media only screen and (prefers-color-scheme:dark){:host(:not([data-theme])),:root:not([data-theme]){color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}:host(:not([data-theme])) details summary[role=button].contrast:not(.outline)::after,:root:not([data-theme]) details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}:host(:not([data-theme])) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before,:root:not([data-theme]) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}}[data-theme=dark]{color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}[data-theme=dark] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}[data-theme=dark] details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}[data-theme=dark] [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--pico-primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:host),:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family);text-underline-offset:var(--pico-text-underline-offset);text-rendering:optimizeLegibility;overflow-wrap:break-word;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{width:100%;margin:0}main{display:block}body>footer,body>header,body>main{padding-block:var(--pico-block-spacing-vertical)}section{margin-bottom:var(--pico-block-spacing-vertical)}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--pico-spacing);padding-left:var(--pico-spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:1024px){.container{max-width:950px}}@media (min-width:1280px){.container{max-width:1200px}}@media (min-width:1536px){.container{max-width:1450px}}.grid{grid-column-gap:var(--pico-grid-column-gap);grid-row-gap:var(--pico-grid-row-gap);display:grid;grid-template-columns:1fr}@media (min-width:768px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}.overflow-auto{overflow:auto}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-style:normal;font-weight:var(--pico-font-weight)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family)}h1{--pico-color:var(--pico-h1-color)}h2{--pico-color:var(--pico-h2-color)}h3{--pico-color:var(--pico-h3-color)}h4{--pico-color:var(--pico-h4-color)}h5{--pico-color:var(--pico-h5-color)}h6{--pico-color:var(--pico-h6-color)}:where(article,address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--pico-typography-spacing-top)}p{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup>*{margin-top:0;margin-bottom:0}hgroup>:not(:first-child):last-child{--pico-color:var(--pico-muted-color);--pico-font-weight:unset;font-size:1rem}:where(ol,ul) li{margin-bottom:calc(var(--pico-typography-spacing-vertical) * .25)}:where(dl,ol,ul) :where(dl,ol,ul){margin:0;margin-top:calc(var(--pico-typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--pico-mark-background-color);color:var(--pico-mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--pico-typography-spacing-vertical) 0;padding:var(--pico-spacing);border-right:none;border-left:.25rem solid var(--pico-blockquote-border-color);border-inline-start:0.25rem solid var(--pico-blockquote-border-color);border-inline-end:none}blockquote footer{margin-top:calc(var(--pico-typography-spacing-vertical) * .5);color:var(--pico-blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--pico-ins-color);text-decoration:none}del{color:var(--pico-del-color)}::-moz-selection{background-color:var(--pico-text-selection-color)}::selection{background-color:var(--pico-text-selection-color)}:where(a:not([role=button])),[role=link]{--pico-color:var(--pico-primary);--pico-background-color:transparent;--pico-underline:var(--pico-primary-underline);outline:0;background-color:var(--pico-background-color);color:var(--pico-color);-webkit-text-decoration:var(--pico-text-decoration);text-decoration:var(--pico-text-decoration);text-decoration-color:var(--pico-underline);text-underline-offset:0.125em;transition:background-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition)}:where(a:not([role=button])):is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-primary-hover);--pico-underline:var(--pico-primary-hover-underline);--pico-text-decoration:underline}:where(a:not([role=button])):focus-visible,[role=link]:focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}:where(a:not([role=button])).secondary,[role=link].secondary{--pico-color:var(--pico-secondary);--pico-underline:var(--pico-secondary-underline)}:where(a:not([role=button])).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-underline:var(--pico-secondary-hover-underline)}:where(a:not([role=button])).contrast,[role=link].contrast{--pico-color:var(--pico-contrast);--pico-underline:var(--pico-contrast-underline)}:where(a:not([role=button])).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-underline:var(--pico-contrast-hover-underline)}a[role=button]{display:inline-block}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[role=button],[type=button],[type=file]::file-selector-button,[type=reset],[type=submit],button{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);--pico-color:var(--pico-primary-inverse);--pico-box-shadow:var(--pico-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:1rem;line-height:var(--pico-line-height);text-align:center;text-decoration:none;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}[role=button]:is(:hover,:active,:focus),[role=button]:is([aria-current]:not([aria-current=false])),[type=button]:is(:hover,:active,:focus),[type=button]:is([aria-current]:not([aria-current=false])),[type=file]::file-selector-button:is(:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])),[type=reset]:is(:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false])),[type=submit]:is(:hover,:active,:focus),[type=submit]:is([aria-current]:not([aria-current=false])),button:is(:hover,:active,:focus),button:is([aria-current]:not([aria-current=false])){--pico-background-color:var(--pico-primary-hover-background);--pico-border-color:var(--pico-primary-hover-border);--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--pico-color:var(--pico-primary-inverse)}[role=button]:focus,[role=button]:is([aria-current]:not([aria-current=false])):focus,[type=button]:focus,[type=button]:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus,[type=submit]:focus,[type=submit]:is([aria-current]:not([aria-current=false])):focus,button:focus,button:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}[type=button],[type=reset],[type=submit]{margin-bottom:var(--pico-spacing)}:is(button,[type=submit],[type=button],[role=button]).secondary,[type=file]::file-selector-button,[type=reset]{--pico-background-color:var(--pico-secondary-background);--pico-border-color:var(--pico-secondary-border);--pico-color:var(--pico-secondary-inverse);cursor:pointer}:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border);--pico-color:var(--pico-secondary-inverse)}:is(button,[type=submit],[type=button],[role=button]).secondary:focus,:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}:is(button,[type=submit],[type=button],[role=button]).contrast{--pico-background-color:var(--pico-contrast-background);--pico-border-color:var(--pico-contrast-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-contrast-hover-background);--pico-border-color:var(--pico-contrast-hover-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:focus,:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}:is(button,[type=submit],[type=button],[role=button]).outline,[type=reset].outline{--pico-background-color:transparent;--pico-color:var(--pico-primary);--pico-border-color:var(--pico-primary)}:is(button,[type=submit],[type=button],[role=button]).outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:transparent;--pico-color:var(--pico-primary-hover);--pico-border-color:var(--pico-primary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary,[type=reset].outline{--pico-color:var(--pico-secondary);--pico-border-color:var(--pico-secondary)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-border-color:var(--pico-secondary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast{--pico-color:var(--pico-contrast);--pico-border-color:var(--pico-contrast)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-border-color:var(--pico-contrast-hover)}:where(button,[type=submit],[type=reset],[type=button],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]){opacity:.5;pointer-events:none}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--pico-spacing)/ 2) var(--pico-spacing);border-bottom:var(--pico-border-width) solid var(--pico-table-border-color);background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--pico-border-width) solid var(--pico-table-border-color);border-bottom:0}table.striped tbody tr:nth-child(odd) td,table.striped tbody tr:nth-child(odd) th{background-color:var(--pico-table-row-stripped-background-color)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:host),svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-size:.875em;font-family:var(--pico-font-family)}pre code,pre samp{font-size:inherit;font-family:inherit}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre,samp{border-radius:var(--pico-border-radius);background:var(--pico-code-background-color);color:var(--pico-code-color);font-weight:var(--pico-font-weight);line-height:initial}code,kbd,samp{display:inline-block;padding:.375rem}pre{display:block;margin-bottom:var(--pico-spacing);overflow-x:auto}pre>code,pre>samp{display:block;padding:var(--pico-spacing);background:0 0;line-height:var(--pico-line-height)}kbd{background-color:var(--pico-code-kbd-background-color);color:var(--pico-code-kbd-color);vertical-align:baseline}figure{display:block;margin:0;padding:0}figure figcaption{padding:calc(var(--pico-spacing) * .5) 0;color:var(--pico-muted-color)}hr{height:0;margin:var(--pico-typography-spacing-vertical) 0;border:0;border-top:1px solid var(--pico-muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--pico-line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2)}fieldset{width:100%;margin:0;margin-bottom:var(--pico-spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--pico-spacing) * .375);color:var(--pico-color);font-weight:var(--pico-form-label-font-weight,var(--pico-font-weight))}fieldset legend{margin-bottom:calc(var(--pico-spacing) * .5)}button[type=submit],input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal)}input,select,textarea{--pico-background-color:var(--pico-form-element-background-color);--pico-border-color:var(--pico-form-element-border-color);--pico-color:var(--pico-form-element-color);--pico-box-shadow:none;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--pico-background-color:var(--pico-form-element-active-background-color)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--pico-border-color:var(--pico-form-element-active-border-color)}:where(select,textarea):not([readonly]):focus,input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus{--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],label[aria-disabled=true],select[disabled],textarea[disabled]{opacity:var(--pico-form-element-disabled-opacity);pointer-events:none}label[aria-disabled=true] input[disabled]{opacity:1}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid]{padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal)!important;padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=false]:not(select){background-image:var(--pico-icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=true]:not(select){background-image:var(--pico-icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--pico-border-color:var(--pico-form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--pico-border-color:var(--pico-form-element-valid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--pico-border-color:var(--pico-form-element-invalid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--pico-form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--pico-spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal);padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);background-image:var(--pico-icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}select[multiple] option:checked{background:var(--pico-form-element-selected-background-color);color:var(--pico-form-element-color)}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}textarea{display:block;resize:vertical}textarea[aria-invalid]{--pico-icon-height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);background-position:top right .75rem!important;background-size:1rem var(--pico-icon-height)!important}:where(input,select,textarea,fieldset,.grid)+small{display:block;width:100%;margin-top:calc(var(--pico-spacing) * -.75);margin-bottom:var(--pico-spacing);color:var(--pico-muted-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=false]+small{color:var(--pico-ins-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=true]+small{color:var(--pico-del-color)}label>:where(input,select,textarea){margin-top:calc(var(--pico-spacing) * .25)}label:has([type=checkbox],[type=radio]){width:-moz-fit-content;width:fit-content;cursor:pointer}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-inline-end:.5em;border-width:var(--pico-border-width);vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-bottom:0;cursor:pointer}[type=checkbox]~label:not(:last-of-type),[type=radio]~label:not(:last-of-type){margin-inline-end:1em}[type=checkbox]:indeterminate{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--pico-background-color:var(--pico-switch-background-color);--pico-color:var(--pico-switch-color);width:2.25em;height:1.25em;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:1.25em;background-color:var(--pico-background-color);line-height:1.25em}[type=checkbox][role=switch]:not([aria-invalid]){--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:before{display:block;aspect-ratio:1;height:100%;border-radius:50%;background-color:var(--pico-color);box-shadow:var(--pico-switch-thumb-box-shadow);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:focus{--pico-background-color:var(--pico-switch-background-color);--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:checked{--pico-background-color:var(--pico-switch-checked-background-color);--pico-border-color:var(--pico-switch-checked-background-color);background-image:none}[type=checkbox][role=switch]:checked::before{margin-inline-start:calc(2.25em - 1.25em)}[type=checkbox][role=switch][disabled]{--pico-background-color:var(--pico-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus{--pico-background-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true]{--pico-background-color:var(--pico-form-element-invalid-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus,[type=radio][aria-invalid=false]:checked,[type=radio][aria-invalid=false]:checked:active,[type=radio][aria-invalid=false]:checked:focus{--pico-border-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=radio]:checked:active[aria-invalid=true],[type=radio]:checked:focus[aria-invalid=true],[type=radio]:checked[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--pico-icon-position:0.75rem;--pico-icon-width:1rem;padding-right:calc(var(--pico-icon-width) + var(--pico-icon-position));background-image:var(--pico-icon-date);background-position:center right var(--pico-icon-position);background-size:var(--pico-icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--pico-icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--pico-icon-width);margin-right:calc(var(--pico-icon-width) * -1);margin-left:var(--pico-icon-position);opacity:0}@-moz-document url-prefix(){[type=date],[type=datetime-local],[type=month],[type=time],[type=week]{padding-right:var(--pico-form-element-spacing-horizontal)!important;background-image:none!important}}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--pico-color:var(--pico-muted-color);margin-left:calc(var(--pico-outline-width) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) 0;padding-left:var(--pico-outline-width);border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{margin-right:calc(var(--pico-spacing)/ 2);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal)}[type=file]:is(:hover,:active,:focus)::file-selector-button{--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border)}[type=file]:focus::file-selector-button{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-webkit-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-moz-range-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-moz-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-ms-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-ms-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-moz-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-ms-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]:active,[type=range]:focus-within{--pico-range-border-color:var(--pico-range-active-border-color);--pico-range-thumb-color:var(--pico-range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem);background-image:var(--pico-icon-search);background-position:center left calc(var(--pico-form-element-spacing-horizontal) + .125rem);background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--pico-icon-search),var(--pico-icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--pico-icon-search),var(--pico-icon-invalid)}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}details{display:block;margin-bottom:var(--pico-spacing)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--pico-transition)}details summary:not([role]){color:var(--pico-accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;margin-inline-start:calc(var(--pico-spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--pico-transition)}details summary:focus{outline:0}details summary:focus:not([role]){color:var(--pico-accordion-active-summary-color)}details summary:focus-visible:not([role]){outline:var(--pico-outline-width) solid var(--pico-primary-focus);outline-offset:calc(var(--pico-spacing,1rem) * 0.5);color:var(--pico-primary)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--pico-line-height,1.5))}details[open]>summary{margin-bottom:var(--pico-spacing)}details[open]>summary:not([role]):not(:focus){color:var(--pico-accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin-bottom:var(--pico-block-spacing-vertical);padding:var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal);border-radius:var(--pico-border-radius);background:var(--pico-card-background-color);box-shadow:var(--pico-card-box-shadow)}article>footer,article>header{margin-right:calc(var(--pico-block-spacing-horizontal) * -1);margin-left:calc(var(--pico-block-spacing-horizontal) * -1);padding:calc(var(--pico-block-spacing-vertical) * .66) var(--pico-block-spacing-horizontal);background-color:var(--pico-card-sectioning-background-color)}article>header{margin-top:calc(var(--pico-block-spacing-vertical) * -1);margin-bottom:var(--pico-block-spacing-vertical);border-bottom:var(--pico-border-width) solid var(--pico-card-border-color);border-top-right-radius:var(--pico-border-radius);border-top-left-radius:var(--pico-border-radius)}article>footer{margin-top:var(--pico-block-spacing-vertical);margin-bottom:calc(var(--pico-block-spacing-vertical) * -1);border-top:var(--pico-border-width) solid var(--pico-card-border-color);border-bottom-right-radius:var(--pico-border-radius);border-bottom-left-radius:var(--pico-border-radius)}details.dropdown{position:relative;border-bottom:none}details.dropdown>a::after,details.dropdown>button::after,details.dropdown>summary::after{display:block;width:1rem;height:calc(1rem * var(--pico-line-height,1.5));margin-inline-start:.25rem;float:right;transform:rotate(0) translateX(.2rem);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}nav details.dropdown{margin-bottom:0}details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-form-element-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-form-element-background-color);color:var(--pico-form-element-placeholder-color);line-height:inherit;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}details.dropdown>summary:not([role]):active,details.dropdown>summary:not([role]):focus{border-color:var(--pico-form-element-active-border-color);background-color:var(--pico-form-element-active-background-color)}details.dropdown>summary:not([role]):focus{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}details.dropdown>summary:not([role]):focus-visible{outline:0}details.dropdown>summary:not([role])[aria-invalid=false]{--pico-form-element-border-color:var(--pico-form-element-valid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-valid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-valid-focus-color)}details.dropdown>summary:not([role])[aria-invalid=true]{--pico-form-element-border-color:var(--pico-form-element-invalid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-invalid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-invalid-focus-color)}nav details.dropdown{display:inline;margin:calc(var(--pico-nav-element-spacing-vertical) * -1) 0}nav details.dropdown>summary::after{transform:rotate(0) translateX(0)}nav details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-nav-link-spacing-vertical) * 2);padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav details.dropdown>summary:not([role]):focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}details.dropdown>summary+ul{display:flex;z-index:99;position:absolute;left:0;flex-direction:column;width:100%;min-width:-moz-fit-content;min-width:fit-content;margin:0;margin-top:var(--pico-outline-width);padding:0;border:var(--pico-border-width) solid var(--pico-dropdown-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-dropdown-background-color);box-shadow:var(--pico-dropdown-box-shadow);color:var(--pico-dropdown-color);white-space:nowrap;opacity:0;transition:opacity var(--pico-transition),transform 0s ease-in-out 1s}details.dropdown>summary+ul[dir=rtl]{right:0;left:auto}details.dropdown>summary+ul li{width:100%;margin-bottom:0;padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);list-style:none}details.dropdown>summary+ul li:first-of-type{margin-top:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li:last-of-type{margin-bottom:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li a{display:block;margin:calc(var(--pico-form-element-spacing-vertical) * -.5) calc(var(--pico-form-element-spacing-horizontal) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);overflow:hidden;border-radius:0;color:var(--pico-dropdown-color);text-decoration:none;text-overflow:ellipsis}details.dropdown>summary+ul li a:active,details.dropdown>summary+ul li a:focus,details.dropdown>summary+ul li a:focus-visible,details.dropdown>summary+ul li a:hover,details.dropdown>summary+ul li a[aria-current]:not([aria-current=false]){background-color:var(--pico-dropdown-hover-background-color)}details.dropdown>summary+ul li label{width:100%}details.dropdown>summary+ul li:has(label):hover{background-color:var(--pico-dropdown-hover-background-color)}details.dropdown[open]>summary{margin-bottom:0}details.dropdown[open]>summary+ul{transform:scaleY(1);opacity:1;transition:opacity var(--pico-transition),transform 0s ease-in-out 0s}details.dropdown[open]>summary::before{display:block;z-index:1;position:fixed;width:100vw;height:100vh;inset:0;background:0 0;content:"";cursor:default}label>details.dropdown{margin-top:calc(var(--pico-spacing) * .25)}[role=group],[role=search]{display:inline-flex;position:relative;width:100%;margin-bottom:var(--pico-spacing);border-radius:var(--pico-border-radius);box-shadow:var(--pico-group-box-shadow,0 0 0 transparent);vertical-align:middle;transition:box-shadow var(--pico-transition)}[role=group] input:not([type=checkbox],[type=radio]),[role=group] select,[role=group]>*,[role=search] input:not([type=checkbox],[type=radio]),[role=search] select,[role=search]>*{position:relative;flex:1 1 auto;margin-bottom:0}[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=group]>:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child),[role=search]>:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}[role=group] input:not([type=checkbox],[type=radio]):not(:last-child),[role=group] select:not(:last-child),[role=group]>:not(:last-child),[role=search] input:not([type=checkbox],[type=radio]):not(:last-child),[role=search] select:not(:last-child),[role=search]>:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}[role=group] input:not([type=checkbox],[type=radio]):focus,[role=group] select:focus,[role=group]>:focus,[role=search] input:not([type=checkbox],[type=radio]):focus,[role=search] select:focus,[role=search]>:focus{z-index:2}[role=group] [role=button]:not(:first-child),[role=group] [type=button]:not(:first-child),[role=group] [type=reset]:not(:first-child),[role=group] [type=submit]:not(:first-child),[role=group] button:not(:first-child),[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=search] [role=button]:not(:first-child),[role=search] [type=button]:not(:first-child),[role=search] [type=reset]:not(:first-child),[role=search] [type=submit]:not(:first-child),[role=search] button:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child){margin-left:calc(var(--pico-border-width) * -1)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=reset],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=reset],[role=search] [type=submit],[role=search] button{width:auto}@supports selector(:has(*)){[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-button)}[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select,[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select{border-color:transparent}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus),[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-input)}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) button,[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) button{--pico-button-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-border);--pico-button-hover-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-hover-border)}[role=group] [role=button]:focus,[role=group] [type=button]:focus,[role=group] [type=reset]:focus,[role=group] [type=submit]:focus,[role=group] button:focus,[role=search] [role=button]:focus,[role=search] [type=button]:focus,[role=search] [type=reset]:focus,[role=search] [type=submit]:focus,[role=search] button:focus{box-shadow:none}}[role=search]>:first-child{border-top-left-radius:5rem;border-bottom-left-radius:5rem}[role=search]>:last-child{border-top-right-radius:5rem;border-bottom-right-radius:5rem}[aria-busy=true]:not(input,select,textarea,html,form){white-space:nowrap}[aria-busy=true]:not(input,select,textarea,html,form)::before{display:inline-block;width:1em;height:1em;background-image:var(--pico-icon-loading);background-size:1em auto;background-repeat:no-repeat;content:"";vertical-align:-.125em}[aria-busy=true]:not(input,select,textarea,html,form):not(:empty)::before{margin-inline-end:calc(var(--pico-spacing) * .5)}[aria-busy=true]:not(input,select,textarea,html,form):empty{text-align:center}[role=button][aria-busy=true],[type=button][aria-busy=true],[type=reset][aria-busy=true],[type=submit][aria-busy=true],a[aria-busy=true],button[aria-busy=true]{pointer-events:none}:host,:root{--pico-scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:0;border:0;-webkit-backdrop-filter:var(--pico-modal-overlay-backdrop-filter);backdrop-filter:var(--pico-modal-overlay-backdrop-filter);background-color:var(--pico-modal-overlay-background-color);color:var(--pico-color)}dialog>article{width:100%;max-height:calc(100vh - var(--pico-spacing) * 2);margin:var(--pico-spacing);overflow:auto}@media (min-width:576px){dialog>article{max-width:510px}}@media (min-width:768px){dialog>article{max-width:700px}}dialog>article>header>*{margin-bottom:0}dialog>article>header .close,dialog>article>header :is(a,button)[rel=prev]{margin:0;margin-left:var(--pico-spacing);padding:0;float:right}dialog>article>footer{text-align:right}dialog>article>footer [role=button],dialog>article>footer button{margin-bottom:0}dialog>article>footer [role=button]:not(:first-of-type),dialog>article>footer button:not(:first-of-type){margin-left:calc(var(--pico-spacing) * .5)}dialog>article .close,dialog>article :is(a,button)[rel=prev]{display:block;width:1rem;height:1rem;margin-top:calc(var(--pico-spacing) * -1);margin-bottom:var(--pico-spacing);margin-left:auto;border:none;background-image:var(--pico-icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;background-color:transparent;opacity:.5;transition:opacity var(--pico-transition)}dialog>article .close:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),dialog>article :is(a,button)[rel=prev]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--pico-scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto;touch-action:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:"​"}nav,nav ul{display:flex}nav{justify-content:space-between;overflow:visible}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--pico-nav-element-spacing-vertical) var(--pico-nav-element-spacing-horizontal)}nav li :where(a,[role=link]){display:inline-block;margin:calc(var(--pico-nav-link-spacing-vertical) * -1) calc(var(--pico-nav-link-spacing-horizontal) * -1);padding:var(--pico-nav-link-spacing-vertical) var(--pico-nav-link-spacing-horizontal);border-radius:var(--pico-border-radius)}nav li :where(a,[role=link]):not(:hover){text-decoration:none}nav li [role=button],nav li [type=button],nav li button,nav li input:not([type=checkbox],[type=radio],[type=range],[type=file]),nav li select{height:auto;margin-right:inherit;margin-bottom:0;margin-left:inherit;padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){margin-inline-start:var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li a{margin:calc(var(--pico-nav-link-spacing-vertical) * -1) 0;margin-inline-start:calc(var(--pico-nav-link-spacing-horizontal) * -1)}nav[aria-label=breadcrumb] ul li:not(:last-child)::after{display:inline-block;position:absolute;width:calc(var(--pico-nav-link-spacing-horizontal) * 4);margin:0 calc(var(--pico-nav-link-spacing-horizontal) * -1);content:var(--pico-nav-breadcrumb-divider);color:var(--pico-muted-color);text-align:center;text-decoration:none;white-space:nowrap}nav[aria-label=breadcrumb] a[aria-current]:not([aria-current=false]){background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--pico-nav-element-spacing-vertical) * .5) var(--pico-nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--pico-spacing) * .5);overflow:hidden;border:0;border-radius:var(--pico-border-radius);background-color:var(--pico-progress-background-color);color:var(--pico-progress-color)}progress::-webkit-progress-bar{border-radius:var(--pico-border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--pico-progress-color);-webkit-transition:inline-size var(--pico-transition);transition:inline-size var(--pico-transition)}progress::-moz-progress-bar{background-color:var(--pico-progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--pico-progress-background-color) linear-gradient(to right,var(--pico-progress-color) 30%,var(--pico-progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input,[role=button]){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--pico-border-radius);background:var(--pico-tooltip-background-color);content:attr(data-tooltip);color:var(--pico-tooltip-color);font-style:normal;font-weight:var(--pico-font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--pico-tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{--pico-tooltip-slide-to:translate(-50%, -0.25rem);transform:translate(-50%,.75rem);animation-duration:.2s;animation-fill-mode:forwards;animation-name:tooltip-slide;opacity:0}[data-tooltip]:focus::after,[data-tooltip]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, 0rem);transform:translate(-50%,-.25rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{--pico-tooltip-slide-to:translate(-50%, 0.25rem);transform:translate(-50%,-.75rem);animation-name:tooltip-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, -0.3rem);transform:translate(-50%,-.5rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{--pico-tooltip-slide-to:translate(-0.25rem, -50%);transform:translate(.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{--pico-tooltip-caret-slide-to:translate(0.3rem, -50%);transform:translate(.05rem,-50%);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{--pico-tooltip-slide-to:translate(0.25rem, -50%);transform:translate(-.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{--pico-tooltip-caret-slide-to:translate(-0.3rem, -50%);transform:translate(-.05rem,-50%);animation-name:tooltip-caret-slide}}@keyframes tooltip-slide{to{transform:var(--pico-tooltip-slide-to);opacity:1}}@keyframes tooltip-caret-slide{50%{opacity:0}to{transform:var(--pico-tooltip-caret-slide-to);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}} \ No newline at end of file diff --git a/cmd/sogoms/admin/templates/app_detail.html b/cmd/sogoms/admin/templates/app_detail.html new file mode 100644 index 0000000..86e458c --- /dev/null +++ b/cmd/sogoms/admin/templates/app_detail.html @@ -0,0 +1,116 @@ +{{define "app_detail.html"}} +{{template "partials/header.html" .}} + +

    {{.App.App}}

    + +
    + +
    +
    Informations
    +
    +
    Version
    +
    {{if .App.Version}}{{.App.Version}}{{else}}Non définie{{end}}
    + +
    Base Path
    +
    {{.App.BasePath}}
    + +
    Hosts
    +
    + {{range .App.Hosts}} + {{.}}
    + {{end}} +
    +
    +
    + + +
    +
    Base de données
    +
    +
    Host
    +
    {{.App.Database.Host}}:{{.App.Database.Port}}
    + +
    Database
    +
    {{.App.Database.Name}}
    + +
    User
    +
    {{.App.Database.User}}
    +
    +
    + + +
    +
    + + +
    +
    Configuration
    +
    + {{if .App.Schema}} +
    Tables (schema)
    +
    {{.App.SchemaTableCount}} tables
    + {{end}} + + {{if .App.Queries}} +
    Fichiers queries
    +
    {{.App.QueriesCount}} fichiers
    + {{end}} + +
    Routes
    +
    {{.App.RoutesCount}} routes
    +
    +
    +
    + +{{if .App.Schema}} + +
    +
    Schema - Tables
    + + + + + + + + + + {{range .Tables}} + + + + + + {{end}} + +
    TableColonnesClé primaire
    {{.Name}}{{.ColumnCount}}{{.PrimaryKey}}
    +
    +{{end}} + +{{if .Routes}} + +
    +
    Routes API
    + + + + + + + + + + {{range .Routes}} + + + + + + {{end}} + +
    MéthodePathHandler
    {{.Method}}{{.Path}}{{.Handler}}
    +
    +{{end}} + +{{template "partials/footer.html" .}} +{{end}} diff --git a/cmd/sogoms/admin/templates/apps.html b/cmd/sogoms/admin/templates/apps.html new file mode 100644 index 0000000..590c614 --- /dev/null +++ b/cmd/sogoms/admin/templates/apps.html @@ -0,0 +1,58 @@ +{{define "apps.html"}} +{{template "partials/header.html" .}} + +
    +

    Applications

    + {{if .IsSuperAdmin}} + + Nouvelle App + {{end}} +
    + +{{if .Apps}} +
    + {{range .Apps}} +
    +
    + {{.App}} + {{if .Version}}v{{.Version}}{{end}} +
    + +
    +
    Hosts
    +
    + {{range .Hosts}} + {{.}}
    + {{end}} +
    + +
    Base Path
    +
    {{.BasePath}}
    + +
    Database
    +
    + {{.Database.User}}@{{.Database.Host}}:{{.Database.Port}}/{{.Database.Name}} +
    + + {{if .Schema}} +
    Tables (schema)
    +
    {{.SchemaTableCount}} tables
    + {{end}} + + {{if .Queries}} +
    Queries
    +
    {{.QueriesCount}} fichiers
    + {{end}} +
    + + +
    + {{end}} +
    +{{else}} +

    Aucune application configurée.

    +{{end}} + +{{template "partials/footer.html" .}} +{{end}} diff --git a/cmd/sogoms/admin/templates/apps_new.html b/cmd/sogoms/admin/templates/apps_new.html new file mode 100644 index 0000000..2034950 --- /dev/null +++ b/cmd/sogoms/admin/templates/apps_new.html @@ -0,0 +1,88 @@ +{{define "apps_new.html"}} +{{template "partials/header.html" .}} + +

    Nouvelle Application

    + +
    + + + +
    +
    Informations
    + + + + Lettres minuscules, chiffres et underscore uniquement + + + + + + +
    + + +
    +
    Hosts
    + + + +
    + + +
    +
    Base de données
    + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + + +
    +
    + + +
    +
    + + + +
    + + +
    +
    Authentification JWT
    + + + + Le secret JWT sera généré automatiquement +
    + + +
    + Annuler + +
    +
    + +{{template "partials/footer.html" .}} +{{end}} diff --git a/cmd/sogoms/admin/templates/dashboard.html b/cmd/sogoms/admin/templates/dashboard.html new file mode 100644 index 0000000..c7ee45a --- /dev/null +++ b/cmd/sogoms/admin/templates/dashboard.html @@ -0,0 +1,81 @@ +{{define "dashboard.html"}} +{{template "partials/header.html" .}} + +

    Dashboard

    + + + +
    + {{if .IsSuperAdmin}} + +
    +
    + Services + +
    +
    + Chargement... +
    +
    + {{end}} + + +
    +
    + Applications + {{if .IsSuperAdmin}} + ({{len .Apps}} apps) + {{end}} +
    +
    + Chargement... +
    +
    + + {{if .IsSuperAdmin}} + +
    +
    Statistiques
    +
    +
    Applications
    +
    {{len .Apps}}
    +
    +
    + {{end}} +
    + +{{if .IsSuperAdmin}} + +
    +
    + Jobs Cron + +
    +
    + Chargement... +
    +
    +{{end}} + +{{if not .IsSuperAdmin}} + +
    +
    Vos permissions
    +
      + {{range .Permissions}} +
    • {{.}}
    • + {{end}} +
    +
    +{{end}} + +{{template "partials/footer.html" .}} +{{end}} diff --git a/cmd/sogoms/admin/templates/login.html b/cmd/sogoms/admin/templates/login.html new file mode 100644 index 0000000..7cd9bf7 --- /dev/null +++ b/cmd/sogoms/admin/templates/login.html @@ -0,0 +1,69 @@ +{{define "login.html"}} + + + + + + Connexion - SOGOMS Admin + + + + + + + +{{end}} diff --git a/cmd/sogoms/admin/templates/partials/apps_list.html b/cmd/sogoms/admin/templates/partials/apps_list.html new file mode 100644 index 0000000..8df0095 --- /dev/null +++ b/cmd/sogoms/admin/templates/partials/apps_list.html @@ -0,0 +1,16 @@ +{{define "partials/apps_list.html"}} +{{if .Apps}} + +

    Voir toutes les apps →

    +{{else}} +

    Aucune application accessible

    +{{end}} +{{end}} diff --git a/cmd/sogoms/admin/templates/partials/cron_jobs.html b/cmd/sogoms/admin/templates/partials/cron_jobs.html new file mode 100644 index 0000000..f34b9f0 --- /dev/null +++ b/cmd/sogoms/admin/templates/partials/cron_jobs.html @@ -0,0 +1,36 @@ +{{define "partials/cron_jobs.html"}} +{{if .Jobs}} + + + + + + + + + + + + + {{range .Jobs}} + + + + + + + + + {{end}} + +
    AppJobTypeScheduleProchain runStatut
    {{index . "app_id"}}{{index . "name"}}{{index . "type"}}{{index . "schedule"}}{{index . "next_run"}} + {{if index . "enabled"}} + Actif + {{else}} + Inactif + {{end}} +
    +{{else}} +

    Aucun job cron configuré.

    +{{end}} +{{end}} diff --git a/cmd/sogoms/admin/templates/partials/flash.html b/cmd/sogoms/admin/templates/partials/flash.html new file mode 100644 index 0000000..87ebff0 --- /dev/null +++ b/cmd/sogoms/admin/templates/partials/flash.html @@ -0,0 +1,61 @@ +{{define "partials/flash.html"}} +{{if .FlashMessage}} + + + +{{end}} +{{end}} diff --git a/cmd/sogoms/admin/templates/partials/footer.html b/cmd/sogoms/admin/templates/partials/footer.html new file mode 100644 index 0000000..31083cb --- /dev/null +++ b/cmd/sogoms/admin/templates/partials/footer.html @@ -0,0 +1,9 @@ +{{define "partials/footer.html"}} + +
    +
    + SOGOMS Admin © 2025 +
    + + +{{end}} diff --git a/cmd/sogoms/admin/templates/partials/header.html b/cmd/sogoms/admin/templates/partials/header.html new file mode 100644 index 0000000..3ff302c --- /dev/null +++ b/cmd/sogoms/admin/templates/partials/header.html @@ -0,0 +1,50 @@ +{{define "partials/header.html"}} + + + + + + {{.Title}} - SOGOMS Admin + + + + + + +
    + +
    +
    +{{template "partials/flash.html" .}} +{{end}} diff --git a/cmd/sogoms/admin/templates/partials/services_status.html b/cmd/sogoms/admin/templates/partials/services_status.html new file mode 100644 index 0000000..f549f2d --- /dev/null +++ b/cmd/sogoms/admin/templates/partials/services_status.html @@ -0,0 +1,17 @@ +{{define "partials/services_status.html"}} +
      + {{range .Services}} +
    • + {{.Name}} + {{if .Available}} + OK + {{else}} + Erreur + {{end}} + {{if .LatencyMs}} + ({{.LatencyMs}}ms) + {{end}} +
    • + {{end}} +
    +{{end}} diff --git a/cmd/sogoms/cron/main.go b/cmd/sogoms/cron/main.go new file mode 100644 index 0000000..d1780a6 --- /dev/null +++ b/cmd/sogoms/cron/main.go @@ -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}) +} + diff --git a/cmd/sogoms/db/main.go b/cmd/sogoms/db/main.go index c2a09ab..3e7f2be 100755 --- a/cmd/sogoms/db/main.go +++ b/cmd/sogoms/db/main.go @@ -157,7 +157,22 @@ func main() { } 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) if !ok || appID == "" { 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) case "delete": return handleDelete(req, db, appID) - case "health": - return handleHealth(req, db) + case "introspect": + return handleIntrospect(req, db, appID) default: 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. func extractQueryParams(params map[string]any) (string, []any, error) { query, ok := params["query"].(string) @@ -434,3 +441,214 @@ func scanRows(rows *sql.Rows) ([]map[string]any, error) { 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 + } +} diff --git a/config/routes/prokov.yaml b/config/apps/prokov/app.yaml similarity index 100% rename from config/routes/prokov.yaml rename to config/apps/prokov/app.yaml diff --git a/config/apps/prokov/cron.yaml b/config/apps/prokov/cron.yaml new file mode 100644 index 0000000..305e02d --- /dev/null +++ b/config/apps/prokov/cron.yaml @@ -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 diff --git a/config/emails/prokov/password_reset.yaml b/config/apps/prokov/emails/password_reset.yaml similarity index 100% rename from config/emails/prokov/password_reset.yaml rename to config/apps/prokov/emails/password_reset.yaml diff --git a/config/emails/prokov/task_assigned.yaml b/config/apps/prokov/emails/task_assigned.yaml similarity index 100% rename from config/emails/prokov/task_assigned.yaml rename to config/apps/prokov/emails/task_assigned.yaml diff --git a/config/emails/prokov/tasks_today.yaml b/config/apps/prokov/emails/tasks_today.yaml similarity index 100% rename from config/emails/prokov/tasks_today.yaml rename to config/apps/prokov/emails/tasks_today.yaml diff --git a/config/emails/prokov/welcome.yaml b/config/apps/prokov/emails/welcome.yaml similarity index 100% rename from config/emails/prokov/welcome.yaml rename to config/apps/prokov/emails/welcome.yaml diff --git a/clients/prokov.sql b/config/apps/prokov/prokov.sql similarity index 95% rename from clients/prokov.sql rename to config/apps/prokov/prokov.sql index d57c417..834b7d0 100644 --- a/clients/prokov.sql +++ b/config/apps/prokov/prokov.sql @@ -54,7 +54,7 @@ CREATE TABLE `projects` ( 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_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 */; -- @@ -95,7 +95,7 @@ CREATE TABLE `tags` ( PRIMARY KEY (`id`), UNIQUE KEY `unique_tag_per_user` (`user_id`,`name`), 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 */; -- @@ -146,7 +146,7 @@ CREATE TABLE `tasks` ( 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_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 */; -- @@ -161,6 +161,8 @@ CREATE TABLE `users` ( `email` varchar(255) NOT NULL, `password` varchar(255) 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, `created_at` timestamp NULL DEFAULT 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 */; /*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 diff --git a/config/queries/prokov/auth.yaml b/config/apps/prokov/queries/auth.yaml similarity index 100% rename from config/queries/prokov/auth.yaml rename to config/apps/prokov/queries/auth.yaml diff --git a/config/queries/prokov/projects.yaml b/config/apps/prokov/queries/projects.yaml similarity index 100% rename from config/queries/prokov/projects.yaml rename to config/apps/prokov/queries/projects.yaml diff --git a/config/queries/prokov/statuses.yaml b/config/apps/prokov/queries/statuses.yaml similarity index 100% rename from config/queries/prokov/statuses.yaml rename to config/apps/prokov/queries/statuses.yaml diff --git a/config/queries/prokov/tags.yaml b/config/apps/prokov/queries/tags.yaml similarity index 100% rename from config/queries/prokov/tags.yaml rename to config/apps/prokov/queries/tags.yaml diff --git a/config/queries/prokov/tasks.yaml b/config/apps/prokov/queries/tasks.yaml similarity index 100% rename from config/queries/prokov/tasks.yaml rename to config/apps/prokov/queries/tasks.yaml diff --git a/config/scenarios/prokov/auth/login.yaml b/config/apps/prokov/scenarios/auth/login.yaml similarity index 100% rename from config/scenarios/prokov/auth/login.yaml rename to config/apps/prokov/scenarios/auth/login.yaml diff --git a/config/scenarios/prokov/auth/logout.yaml b/config/apps/prokov/scenarios/auth/logout.yaml similarity index 100% rename from config/scenarios/prokov/auth/logout.yaml rename to config/apps/prokov/scenarios/auth/logout.yaml diff --git a/config/scenarios/prokov/auth/me.yaml b/config/apps/prokov/scenarios/auth/me.yaml similarity index 100% rename from config/scenarios/prokov/auth/me.yaml rename to config/apps/prokov/scenarios/auth/me.yaml diff --git a/config/scenarios/prokov/auth/register.yaml b/config/apps/prokov/scenarios/auth/register.yaml similarity index 100% rename from config/scenarios/prokov/auth/register.yaml rename to config/apps/prokov/scenarios/auth/register.yaml diff --git a/config/scenarios/prokov/projects/create.yaml b/config/apps/prokov/scenarios/projects/create.yaml similarity index 100% rename from config/scenarios/prokov/projects/create.yaml rename to config/apps/prokov/scenarios/projects/create.yaml diff --git a/config/scenarios/prokov/projects/delete.yaml b/config/apps/prokov/scenarios/projects/delete.yaml similarity index 100% rename from config/scenarios/prokov/projects/delete.yaml rename to config/apps/prokov/scenarios/projects/delete.yaml diff --git a/config/scenarios/prokov/projects/list.yaml b/config/apps/prokov/scenarios/projects/list.yaml similarity index 100% rename from config/scenarios/prokov/projects/list.yaml rename to config/apps/prokov/scenarios/projects/list.yaml diff --git a/config/scenarios/prokov/projects/show.yaml b/config/apps/prokov/scenarios/projects/show.yaml similarity index 100% rename from config/scenarios/prokov/projects/show.yaml rename to config/apps/prokov/scenarios/projects/show.yaml diff --git a/config/scenarios/prokov/projects/update.yaml b/config/apps/prokov/scenarios/projects/update.yaml similarity index 100% rename from config/scenarios/prokov/projects/update.yaml rename to config/apps/prokov/scenarios/projects/update.yaml diff --git a/config/scenarios/prokov/statuses/create.yaml b/config/apps/prokov/scenarios/statuses/create.yaml similarity index 100% rename from config/scenarios/prokov/statuses/create.yaml rename to config/apps/prokov/scenarios/statuses/create.yaml diff --git a/config/scenarios/prokov/statuses/delete.yaml b/config/apps/prokov/scenarios/statuses/delete.yaml similarity index 100% rename from config/scenarios/prokov/statuses/delete.yaml rename to config/apps/prokov/scenarios/statuses/delete.yaml diff --git a/config/scenarios/prokov/statuses/list.yaml b/config/apps/prokov/scenarios/statuses/list.yaml similarity index 100% rename from config/scenarios/prokov/statuses/list.yaml rename to config/apps/prokov/scenarios/statuses/list.yaml diff --git a/config/scenarios/prokov/statuses/show.yaml b/config/apps/prokov/scenarios/statuses/show.yaml similarity index 100% rename from config/scenarios/prokov/statuses/show.yaml rename to config/apps/prokov/scenarios/statuses/show.yaml diff --git a/config/scenarios/prokov/statuses/update.yaml b/config/apps/prokov/scenarios/statuses/update.yaml similarity index 100% rename from config/scenarios/prokov/statuses/update.yaml rename to config/apps/prokov/scenarios/statuses/update.yaml diff --git a/config/scenarios/prokov/tags/create.yaml b/config/apps/prokov/scenarios/tags/create.yaml similarity index 100% rename from config/scenarios/prokov/tags/create.yaml rename to config/apps/prokov/scenarios/tags/create.yaml diff --git a/config/scenarios/prokov/tags/delete.yaml b/config/apps/prokov/scenarios/tags/delete.yaml similarity index 100% rename from config/scenarios/prokov/tags/delete.yaml rename to config/apps/prokov/scenarios/tags/delete.yaml diff --git a/config/scenarios/prokov/tags/list.yaml b/config/apps/prokov/scenarios/tags/list.yaml similarity index 100% rename from config/scenarios/prokov/tags/list.yaml rename to config/apps/prokov/scenarios/tags/list.yaml diff --git a/config/scenarios/prokov/tags/show.yaml b/config/apps/prokov/scenarios/tags/show.yaml similarity index 100% rename from config/scenarios/prokov/tags/show.yaml rename to config/apps/prokov/scenarios/tags/show.yaml diff --git a/config/scenarios/prokov/tags/update.yaml b/config/apps/prokov/scenarios/tags/update.yaml similarity index 100% rename from config/scenarios/prokov/tags/update.yaml rename to config/apps/prokov/scenarios/tags/update.yaml diff --git a/config/scenarios/prokov/tasks/create.yaml b/config/apps/prokov/scenarios/tasks/create.yaml similarity index 100% rename from config/scenarios/prokov/tasks/create.yaml rename to config/apps/prokov/scenarios/tasks/create.yaml diff --git a/config/scenarios/prokov/tasks/delete.yaml b/config/apps/prokov/scenarios/tasks/delete.yaml similarity index 100% rename from config/scenarios/prokov/tasks/delete.yaml rename to config/apps/prokov/scenarios/tasks/delete.yaml diff --git a/config/scenarios/prokov/tasks/list.yaml b/config/apps/prokov/scenarios/tasks/list.yaml similarity index 100% rename from config/scenarios/prokov/tasks/list.yaml rename to config/apps/prokov/scenarios/tasks/list.yaml diff --git a/config/scenarios/prokov/tasks/show.yaml b/config/apps/prokov/scenarios/tasks/show.yaml similarity index 100% rename from config/scenarios/prokov/tasks/show.yaml rename to config/apps/prokov/scenarios/tasks/show.yaml diff --git a/config/scenarios/prokov/tasks/update.yaml b/config/apps/prokov/scenarios/tasks/update.yaml similarity index 100% rename from config/scenarios/prokov/tasks/update.yaml rename to config/apps/prokov/scenarios/tasks/update.yaml diff --git a/config/apps/prokov/schema.yaml b/config/apps/prokov/schema.yaml new file mode 100644 index 0000000..27deb3c --- /dev/null +++ b/config/apps/prokov/schema.yaml @@ -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" diff --git a/config/sogoctl.yaml b/config/sogoctl.yaml index db330da..19f0fd2 100644 --- a/config/sogoctl.yaml +++ b/config/sogoctl.yaml @@ -43,6 +43,49 @@ services: depends_on: - 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: binary: /opt/sogoms/bin/sogoway args: diff --git a/cron b/cron new file mode 100755 index 0000000..769bdc0 Binary files /dev/null and b/cron differ diff --git a/db b/db new file mode 100755 index 0000000..1540678 Binary files /dev/null and b/db differ diff --git a/deploy-admin.sh b/deploy-admin.sh new file mode 100755 index 0000000..bed68a8 --- /dev/null +++ b/deploy-admin.sh @@ -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." diff --git a/deploy.sh b/deploy.sh index 0c68418..1493c7a 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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?" fi -if [ ! -d "config/routes" ]; then - echo_error "config/routes missing" +if [ ! -d "config/apps" ]; then + echo_error "config/apps missing" fi # 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-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-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/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 echo_step "Creating archives..." @@ -119,10 +121,10 @@ $SSH_CMD " echo '📁 Deploying binaries...' incus exec ${INCUS_CONTAINER} -- mkdir -p ${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...' - 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}/ echo '📁 Setting up run and log directories...' @@ -186,7 +188,8 @@ echo_info " Deployment time: $(date)" echo "" echo_warning "Next steps on gw3:" 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_info "To connect: ssh in3 -t 'incus exec $INCUS_CONTAINER -- sh'" diff --git a/internal/admin/audit.go b/internal/admin/audit.go new file mode 100644 index 0000000..4a2eb6d --- /dev/null +++ b/internal/admin/audit.go @@ -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, + }) +} diff --git a/internal/admin/config.go b/internal/admin/config.go new file mode 100644 index 0000000..b1eeeb2 --- /dev/null +++ b/internal/admin/config.go @@ -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 +} diff --git a/internal/admin/permissions.go b/internal/admin/permissions.go new file mode 100644 index 0000000..d0c2952 --- /dev/null +++ b/internal/admin/permissions.go @@ -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 +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 624916e..db362fb 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -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. func (j *JWT) Generate(userID int64, email, name, appID string) (string, error) { now := time.Now() diff --git a/internal/config/config.go b/internal/config/config.go index 7d84e23..c2b13d4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,7 +20,8 @@ type AppConfig struct { Database Database `yaml:"database"` Auth Auth `yaml:"auth"` 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. @@ -48,6 +49,14 @@ func (q *Queries) Get(domain, key string) string { return "" } +// FileCount retourne le nombre de fichiers de queries chargés. +func (q *Queries) FileCount() int { + if q == nil || q.files == nil { + return 0 + } + return len(q.files) +} + // GetMap retourne une map de requêtes (ex: login_data). func (q *Queries) GetMap(domain, key string) map[string]string { if q == nil || q.files == nil { @@ -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 { r.mu.Lock() defer r.mu.Unlock() - routesDir := filepath.Join(r.configDir, "routes") - entries, err := os.ReadDir(routesDir) + appsDir := filepath.Join(r.configDir, "apps") + entries, err := os.ReadDir(appsDir) if err != nil { - return fmt.Errorf("read routes dir: %w", err) + return fmt.Errorf("read apps dir: %w", err) } for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { + if !entry.IsDir() { continue } - path := filepath.Join(routesDir, entry.Name()) - cfg, err := r.loadAppConfig(path) + appID := entry.Name() + appConfigPath := filepath.Join(appsDir, appID, "app.yaml") + + // Vérifier que app.yaml existe + if _, err := os.Stat(appConfigPath); os.IsNotExist(err) { + continue + } + + cfg, err := r.loadAppConfig(appConfigPath, appID) if err != nil { - return fmt.Errorf("load %s: %w", entry.Name(), err) + return fmt.Errorf("load %s: %w", appID, err) } r.apps[cfg.App] = cfg @@ -333,7 +349,7 @@ func (r *Registry) Load() error { } // 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) if err != nil { return nil, err @@ -372,15 +388,18 @@ func (r *Registry) loadAppConfig(path string) (*AppConfig, error) { cfg.Auth.JWTExpiry = "24h" } - // Charger les requêtes depuis config/queries/{app}/ - cfg.Queries = r.loadQueries(cfg.App) + // Charger les requêtes depuis config/apps/{app}/queries/ + cfg.Queries = r.loadQueries(appID) + + // Charger le schema depuis config/apps/{app}/schema.yaml + cfg.Schema = loadSchema(r.configDir, appID) return &cfg, nil } // loadQueries charge les fichiers de requêtes pour une application. func (r *Registry) loadQueries(appID string) *Queries { - queriesDir := filepath.Join(r.configDir, "queries", appID) + queriesDir := filepath.Join(r.configDir, "apps", appID, "queries") entries, err := os.ReadDir(queriesDir) if err != nil { return nil // Pas de répertoire queries, c'est OK diff --git a/internal/config/schema.go b/internal/config/schema.go new file mode 100644 index 0000000..c3f7dca --- /dev/null +++ b/internal/config/schema.go @@ -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 +} diff --git a/internal/cron/scheduler.go b/internal/cron/scheduler.go new file mode 100644 index 0000000..a5c625b --- /dev/null +++ b/internal/cron/scheduler.go @@ -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) +} diff --git a/sogoms.svg b/sogoms.svg new file mode 100644 index 0000000..d786cff --- /dev/null +++ b/sogoms.svg @@ -0,0 +1,2 @@ + +