// Handlers pour la page paramètres : lecture/écriture de la configuration globale. package api import ( "encoding/json" "fmt" "io" "net/http" "strconv" "git.geronzi.fr/proxmoxPanel/core/backend/internal/audit" "git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto" "git.geronzi.fr/proxmoxPanel/core/backend/internal/db" "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 } // NewSettingsHandler crée un SettingsHandler. func NewSettingsHandler(database *db.DB, auditLog *audit.Logger, enc *crypto.Encryptor) *SettingsHandler { return &SettingsHandler{db: database, auditLogger: auditLog, encryptor: enc} } // 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"` 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, 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 int rows.Scan(&m.ID, &m.Name, &m.Description, &m.Version, &isCore, &isEnabled, &m.NavHref, &m.NavIcon, &m.NavColor, &m.NavLabelKey) m.IsCore = isCore == 1 m.IsEnabled = isEnabled == 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)) JSONResponse(w, http.StatusOK, map[string]string{"message": "Module activé (redémarrage requis pour prendre effet)"}) } // 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)) JSONResponse(w, http.StatusOK, map[string]string{"message": "Module désactivé"}) } // 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"` } // GetRegistryModules liste les repos de l'organisation proxmoxPanel sur Forgejo. // 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 { JSONError(w, "Erreur lecture modules", http.StatusInternalServerError) return } installed := make(map[string]bool) for rows.Next() { var id string rows.Scan(&id) installed[id] = true } rows.Close() // Appel à l'API Forgejo resp, err := http.Get("https://git.geronzi.fr/api/v1/repos/search?q=&owner=proxmoxPanel&limit=50") if err != nil { JSONError(w, fmt.Sprintf("Erreur accès store : %v", err), http.StatusBadGateway) return } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { JSONError(w, "Erreur lecture réponse store", http.StatusBadGateway) return } // Structure de réponse Gitea/Forgejo var forgejoResp struct { Data []struct { Name string `json:"name"` FullName string `json:"full_name"` Description string `json:"description"` HTMLURL string `json:"html_url"` } `json:"data"` } if err := json.Unmarshal(body, &forgejoResp); err != nil { JSONError(w, "Erreur parsing réponse store", http.StatusBadGateway) return } var modules []RegistryModule for _, repo := range forgejoResp.Data { // Exclure le repo "core" 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], }) } if modules == nil { modules = []RegistryModule{} } JSONResponse(w, http.StatusOK, modules) } // 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"` } // 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 url := fmt.Sprintf("https://git.geronzi.fr/api/v1/repos/proxmoxPanel/%s/raw/module.json?ref=main", id) resp, err := http.Get(url) 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("https://git.geronzi.fr/proxmoxPanel/%s", id) // Insérer ou remplacer en DB _, err = h.db.Exec(` INSERT INTO modules (id, name, description, version, is_core, is_enabled, 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, 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, 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}, clientIP(r)) JSONResponse(w, http.StatusOK, map[string]string{ "message": fmt.Sprintf("Module %s installé — rebuild requis pour activation", mod.ID), }) }