mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
484 lines
15 KiB
Go
484 lines
15 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"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)
|
|
mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
var raw map[string]any
|
|
if err = json.Unmarshal(body, &raw); 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.
|
|
err = cfg.SecurityCopyFrom(h.configPath)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
applyConfigSecretsFromMap(&cfg, raw)
|
|
|
|
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
|
|
}
|
|
|
|
// Refresh cached pico token in case user changed it.
|
|
refreshPicoToken(&cfg)
|
|
|
|
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
|
|
}
|
|
|
|
// Restore security fields (tokens/keys) from the loaded config before validation,
|
|
// because private fields are lost during JSON round-trip.
|
|
if err = newCfg.SecurityCopyFrom(h.configPath); err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
applyConfigSecretsFromMap(&newCfg, base)
|
|
|
|
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
|
|
}
|
|
|
|
// Refresh cached pico token in case user changed it.
|
|
refreshPicoToken(&newCfg)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// handleTestCommandPatterns tests a command against whitelist and blacklist patterns.
|
|
//
|
|
// POST /api/config/test-command-patterns
|
|
func (h *Handler) handleTestCommandPatterns(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 req struct {
|
|
AllowPatterns []string `json:"allow_patterns"`
|
|
DenyPatterns []string `json:"deny_patterns"`
|
|
Command string `json:"command"`
|
|
}
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
lower := strings.ToLower(strings.TrimSpace(req.Command))
|
|
|
|
type result struct {
|
|
Allowed bool `json:"allowed"`
|
|
Blocked bool `json:"blocked"`
|
|
MatchedWhitelist *string `json:"matched_whitelist,omitempty"`
|
|
MatchedBlacklist *string `json:"matched_blacklist,omitempty"`
|
|
}
|
|
|
|
resp := result{Allowed: false, Blocked: false}
|
|
|
|
// Check whitelist first
|
|
for _, pattern := range req.AllowPatterns {
|
|
re, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
continue // skip invalid patterns
|
|
}
|
|
if re.MatchString(lower) {
|
|
resp.Allowed = true
|
|
resp.MatchedWhitelist = &pattern
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check blacklist
|
|
for _, pattern := range req.DenyPatterns {
|
|
re, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if re.MatchString(lower) {
|
|
resp.Blocked = true
|
|
resp.MatchedBlacklist = &pattern
|
|
break
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// 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.String() == "" {
|
|
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.String() == "" {
|
|
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.String() == "" {
|
|
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
|
|
}
|
|
|
|
if cfg.Channels.WeCom.Enabled {
|
|
if cfg.Channels.WeCom.BotID == "" {
|
|
errs = append(errs, "channels.wecom.bot_id is required when wecom channel is enabled")
|
|
}
|
|
if cfg.Channels.WeCom.Secret.String() == "" {
|
|
errs = append(errs, "channels.wecom.secret is required when wecom 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
|
|
}
|
|
}
|
|
}
|
|
|
|
func asMapField(value map[string]any, key string) (map[string]any, bool) {
|
|
raw, exists := value[key]
|
|
if !exists {
|
|
return nil, false
|
|
}
|
|
m, isMap := raw.(map[string]any)
|
|
return m, isMap
|
|
}
|
|
|
|
func getSecretString(m map[string]any, key string) (string, bool) {
|
|
if raw, exists := m[key]; exists {
|
|
s, isString := raw.(string)
|
|
if isString {
|
|
return s, true
|
|
}
|
|
}
|
|
if raw, exists := m["_"+key]; exists {
|
|
s, isString := raw.(string)
|
|
if isString {
|
|
return s, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func applyConfigSecretsFromMap(cfg *config.Config, raw map[string]any) {
|
|
channels, hasChannels := asMapField(raw, "channels")
|
|
if hasChannels {
|
|
if telegram, hasTelegram := asMapField(channels, "telegram"); hasTelegram {
|
|
if token, hasToken := getSecretString(telegram, "token"); hasToken {
|
|
cfg.Channels.Telegram.SetToken(token)
|
|
}
|
|
}
|
|
if feishu, hasFeishu := asMapField(channels, "feishu"); hasFeishu {
|
|
if appSecret, hasAppSecret := getSecretString(feishu, "app_secret"); hasAppSecret {
|
|
cfg.Channels.Feishu.AppSecret.Set(appSecret)
|
|
}
|
|
if encryptKey, hasEncryptKey := getSecretString(feishu, "encrypt_key"); hasEncryptKey {
|
|
cfg.Channels.Feishu.EncryptKey.Set(encryptKey)
|
|
}
|
|
if verificationToken, hasVerificationToken := getSecretString(
|
|
feishu,
|
|
"verification_token",
|
|
); hasVerificationToken {
|
|
cfg.Channels.Feishu.VerificationToken.Set(verificationToken)
|
|
}
|
|
}
|
|
if discord, hasDiscord := asMapField(channels, "discord"); hasDiscord {
|
|
if token, hasToken := getSecretString(discord, "token"); hasToken {
|
|
cfg.Channels.Discord.Token.Set(token)
|
|
}
|
|
}
|
|
if weixin, hasWeixin := asMapField(channels, "weixin"); hasWeixin {
|
|
if token, hasToken := getSecretString(weixin, "token"); hasToken {
|
|
cfg.Channels.Weixin.SetToken(token)
|
|
}
|
|
}
|
|
if qq, hasQQ := asMapField(channels, "qq"); hasQQ {
|
|
if appSecret, hasAppSecret := getSecretString(qq, "app_secret"); hasAppSecret {
|
|
cfg.Channels.QQ.AppSecret.Set(appSecret)
|
|
}
|
|
}
|
|
if dingtalk, hasDingTalk := asMapField(channels, "dingtalk"); hasDingTalk {
|
|
if clientSecret, hasClientSecret := getSecretString(dingtalk, "client_secret"); hasClientSecret {
|
|
cfg.Channels.DingTalk.ClientSecret.Set(clientSecret)
|
|
}
|
|
}
|
|
if slack, hasSlack := asMapField(channels, "slack"); hasSlack {
|
|
if botToken, hasBotToken := getSecretString(slack, "bot_token"); hasBotToken {
|
|
cfg.Channels.Slack.BotToken.Set(botToken)
|
|
}
|
|
if appToken, hasAppToken := getSecretString(slack, "app_token"); hasAppToken {
|
|
cfg.Channels.Slack.AppToken.Set(appToken)
|
|
}
|
|
}
|
|
if matrix, hasMatrix := asMapField(channels, "matrix"); hasMatrix {
|
|
if accessToken, hasAccessToken := getSecretString(matrix, "access_token"); hasAccessToken {
|
|
cfg.Channels.Matrix.AccessToken.Set(accessToken)
|
|
}
|
|
}
|
|
if line, hasLine := asMapField(channels, "line"); hasLine {
|
|
if channelSecret, hasChannelSecret := getSecretString(line, "channel_secret"); hasChannelSecret {
|
|
cfg.Channels.LINE.ChannelSecret.Set(channelSecret)
|
|
}
|
|
if channelAccessToken, hasChannelAccessToken := getSecretString(
|
|
line,
|
|
"channel_access_token",
|
|
); hasChannelAccessToken {
|
|
cfg.Channels.LINE.ChannelAccessToken.Set(channelAccessToken)
|
|
}
|
|
}
|
|
if onebot, hasOneBot := asMapField(channels, "onebot"); hasOneBot {
|
|
if accessToken, hasAccessToken := getSecretString(onebot, "access_token"); hasAccessToken {
|
|
cfg.Channels.OneBot.AccessToken.Set(accessToken)
|
|
}
|
|
}
|
|
if wecom, hasWeCom := asMapField(channels, "wecom"); hasWeCom {
|
|
if secret, hasSecret := getSecretString(wecom, "secret"); hasSecret {
|
|
cfg.Channels.WeCom.SetSecret(secret)
|
|
}
|
|
}
|
|
if pico, hasPico := asMapField(channels, "pico"); hasPico {
|
|
if token, hasToken := getSecretString(pico, "token"); hasToken {
|
|
cfg.Channels.Pico.SetToken(token)
|
|
}
|
|
}
|
|
if irc, hasIRC := asMapField(channels, "irc"); hasIRC {
|
|
if password, hasPassword := getSecretString(irc, "password"); hasPassword {
|
|
cfg.Channels.IRC.Password.Set(password)
|
|
}
|
|
if nickservPassword, hasNickservPassword := getSecretString(irc, "nickserv_password"); hasNickservPassword {
|
|
cfg.Channels.IRC.NickServPassword.Set(nickservPassword)
|
|
}
|
|
if saslPassword, hasSASLPassword := getSecretString(irc, "sasl_password"); hasSASLPassword {
|
|
cfg.Channels.IRC.SASLPassword.Set(saslPassword)
|
|
}
|
|
}
|
|
}
|
|
|
|
tools, hasTools := asMapField(raw, "tools")
|
|
if !hasTools {
|
|
return
|
|
}
|
|
skills, hasSkills := asMapField(tools, "skills")
|
|
if !hasSkills {
|
|
return
|
|
}
|
|
if github, hasGithub := asMapField(skills, "github"); hasGithub {
|
|
if token, hasToken := getSecretString(github, "token"); hasToken {
|
|
cfg.Tools.Skills.Github.Token.Set(token)
|
|
}
|
|
}
|
|
registries, hasRegistries := asMapField(skills, "registries")
|
|
if !hasRegistries {
|
|
return
|
|
}
|
|
if clawHub, hasClawHub := asMapField(registries, "clawhub"); hasClawHub {
|
|
if authToken, hasAuthToken := getSecretString(clawHub, "auth_token"); hasAuthToken {
|
|
cfg.Tools.Skills.Registries.ClawHub.AuthToken.Set(authToken)
|
|
}
|
|
}
|
|
}
|