mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
refactor(web): load channel configs without exposing secret values (#2277)
* refactor(web): load channel configs without exposing secret values - add a dedicated channel config API that returns sanitized config plus configured secret metadata - update channel config pages and forms to use secret presence for placeholders, validation, reset, and save behavior - refresh the channel settings layout and clean up related i18n copy - add backend tests for the new channel config endpoint * fix(config): restore missing strings import
This commit is contained in:
@@ -3,6 +3,8 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
type channelCatalogItem struct {
|
||||
@@ -30,9 +32,22 @@ var channelCatalog = []channelCatalogItem{
|
||||
{Name: "irc", ConfigKey: "irc"},
|
||||
}
|
||||
|
||||
type channelConfigResponse struct {
|
||||
Config any `json:"config"`
|
||||
ConfiguredSecrets []string `json:"configured_secrets"`
|
||||
ConfigKey string `json:"config_key"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
}
|
||||
|
||||
type channelSecretPresence struct {
|
||||
key string
|
||||
configured bool
|
||||
}
|
||||
|
||||
// registerChannelRoutes binds read-only channel catalog endpoints to the ServeMux.
|
||||
func (h *Handler) registerChannelRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/channels/catalog", h.handleListChannelCatalog)
|
||||
mux.HandleFunc("GET /api/channels/{name}/config", h.handleGetChannelConfig)
|
||||
}
|
||||
|
||||
// handleListChannelCatalog returns the channels supported by backend.
|
||||
@@ -44,3 +59,172 @@ func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Reques
|
||||
"channels": channelCatalog,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetChannelConfig returns safe channel config plus secret presence metadata.
|
||||
//
|
||||
// GET /api/channels/{name}/config
|
||||
func (h *Handler) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) {
|
||||
channelName := r.PathValue("name")
|
||||
item, ok := findChannelCatalogItem(channelName)
|
||||
if !ok {
|
||||
http.Error(w, "Channel not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to load config", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp := buildChannelConfigResponse(cfg, item)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func findChannelCatalogItem(name string) (channelCatalogItem, bool) {
|
||||
for _, item := range channelCatalog {
|
||||
if item.Name == name {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return channelCatalogItem{}, false
|
||||
}
|
||||
|
||||
func buildChannelConfigResponse(cfg *config.Config, item channelCatalogItem) channelConfigResponse {
|
||||
resp := channelConfigResponse{
|
||||
ConfiguredSecrets: []string{},
|
||||
ConfigKey: item.ConfigKey,
|
||||
Variant: item.Variant,
|
||||
}
|
||||
|
||||
switch item.Name {
|
||||
case "weixin":
|
||||
channelCfg := cfg.Channels.Weixin
|
||||
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||
channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""},
|
||||
)
|
||||
channelCfg.Token = config.SecureString{}
|
||||
resp.Config = channelCfg
|
||||
case "telegram":
|
||||
channelCfg := cfg.Channels.Telegram
|
||||
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||
channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""},
|
||||
)
|
||||
channelCfg.Token = config.SecureString{}
|
||||
resp.Config = channelCfg
|
||||
case "discord":
|
||||
channelCfg := cfg.Channels.Discord
|
||||
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||
channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""},
|
||||
)
|
||||
channelCfg.Token = config.SecureString{}
|
||||
resp.Config = channelCfg
|
||||
case "slack":
|
||||
channelCfg := cfg.Channels.Slack
|
||||
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||
channelSecretPresence{key: "bot_token", configured: channelCfg.BotToken.String() != ""},
|
||||
channelSecretPresence{key: "app_token", configured: channelCfg.AppToken.String() != ""},
|
||||
)
|
||||
channelCfg.BotToken = config.SecureString{}
|
||||
channelCfg.AppToken = config.SecureString{}
|
||||
resp.Config = channelCfg
|
||||
case "feishu":
|
||||
channelCfg := cfg.Channels.Feishu
|
||||
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||
channelSecretPresence{key: "app_secret", configured: channelCfg.AppSecret.String() != ""},
|
||||
channelSecretPresence{key: "encrypt_key", configured: channelCfg.EncryptKey.String() != ""},
|
||||
channelSecretPresence{key: "verification_token", configured: channelCfg.VerificationToken.String() != ""},
|
||||
)
|
||||
channelCfg.AppSecret = config.SecureString{}
|
||||
channelCfg.EncryptKey = config.SecureString{}
|
||||
channelCfg.VerificationToken = config.SecureString{}
|
||||
resp.Config = channelCfg
|
||||
case "dingtalk":
|
||||
channelCfg := cfg.Channels.DingTalk
|
||||
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||
channelSecretPresence{key: "client_secret", configured: channelCfg.ClientSecret.String() != ""},
|
||||
)
|
||||
channelCfg.ClientSecret = config.SecureString{}
|
||||
resp.Config = channelCfg
|
||||
case "line":
|
||||
channelCfg := cfg.Channels.LINE
|
||||
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||
channelSecretPresence{key: "channel_secret", configured: channelCfg.ChannelSecret.String() != ""},
|
||||
channelSecretPresence{
|
||||
key: "channel_access_token",
|
||||
configured: channelCfg.ChannelAccessToken.String() != "",
|
||||
},
|
||||
)
|
||||
channelCfg.ChannelSecret = config.SecureString{}
|
||||
channelCfg.ChannelAccessToken = config.SecureString{}
|
||||
resp.Config = channelCfg
|
||||
case "qq":
|
||||
channelCfg := cfg.Channels.QQ
|
||||
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||
channelSecretPresence{key: "app_secret", configured: channelCfg.AppSecret.String() != ""},
|
||||
)
|
||||
channelCfg.AppSecret = config.SecureString{}
|
||||
resp.Config = channelCfg
|
||||
case "onebot":
|
||||
channelCfg := cfg.Channels.OneBot
|
||||
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||
channelSecretPresence{key: "access_token", configured: channelCfg.AccessToken.String() != ""},
|
||||
)
|
||||
channelCfg.AccessToken = config.SecureString{}
|
||||
resp.Config = channelCfg
|
||||
case "wecom":
|
||||
channelCfg := cfg.Channels.WeCom
|
||||
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||
channelSecretPresence{key: "secret", configured: channelCfg.Secret.String() != ""},
|
||||
)
|
||||
channelCfg.Secret = config.SecureString{}
|
||||
resp.Config = channelCfg
|
||||
case "whatsapp", "whatsapp_native":
|
||||
resp.Config = cfg.Channels.WhatsApp
|
||||
case "pico":
|
||||
channelCfg := cfg.Channels.Pico
|
||||
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||
channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""},
|
||||
)
|
||||
channelCfg.Token = config.SecureString{}
|
||||
resp.Config = channelCfg
|
||||
case "maixcam":
|
||||
resp.Config = cfg.Channels.MaixCam
|
||||
case "matrix":
|
||||
channelCfg := cfg.Channels.Matrix
|
||||
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||
channelSecretPresence{key: "access_token", configured: channelCfg.AccessToken.String() != ""},
|
||||
)
|
||||
channelCfg.AccessToken = config.SecureString{}
|
||||
resp.Config = channelCfg
|
||||
case "irc":
|
||||
channelCfg := cfg.Channels.IRC
|
||||
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||
channelSecretPresence{key: "password", configured: channelCfg.Password.String() != ""},
|
||||
channelSecretPresence{key: "nickserv_password", configured: channelCfg.NickServPassword.String() != ""},
|
||||
channelSecretPresence{key: "sasl_password", configured: channelCfg.SASLPassword.String() != ""},
|
||||
)
|
||||
channelCfg.Password = config.SecureString{}
|
||||
channelCfg.NickServPassword = config.SecureString{}
|
||||
channelCfg.SASLPassword = config.SecureString{}
|
||||
resp.Config = channelCfg
|
||||
default:
|
||||
resp.Config = map[string]any{}
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func collectConfiguredSecrets(secrets ...channelSecretPresence) []string {
|
||||
configured := make([]string, 0, len(secrets))
|
||||
for _, secret := range secrets {
|
||||
if secret.configured {
|
||||
configured = append(configured, secret.key)
|
||||
}
|
||||
}
|
||||
return configured
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestHandleGetChannelConfig_ReturnsSecretPresenceWithoutLeakingSecrets(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.Channels.Feishu.Enabled = true
|
||||
cfg.Channels.Feishu.AppID = "cli_test_app"
|
||||
cfg.Channels.Feishu.AppSecret = *config.NewSecureString("feishu-secret-from-security")
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/channels/feishu/config", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"GET /api/channels/feishu/config status = %d, want %d, body=%s",
|
||||
rec.Code,
|
||||
http.StatusOK,
|
||||
rec.Body.String(),
|
||||
)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "feishu-secret-from-security") {
|
||||
t.Fatalf("response leaked secret value: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Config map[string]any `json:"config"`
|
||||
ConfiguredSecrets []string `json:"configured_secrets"`
|
||||
ConfigKey string `json:"config_key"`
|
||||
Variant string `json:"variant"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
if got := resp.ConfigKey; got != "feishu" {
|
||||
t.Fatalf("config_key = %q, want %q", got, "feishu")
|
||||
}
|
||||
if got := resp.Config["app_id"]; got != "cli_test_app" {
|
||||
t.Fatalf("config.app_id = %#v, want %q", got, "cli_test_app")
|
||||
}
|
||||
if _, exists := resp.Config["app_secret"]; exists {
|
||||
t.Fatalf("config should omit app_secret, got %#v", resp.Config["app_secret"])
|
||||
}
|
||||
if len(resp.ConfiguredSecrets) != 1 || resp.ConfiguredSecrets[0] != "app_secret" {
|
||||
t.Fatalf("configured_secrets = %#v, want [\"app_secret\"]", resp.ConfiguredSecrets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetChannelConfig_ReturnsNotFoundForUnknownChannel(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/channels/not-a-channel/config", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET /api/channels/not-a-channel/config status = %d, want %d", rec.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user