mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
1f9d390a64
- Move SecurityCopyFrom() before validateConfig() in PUT and PATCH handlers - Make SecurityCopyFrom() call applySecurityConfig() to populate private fields - Add tests for config save with security-only channel tokens Without this fix, saving config via the web UI fails with 'channels.pico.token is required' (and similar for Telegram/Discord) when tokens are stored in .security.yml, because the validation ran before security credentials were copied to the config struct.
251 lines
7.6 KiB
Go
251 lines
7.6 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
)
|
|
|
|
// registerConfigRoutes binds configuration management endpoints to the ServeMux.
|
|
func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /api/config", h.handleGetConfig)
|
|
mux.HandleFunc("PUT /api/config", h.handleUpdateConfig)
|
|
mux.HandleFunc("PATCH /api/config", h.handlePatchConfig)
|
|
}
|
|
|
|
// handleGetConfig returns the complete system configuration.
|
|
//
|
|
// GET /api/config
|
|
func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
|
cfg, err := config.LoadConfig(h.configPath)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(cfg); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// handleUpdateConfig updates the complete system configuration.
|
|
//
|
|
// PUT /api/config
|
|
func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer r.Body.Close()
|
|
|
|
var cfg config.Config
|
|
if err = json.Unmarshal(body, &cfg); err != nil {
|
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if execAllowRemoteOmitted(body) {
|
|
cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote
|
|
}
|
|
|
|
// Load existing config and copy security credentials before validation,
|
|
// so that security-managed fields (e.g. pico token) are available.
|
|
oldCfg, err := config.LoadConfig(h.configPath)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
cfg.SecurityCopyFrom(oldCfg)
|
|
|
|
if errs := validateConfig(&cfg); len(errs) > 0 {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"status": "validation_error",
|
|
"errors": errs,
|
|
})
|
|
return
|
|
}
|
|
|
|
logger.Infof("configuration updated successfully")
|
|
|
|
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func execAllowRemoteOmitted(body []byte) bool {
|
|
var raw struct {
|
|
Tools *struct {
|
|
Exec *struct {
|
|
AllowRemote *bool `json:"allow_remote"`
|
|
} `json:"exec"`
|
|
} `json:"tools"`
|
|
}
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
return false
|
|
}
|
|
return raw.Tools == nil || raw.Tools.Exec == nil || raw.Tools.Exec.AllowRemote == nil
|
|
}
|
|
|
|
// handlePatchConfig partially updates the system configuration using JSON Merge Patch (RFC 7396).
|
|
// Only the fields present in the request body will be updated; all other fields remain unchanged.
|
|
//
|
|
// PATCH /api/config
|
|
func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
|
|
patchBody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer r.Body.Close()
|
|
|
|
// Validate the patch is valid JSON
|
|
var patch map[string]any
|
|
if err = json.Unmarshal(patchBody, &patch); err != nil {
|
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Load existing config and marshal to a map for merging
|
|
cfg, err := config.LoadConfig(h.configPath)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
existing, err := json.Marshal(cfg)
|
|
if err != nil {
|
|
http.Error(w, "Failed to serialize current config", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var base map[string]any
|
|
if err = json.Unmarshal(existing, &base); err != nil {
|
|
http.Error(w, "Failed to parse current config", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Recursively merge patch into base
|
|
mergeMap(base, patch)
|
|
|
|
// Convert merged map back to Config struct
|
|
merged, err := json.Marshal(base)
|
|
if err != nil {
|
|
http.Error(w, "Failed to serialize merged config", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var newCfg config.Config
|
|
if err := json.Unmarshal(merged, &newCfg); err != nil {
|
|
http.Error(w, fmt.Sprintf("Merged config is invalid: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Copy security credentials before validation so security-managed
|
|
// fields (e.g. pico token) are available for validation checks.
|
|
newCfg.SecurityCopyFrom(cfg)
|
|
|
|
if errs := validateConfig(&newCfg); len(errs) > 0 {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"status": "validation_error",
|
|
"errors": errs,
|
|
})
|
|
return
|
|
}
|
|
|
|
if err := config.SaveConfig(h.configPath, &newCfg); err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// validateConfig checks the config for common errors before saving.
|
|
// Returns a list of human-readable error strings; empty means valid.
|
|
func validateConfig(cfg *config.Config) []string {
|
|
var errs []string
|
|
|
|
// Validate model_list entries
|
|
if err := cfg.ValidateModelList(); err != nil {
|
|
errs = append(errs, err.Error())
|
|
}
|
|
|
|
// Gateway port range
|
|
if cfg.Gateway.Port != 0 && (cfg.Gateway.Port < 1 || cfg.Gateway.Port > 65535) {
|
|
errs = append(errs, fmt.Sprintf("gateway.port %d is out of valid range (1-65535)", cfg.Gateway.Port))
|
|
}
|
|
|
|
// Pico channel: token required when enabled
|
|
if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token() == "" {
|
|
errs = append(errs, "channels.pico.token is required when pico channel is enabled")
|
|
}
|
|
|
|
// Telegram: token required when enabled
|
|
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token() == "" {
|
|
errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
|
|
}
|
|
|
|
// Discord: token required when enabled
|
|
if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token() == "" {
|
|
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
|
|
}
|
|
|
|
if cfg.Tools.Exec.Enabled {
|
|
if cfg.Tools.Exec.EnableDenyPatterns {
|
|
errs = append(
|
|
errs,
|
|
validateRegexPatterns("tools.exec.custom_deny_patterns", cfg.Tools.Exec.CustomDenyPatterns)...)
|
|
}
|
|
errs = append(
|
|
errs,
|
|
validateRegexPatterns("tools.exec.custom_allow_patterns", cfg.Tools.Exec.CustomAllowPatterns)...)
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
func validateRegexPatterns(field string, patterns []string) []string {
|
|
var errs []string
|
|
for index, pattern := range patterns {
|
|
if _, err := regexp.Compile(pattern); err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s[%d] is not a valid regular expression: %v", field, index, err))
|
|
}
|
|
}
|
|
return errs
|
|
}
|
|
|
|
// mergeMap recursively merges src into dst (JSON Merge Patch semantics).
|
|
// - If a key in src has a null value, it is deleted from dst.
|
|
// - If both dst and src have a nested object for the same key, merge recursively.
|
|
// - Otherwise the value from src overwrites dst.
|
|
func mergeMap(dst, src map[string]any) {
|
|
for key, srcVal := range src {
|
|
if srcVal == nil {
|
|
delete(dst, key)
|
|
continue
|
|
}
|
|
srcMap, srcIsMap := srcVal.(map[string]any)
|
|
dstMap, dstIsMap := dst[key].(map[string]any)
|
|
if srcIsMap && dstIsMap {
|
|
mergeMap(dstMap, srcMap)
|
|
} else {
|
|
dst[key] = srcVal
|
|
}
|
|
}
|
|
}
|