- GetRegistryModules: endpoint /api/v1/orgs/{org}/repos + timeout 10s
répond toujours HTTP 200 avec {modules, error} au lieu de 502
- InstallRegistryModule: essaie branche master puis main pour module.json
- Ajouter FORGEJO_URL / FORGEJO_ORG dans docker-compose.yml
- Frontend: bouton rafraîchir store + affichage erreur
- Frontend: bannière rebuild en cours + bannière rebuild terminé
- Frontend: polling /api/health toutes les 3s après rebuild/restart
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
535 lines
17 KiB
Go
535 lines
17 KiB
Go
// Handlers pour la page paramètres : lecture/écriture de la configuration globale.
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
|
|
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
|
|
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
|
|
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
|
|
dockerclient "git.geronzi.fr/proxmoxPanel/core/backend/internal/docker"
|
|
"git.geronzi.fr/proxmoxPanel/core/backend/internal/logbuffer"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// SettingsHandler contient les handlers de configuration.
|
|
type SettingsHandler struct {
|
|
db *db.DB
|
|
auditLogger *audit.Logger
|
|
encryptor *crypto.Encryptor
|
|
docker *dockerclient.Client
|
|
}
|
|
|
|
// NewSettingsHandler crée un SettingsHandler.
|
|
func NewSettingsHandler(database *db.DB, auditLog *audit.Logger, enc *crypto.Encryptor, docker *dockerclient.Client) *SettingsHandler {
|
|
return &SettingsHandler{db: database, auditLogger: auditLog, encryptor: enc, docker: docker}
|
|
}
|
|
|
|
// paramètres publics (non-sensibles) accessibles par les admins.
|
|
var publicSettings = []string{
|
|
"instance_name",
|
|
"public_url",
|
|
"default_lang",
|
|
"proxmox_url",
|
|
"ssh_host",
|
|
"ssh_username",
|
|
"dashboard_shortcuts",
|
|
}
|
|
|
|
// paramètres sensibles : modifiables en écriture seule, stockés chiffrés.
|
|
var encryptedSettings = []string{"ssh_password", "proxmox_token"}
|
|
|
|
// GetAll retourne tous les paramètres publics de l'application.
|
|
// GET /api/settings
|
|
func (h *SettingsHandler) GetAll(w http.ResponseWriter, r *http.Request) {
|
|
result := make(map[string]string)
|
|
for _, key := range publicSettings {
|
|
value, _, err := h.db.GetSetting(key)
|
|
if err == nil {
|
|
result[key] = value
|
|
}
|
|
}
|
|
JSONResponse(w, http.StatusOK, result)
|
|
}
|
|
|
|
// UpdateSetting met à jour un paramètre spécifique.
|
|
// PUT /api/settings/{key}
|
|
// Body: { "value": "..." }
|
|
func (h *SettingsHandler) UpdateSetting(w http.ResponseWriter, r *http.Request) {
|
|
claims := GetClaims(r)
|
|
|
|
key := chi.URLParam(r, "key")
|
|
if key == "" {
|
|
JSONError(w, "Clé de paramètre manquante", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Vérifier que la clé est modifiable (publique ou chiffrée)
|
|
isPublic := false
|
|
for _, k := range publicSettings {
|
|
if k == key {
|
|
isPublic = true
|
|
break
|
|
}
|
|
}
|
|
isEncrypted := false
|
|
for _, k := range encryptedSettings {
|
|
if k == key {
|
|
isEncrypted = true
|
|
break
|
|
}
|
|
}
|
|
if !isPublic && !isEncrypted {
|
|
JSONError(w, "Paramètre non modifiable via l'API", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Value string `json:"value"`
|
|
}
|
|
if err := decodeJSON(r, &body); err != nil {
|
|
JSONError(w, "Corps de requête invalide", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Paramètres chiffrés : valeur vide = ne pas modifier
|
|
if isEncrypted {
|
|
if body.Value == "" {
|
|
JSONResponse(w, http.StatusOK, map[string]string{"message": "Aucun changement"})
|
|
return
|
|
}
|
|
encrypted, err := h.encryptor.Encrypt(body.Value)
|
|
if err != nil {
|
|
JSONError(w, "Erreur chiffrement", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := h.db.SetSetting(key, encrypted, true); err != nil {
|
|
JSONError(w, "Erreur sauvegarde paramètre", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h.auditLogger.Log(&claims.UserID, claims.Username, "setting_update", key,
|
|
map[string]string{"key": key}, clientIP(r))
|
|
JSONResponse(w, http.StatusOK, map[string]string{"message": "Paramètre mis à jour"})
|
|
return
|
|
}
|
|
|
|
if err := h.db.SetSetting(key, body.Value, false); err != nil {
|
|
JSONError(w, "Erreur sauvegarde paramètre", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.auditLogger.Log(&claims.UserID, claims.Username, "setting_update", key,
|
|
map[string]string{"key": key}, clientIP(r))
|
|
|
|
JSONResponse(w, http.StatusOK, map[string]string{"message": "Paramètre mis à jour"})
|
|
}
|
|
|
|
// moduleResp représente un module dans les réponses API, incluant les champs de navigation.
|
|
type moduleResp struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Version string `json:"version"`
|
|
IsCore bool `json:"is_core"`
|
|
IsEnabled bool `json:"is_enabled"`
|
|
HasBackend bool `json:"has_backend"`
|
|
NavHref string `json:"nav_href"`
|
|
NavIcon string `json:"nav_icon"`
|
|
NavColor string `json:"nav_color"`
|
|
NavLabelKey string `json:"nav_label_key"`
|
|
}
|
|
|
|
// GetModules retourne la liste de tous les modules et leur état.
|
|
// GET /api/modules
|
|
func (h *SettingsHandler) GetModules(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := h.db.Query(`
|
|
SELECT id, name, description, version, is_core, is_enabled, has_backend,
|
|
COALESCE(nav_href,''), COALESCE(nav_icon,''), COALESCE(nav_color,''), COALESCE(nav_label_key,'')
|
|
FROM modules ORDER BY is_core DESC, name ASC
|
|
`)
|
|
if err != nil {
|
|
JSONError(w, "Erreur lecture modules", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var modules []moduleResp
|
|
for rows.Next() {
|
|
var m moduleResp
|
|
var isCore, isEnabled, hasBackend int
|
|
rows.Scan(&m.ID, &m.Name, &m.Description, &m.Version, &isCore, &isEnabled, &hasBackend,
|
|
&m.NavHref, &m.NavIcon, &m.NavColor, &m.NavLabelKey)
|
|
m.IsCore = isCore == 1
|
|
m.IsEnabled = isEnabled == 1
|
|
m.HasBackend = hasBackend == 1
|
|
modules = append(modules, m)
|
|
}
|
|
|
|
JSONResponse(w, http.StatusOK, modules)
|
|
}
|
|
|
|
// EnableModule active un module.
|
|
// POST /api/modules/{id}/enable
|
|
func (h *SettingsHandler) EnableModule(w http.ResponseWriter, r *http.Request) {
|
|
claims := GetClaims(r)
|
|
id := chi.URLParam(r, "id")
|
|
|
|
result, err := h.db.Exec(`UPDATE modules SET is_enabled = 1 WHERE id = ?`, id)
|
|
if err != nil {
|
|
JSONError(w, "Erreur activation module", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
JSONError(w, "Module introuvable", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
h.auditLogger.Log(&claims.UserID, claims.Username, "module_enable", id, nil, clientIP(r))
|
|
|
|
// Si le module a du code backend, redémarrer le container pour que LoadActive() le prenne en compte.
|
|
restart := h.triggerRestartIfBackend(id)
|
|
JSONResponse(w, http.StatusOK, map[string]interface{}{"message": "Module activé", "restarting": restart})
|
|
}
|
|
|
|
// DisableModule désactive un module (ne peut pas désactiver les modules CORE).
|
|
// POST /api/modules/{id}/disable
|
|
func (h *SettingsHandler) DisableModule(w http.ResponseWriter, r *http.Request) {
|
|
claims := GetClaims(r)
|
|
id := chi.URLParam(r, "id")
|
|
|
|
// Vérifier que ce n'est pas un module CORE
|
|
var isCore int
|
|
if err := h.db.QueryRow(`SELECT is_core FROM modules WHERE id = ?`, id).Scan(&isCore); err != nil {
|
|
JSONError(w, "Module introuvable", http.StatusNotFound)
|
|
return
|
|
}
|
|
if isCore == 1 {
|
|
JSONError(w, "Les modules CORE ne peuvent pas être désactivés", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
h.db.Exec(`UPDATE modules SET is_enabled = 0 WHERE id = ?`, id)
|
|
h.auditLogger.Log(&claims.UserID, claims.Username, "module_disable", id, nil, clientIP(r))
|
|
|
|
restart := h.triggerRestartIfBackend(id)
|
|
JSONResponse(w, http.StatusOK, map[string]interface{}{"message": "Module désactivé", "restarting": restart})
|
|
}
|
|
|
|
// triggerRestartIfBackend déclenche un redémarrage Docker si le module a du code backend.
|
|
// Retourne true si un redémarrage a été déclenché.
|
|
func (h *SettingsHandler) triggerRestartIfBackend(moduleID string) bool {
|
|
if h.docker == nil || !h.docker.Available() {
|
|
return false
|
|
}
|
|
var hasBackend int
|
|
if err := h.db.QueryRow(`SELECT has_backend FROM modules WHERE id = ?`, moduleID).Scan(&hasBackend); err != nil {
|
|
return false
|
|
}
|
|
if hasBackend == 1 {
|
|
h.docker.Restart()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// enabledBackendModuleIDs retourne les IDs de tous les modules installés avec has_backend=1.
|
|
// Ces modules doivent être compilés dans le binaire lors d'un rebuild.
|
|
func (h *SettingsHandler) enabledBackendModuleIDs() ([]string, error) {
|
|
rows, err := h.db.Query(`SELECT id FROM modules WHERE has_backend = 1`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var ids []string
|
|
for rows.Next() {
|
|
var id string
|
|
rows.Scan(&id)
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
// GetLogs retourne les dernières lignes du log applicatif (tampon mémoire).
|
|
// GET /api/settings/logs?lines=200
|
|
func (h *SettingsHandler) GetLogs(w http.ResponseWriter, r *http.Request) {
|
|
n := 200
|
|
if s := r.URL.Query().Get("lines"); s != "" {
|
|
if v, err := strconv.Atoi(s); err == nil && v > 0 {
|
|
n = v
|
|
}
|
|
}
|
|
lines := logbuffer.Global.Lines(n)
|
|
if lines == nil {
|
|
lines = []string{}
|
|
}
|
|
JSONResponse(w, http.StatusOK, lines)
|
|
}
|
|
|
|
// GetAuditLog retourne le journal d'audit paginé.
|
|
// GET /api/settings/audit
|
|
func (h *SettingsHandler) GetAuditLog(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := h.db.Query(`
|
|
SELECT id, username, action, resource, details, ip, created_at
|
|
FROM audit_log ORDER BY created_at DESC LIMIT 100
|
|
`)
|
|
if err != nil {
|
|
JSONError(w, "Erreur lecture audit", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type entry struct {
|
|
ID int64 `json:"id"`
|
|
Username string `json:"username"`
|
|
Action string `json:"action"`
|
|
Resource *string `json:"resource,omitempty"`
|
|
Details *string `json:"details,omitempty"`
|
|
IP *string `json:"ip,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
var entries []entry
|
|
for rows.Next() {
|
|
var e entry
|
|
var resource, details, ip *string
|
|
rows.Scan(&e.ID, &e.Username, &e.Action, &resource, &details, &ip, &e.CreatedAt)
|
|
e.Resource = resource
|
|
e.Details = details
|
|
e.IP = ip
|
|
entries = append(entries, e)
|
|
}
|
|
|
|
JSONResponse(w, http.StatusOK, entries)
|
|
}
|
|
|
|
// RegistryModule représente un module disponible dans le store Forgejo.
|
|
type RegistryModule struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
RepoURL string `json:"repo_url"`
|
|
Installed bool `json:"installed"`
|
|
}
|
|
|
|
// registryResp est la réponse unifiée de /api/registry/modules.
|
|
type registryResp struct {
|
|
Modules []RegistryModule `json:"modules"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// forgejoClient est un client HTTP avec timeout pour l'API Forgejo.
|
|
var forgejoClient = &http.Client{Timeout: 10 * time.Second}
|
|
|
|
// GetRegistryModules liste les repos de l'organisation sur Forgejo.
|
|
// Utilise GET /api/v1/orgs/{org}/repos (retourne un tableau JSON direct).
|
|
// Répond toujours 200 — l'erreur éventuelle est dans le champ "error".
|
|
// GET /api/registry/modules
|
|
func (h *SettingsHandler) GetRegistryModules(w http.ResponseWriter, r *http.Request) {
|
|
// Récupérer les modules déjà installés en DB
|
|
rows, err := h.db.Query(`SELECT id FROM modules`)
|
|
if err != nil {
|
|
JSONResponse(w, http.StatusOK, registryResp{Modules: []RegistryModule{}, Error: "Erreur lecture DB : " + err.Error()})
|
|
return
|
|
}
|
|
installed := make(map[string]bool)
|
|
for rows.Next() {
|
|
var id string
|
|
rows.Scan(&id)
|
|
installed[id] = true
|
|
}
|
|
rows.Close()
|
|
|
|
// Base URL et organisation configurables via variables d'environnement
|
|
forgejoURL := envOr("FORGEJO_URL", "https://git.geronzi.fr")
|
|
forgejoOrg := envOr("FORGEJO_ORG", "proxmoxPanel")
|
|
|
|
// Appel à l'API Forgejo : /api/v1/orgs/{org}/repos retourne un tableau JSON direct
|
|
apiURL := fmt.Sprintf("%s/api/v1/orgs/%s/repos?limit=50", forgejoURL, forgejoOrg)
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
req, _ := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
|
resp, err := forgejoClient.Do(req)
|
|
if err != nil {
|
|
JSONResponse(w, http.StatusOK, registryResp{
|
|
Modules: []RegistryModule{},
|
|
Error: fmt.Sprintf("Impossible de joindre le store (%s) : %v", forgejoURL, err),
|
|
})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
JSONResponse(w, http.StatusOK, registryResp{
|
|
Modules: []RegistryModule{},
|
|
Error: fmt.Sprintf("Store a répondu HTTP %d : %s", resp.StatusCode, string(body)),
|
|
})
|
|
return
|
|
}
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
// /api/v1/orgs/{org}/repos retourne directement un tableau []repo
|
|
var repos []struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
HTMLURL string `json:"html_url"`
|
|
}
|
|
if err := json.Unmarshal(body, &repos); err != nil {
|
|
JSONResponse(w, http.StatusOK, registryResp{
|
|
Modules: []RegistryModule{},
|
|
Error: fmt.Sprintf("Réponse store invalide : %v", err),
|
|
})
|
|
return
|
|
}
|
|
|
|
modules := make([]RegistryModule, 0, len(repos))
|
|
for _, repo := range repos {
|
|
if repo.Name == "core" {
|
|
continue
|
|
}
|
|
modules = append(modules, RegistryModule{
|
|
ID: repo.Name,
|
|
Name: repo.Name,
|
|
Description: repo.Description,
|
|
RepoURL: repo.HTMLURL,
|
|
Installed: installed[repo.Name],
|
|
})
|
|
}
|
|
|
|
JSONResponse(w, http.StatusOK, registryResp{Modules: modules})
|
|
}
|
|
|
|
// envOr retourne la valeur de la variable d'environnement ou la valeur par défaut.
|
|
func envOr(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// moduleJSON représente le fichier module.json d'un module.
|
|
type moduleJSON struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Version string `json:"version"`
|
|
NavHref string `json:"nav_href"`
|
|
NavIcon string `json:"nav_icon"`
|
|
NavColor string `json:"nav_color"`
|
|
NavLabelKey string `json:"nav_label_key"`
|
|
CoreMinVersion string `json:"core_min_version"`
|
|
HasBackend bool `json:"has_backend"` // true = nécessite un rebuild Docker pour compilation
|
|
}
|
|
|
|
// InstallRegistryModule installe un module depuis le store Forgejo.
|
|
// POST /api/registry/modules/{id}/install
|
|
func (h *SettingsHandler) InstallRegistryModule(w http.ResponseWriter, r *http.Request) {
|
|
claims := GetClaims(r)
|
|
id := chi.URLParam(r, "id")
|
|
|
|
// Récupérer module.json depuis Forgejo
|
|
forgejoURL := envOr("FORGEJO_URL", "https://git.geronzi.fr")
|
|
forgejoOrg := envOr("FORGEJO_ORG", "proxmoxPanel")
|
|
// Essayer d'abord la branche master puis main
|
|
moduleJSONURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/module.json?ref=master", forgejoURL, forgejoOrg, id)
|
|
reqCtx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
req, _ := http.NewRequestWithContext(reqCtx, "GET", moduleJSONURL, nil)
|
|
resp, err := forgejoClient.Do(req)
|
|
if err == nil && resp.StatusCode == http.StatusNotFound {
|
|
// Retenter sur la branche main
|
|
resp.Body.Close()
|
|
moduleJSONURL = fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/module.json?ref=main", forgejoURL, forgejoOrg, id)
|
|
req2, _ := http.NewRequestWithContext(reqCtx, "GET", moduleJSONURL, nil)
|
|
resp, err = forgejoClient.Do(req2)
|
|
}
|
|
if err != nil {
|
|
JSONError(w, fmt.Sprintf("Impossible d'accéder au module %s : %v", id, err), http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
JSONError(w, fmt.Sprintf("module.json introuvable pour %s", id), http.StatusNotFound)
|
|
return
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
JSONError(w, fmt.Sprintf("Erreur récupération module.json : HTTP %d", resp.StatusCode), http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
JSONError(w, "Erreur lecture module.json", http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
var mod moduleJSON
|
|
if err := json.Unmarshal(body, &mod); err != nil {
|
|
JSONError(w, "Erreur parsing module.json", http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
// Valider l'ID
|
|
if mod.ID == "" {
|
|
mod.ID = id
|
|
}
|
|
if mod.Version == "" {
|
|
mod.Version = "1.0.0"
|
|
}
|
|
|
|
// URL du repo
|
|
repoURL := fmt.Sprintf("%s/%s/%s", envOr("FORGEJO_URL", "https://git.geronzi.fr"), envOr("FORGEJO_ORG", "proxmoxPanel"), id)
|
|
|
|
hasBackend := 0
|
|
if mod.HasBackend {
|
|
hasBackend = 1
|
|
}
|
|
|
|
// Insérer ou remplacer en DB
|
|
_, err = h.db.Exec(`
|
|
INSERT INTO modules (id, name, description, version, is_core, is_enabled, has_backend,
|
|
nav_href, nav_icon, nav_color, nav_label_key, repo_url, installed_at)
|
|
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
name=excluded.name, description=excluded.description, version=excluded.version,
|
|
has_backend=excluded.has_backend,
|
|
nav_href=excluded.nav_href, nav_icon=excluded.nav_icon, nav_color=excluded.nav_color,
|
|
nav_label_key=excluded.nav_label_key, repo_url=excluded.repo_url,
|
|
installed_at=CURRENT_TIMESTAMP
|
|
`, mod.ID, mod.Name, mod.Description, mod.Version, hasBackend,
|
|
mod.NavHref, mod.NavIcon, mod.NavColor, mod.NavLabelKey, repoURL)
|
|
if err != nil {
|
|
JSONError(w, fmt.Sprintf("Erreur installation module : %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.auditLogger.Log(&claims.UserID, claims.Username, "module_install", mod.ID,
|
|
map[string]string{"version": mod.Version, "has_backend": fmt.Sprintf("%v", mod.HasBackend)}, clientIP(r))
|
|
|
|
// Si le module a du code backend : déclencher un rebuild Docker.
|
|
// Le rebuild compile le nouveau module dans le binaire et recrée le container.
|
|
rebuilding := false
|
|
if mod.HasBackend && h.docker != nil && h.docker.Available() {
|
|
moduleIDs, err := h.enabledBackendModuleIDs()
|
|
if err == nil {
|
|
h.docker.RebuildAndRestart(moduleIDs)
|
|
rebuilding = true
|
|
}
|
|
}
|
|
|
|
JSONResponse(w, http.StatusAccepted, map[string]interface{}{
|
|
"message": fmt.Sprintf("Module %s installé", mod.ID),
|
|
"rebuilding": rebuilding,
|
|
})
|
|
}
|