diff --git a/API.en.md b/API.en.md index d3ab762..ccb3e74 100644 --- a/API.en.md +++ b/API.en.md @@ -660,11 +660,13 @@ Requires JWT: `Authorization: Bearer ` ### `GET /admin/vercel/config` -Returns Vercel preconfiguration status. +Returns Vercel preconfiguration status. Environment variables are preferred, then the saved `vercel` config block is used as a fallback. ```json { "has_token": true, + "token_preview": "vc****en", + "token_source": "config", "project_id": "prj_xxx", "team_id": null } @@ -685,6 +687,12 @@ Returns sanitized config, including both `keys` and `api_keys`. "env_source_present": true, "env_writeback_enabled": true, "config_path": "/data/config.json", + "vercel": { + "has_token": true, + "token_preview": "vc****en", + "project_id": "prj_xxx", + "team_id": "" + }, "accounts": [ { "identifier": "user@example.com", @@ -1096,11 +1104,11 @@ The success payload includes `sample_id`, `dir`, `meta_path`, and `upstream_path | Field | Required | Notes | | --- | --- | --- | -| `vercel_token` | ❌ | If empty or `__USE_PRECONFIG__`, read env | -| `project_id` | ❌ | Fallback: `VERCEL_PROJECT_ID` | -| `team_id` | ❌ | Fallback: `VERCEL_TEAM_ID` | +| `vercel_token` | ❌ | If empty or `__USE_PRECONFIG__`, read env, then saved config | +| `project_id` | ❌ | Fallback: `VERCEL_PROJECT_ID`, then saved config | +| `team_id` | ❌ | Fallback: `VERCEL_TEAM_ID`, then saved config | | `auto_validate` | ❌ | Default `true` | -| `save_credentials` | ❌ | Default `true` | +| `save_credentials` | ❌ | Default `true`; saves explicitly supplied Vercel credentials for the next sync | **Success response**: diff --git a/API.md b/API.md index 4c44237..bdfe31b 100644 --- a/API.md +++ b/API.md @@ -671,11 +671,13 @@ data: {"type":"message_stop"} ### `GET /admin/vercel/config` -返回 Vercel 预配置状态。 +返回 Vercel 预配置状态。优先读取环境变量,其次回退到已保存的 `vercel` 配置块。 ```json { "has_token": true, + "token_preview": "vc****en", + "token_source": "config", "project_id": "prj_xxx", "team_id": null } @@ -696,6 +698,12 @@ data: {"type":"message_stop"} "env_source_present": true, "env_writeback_enabled": true, "config_path": "/data/config.json", + "vercel": { + "has_token": true, + "token_preview": "vc****en", + "project_id": "prj_xxx", + "team_id": "" + }, "accounts": [ { "identifier": "user@example.com", @@ -1109,11 +1117,11 @@ data: {"type":"message_stop"} | 字段 | 必填 | 说明 | | --- | --- | --- | -| `vercel_token` | ❌ | 空或 `__USE_PRECONFIG__` 则读环境变量 | -| `project_id` | ❌ | 空则读 `VERCEL_PROJECT_ID` | -| `team_id` | ❌ | 空则读 `VERCEL_TEAM_ID` | +| `vercel_token` | ❌ | 空或 `__USE_PRECONFIG__` 则读环境变量,再回退到已保存配置 | +| `project_id` | ❌ | 空则读 `VERCEL_PROJECT_ID`,再回退到已保存配置 | +| `team_id` | ❌ | 空则读 `VERCEL_TEAM_ID`,再回退到已保存配置 | | `auto_validate` | ❌ | 默认 `true` | -| `save_credentials` | ❌ | 默认 `true` | +| `save_credentials` | ❌ | 默认 `true`;保存本次显式填写的 Vercel 凭据,供下次同步复用 | **成功响应**: diff --git a/VERSION b/VERSION index cca25a9..1d068c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.4.1 +4.4.2 diff --git a/internal/config/codec.go b/internal/config/codec.go index 2fa8f74..ac1876e 100644 --- a/internal/config/codec.go +++ b/internal/config/codec.go @@ -48,6 +48,9 @@ func (c Config) MarshalJSON() ([]byte, error) { if c.ThinkingInjection.Enabled != nil || strings.TrimSpace(c.ThinkingInjection.Prompt) != "" { m["thinking_injection"] = c.ThinkingInjection } + if strings.TrimSpace(c.Vercel.Token) != "" || strings.TrimSpace(c.Vercel.ProjectID) != "" || strings.TrimSpace(c.Vercel.TeamID) != "" { + m["vercel"] = NormalizeVercelConfig(c.Vercel) + } if c.VercelSyncHash != "" { m["_vercel_sync_hash"] = c.VercelSyncHash } @@ -125,6 +128,10 @@ func (c *Config) UnmarshalJSON(b []byte) error { if err := json.Unmarshal(v, &c.ThinkingInjection); err != nil { return fmt.Errorf("invalid field %q: %w", k, err) } + case "vercel": + if err := json.Unmarshal(v, &c.Vercel); err != nil { + return fmt.Errorf("invalid field %q: %w", k, err) + } case "_vercel_sync_hash": if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil { return fmt.Errorf("invalid field %q: %w", k, err) @@ -164,6 +171,7 @@ func (c Config) Clone() Config { Enabled: cloneBoolPtr(c.ThinkingInjection.Enabled), Prompt: c.ThinkingInjection.Prompt, }, + Vercel: c.Vercel, VercelSyncHash: c.VercelSyncHash, VercelSyncTime: c.VercelSyncTime, AdditionalFields: map[string]any{}, diff --git a/internal/config/config.go b/internal/config/config.go index b63bd5d..5ed7398 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,7 @@ type Config struct { AutoDelete AutoDeleteConfig `json:"auto_delete"` CurrentInputFile CurrentInputFileConfig `json:"current_input_file,omitempty"` ThinkingInjection ThinkingInjectionConfig `json:"thinking_injection,omitempty"` + Vercel VercelConfig `json:"vercel,omitempty"` VercelSyncHash string `json:"_vercel_sync_hash,omitempty"` VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"` AdditionalFields map[string]any `json:"-"` @@ -99,6 +100,7 @@ func (c *Config) NormalizeCredentials() { c.Accounts[i].Remark = strings.TrimSpace(c.Accounts[i].Remark) } + c.Vercel = NormalizeVercelConfig(c.Vercel) c.normalizeModelAliases() } @@ -175,3 +177,24 @@ type ThinkingInjectionConfig struct { Enabled *bool `json:"enabled,omitempty"` Prompt string `json:"prompt,omitempty"` } + +type VercelConfig struct { + Token string `json:"token,omitempty"` + ProjectID string `json:"project_id,omitempty"` + TeamID string `json:"team_id,omitempty"` +} + +func NormalizeVercelConfig(v VercelConfig) VercelConfig { + return VercelConfig{ + Token: strings.TrimSpace(v.Token), + ProjectID: strings.TrimSpace(v.ProjectID), + TeamID: strings.TrimSpace(v.TeamID), + } +} + +func (c *Config) ClearVercelCredentials() { + if c == nil { + return + } + c.Vercel = VercelConfig{} +} diff --git a/internal/config/config_edge_test.go b/internal/config/config_edge_test.go index b87154e..ceb6faa 100644 --- a/internal/config/config_edge_test.go +++ b/internal/config/config_edge_test.go @@ -173,6 +173,11 @@ func TestConfigJSONRoundtrip(t *testing.T) { Runtime: RuntimeConfig{ TokenRefreshIntervalHours: 12, }, + Vercel: VercelConfig{ + Token: " vercel-token ", + ProjectID: " prj_123 ", + TeamID: " team_123 ", + }, VercelSyncHash: "hash123", VercelSyncTime: 1234567890, AdditionalFields: map[string]any{ @@ -205,6 +210,9 @@ func TestConfigJSONRoundtrip(t *testing.T) { if decoded.AutoDelete.Mode != "single" { t.Fatalf("unexpected auto delete mode: %#v", decoded.AutoDelete.Mode) } + if decoded.Vercel.Token != "vercel-token" || decoded.Vercel.ProjectID != "prj_123" || decoded.Vercel.TeamID != "team_123" { + t.Fatalf("unexpected vercel config: %#v", decoded.Vercel) + } if decoded.VercelSyncHash != "hash123" { t.Fatalf("unexpected vercel sync hash: %q", decoded.VercelSyncHash) } diff --git a/internal/httpapi/admin/auth/deps.go b/internal/httpapi/admin/auth/deps.go index 72063f6..4f8b9c3 100644 --- a/internal/httpapi/admin/auth/deps.go +++ b/internal/httpapi/admin/auth/deps.go @@ -15,5 +15,6 @@ type Handler struct { var writeJSON = adminshared.WriteJSON var intFrom = adminshared.IntFrom +var maskSecretPreview = adminshared.MaskSecretPreview func nilIfEmpty(s string) any { return adminshared.NilIfEmpty(s) } diff --git a/internal/httpapi/admin/auth/handler_auth.go b/internal/httpapi/admin/auth/handler_auth.go index 18ef6fa..d7a04d5 100644 --- a/internal/httpapi/admin/auth/handler_auth.go +++ b/internal/httpapi/admin/auth/handler_auth.go @@ -61,9 +61,34 @@ func (h *Handler) verify(w http.ResponseWriter, r *http.Request) { } func (h *Handler) getVercelConfig(w http.ResponseWriter, _ *http.Request) { + saved := h.Store.Snapshot().Vercel + token, tokenSource := firstConfiguredValue( + [2]string{"env", os.Getenv("VERCEL_TOKEN")}, + [2]string{"config", saved.Token}, + ) + projectID, _ := firstConfiguredValue( + [2]string{"env", os.Getenv("VERCEL_PROJECT_ID")}, + [2]string{"config", saved.ProjectID}, + ) + teamID, _ := firstConfiguredValue( + [2]string{"env", os.Getenv("VERCEL_TEAM_ID")}, + [2]string{"config", saved.TeamID}, + ) writeJSON(w, http.StatusOK, map[string]any{ - "has_token": strings.TrimSpace(os.Getenv("VERCEL_TOKEN")) != "", - "project_id": strings.TrimSpace(os.Getenv("VERCEL_PROJECT_ID")), - "team_id": nilIfEmpty(strings.TrimSpace(os.Getenv("VERCEL_TEAM_ID"))), + "has_token": token != "", + "token_preview": maskSecretPreview(token), + "token_source": nilIfEmpty(tokenSource), + "project_id": projectID, + "team_id": nilIfEmpty(teamID), }) } + +func firstConfiguredValue(values ...[2]string) (string, string) { + for _, pair := range values { + value := strings.TrimSpace(pair[1]) + if value != "" { + return value, strings.TrimSpace(pair[0]) + } + } + return "", "" +} diff --git a/internal/httpapi/admin/auth/handler_auth_test.go b/internal/httpapi/admin/auth/handler_auth_test.go new file mode 100644 index 0000000..e3db5b4 --- /dev/null +++ b/internal/httpapi/admin/auth/handler_auth_test.go @@ -0,0 +1,38 @@ +package auth + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "ds2api/internal/config" +) + +func TestGetVercelConfigFallsBackToSavedConfig(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"vercel":{"token":"saved-token","project_id":"saved-project","team_id":"saved-team"}}`) + t.Setenv("VERCEL_TOKEN", "") + t.Setenv("VERCEL_PROJECT_ID", "") + t.Setenv("VERCEL_TEAM_ID", "") + h := &Handler{Store: config.LoadStore()} + + rec := httptest.NewRecorder() + h.getVercelConfig(rec, httptest.NewRequest(http.MethodGet, "/admin/vercel/config", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["has_token"] != true { + t.Fatalf("expected saved token to be detected: %#v", payload) + } + if payload["token_source"] != "config" || payload["project_id"] != "saved-project" || payload["team_id"] != "saved-team" { + t.Fatalf("unexpected preconfig payload: %#v", payload) + } + if payload["token_preview"] == "saved-token" { + t.Fatal("token preview leaked the full token") + } +} diff --git a/internal/httpapi/admin/configmgmt/handler_config_import.go b/internal/httpapi/admin/configmgmt/handler_config_import.go index cd1d860..0060591 100644 --- a/internal/httpapi/admin/configmgmt/handler_config_import.go +++ b/internal/httpapi/admin/configmgmt/handler_config_import.go @@ -94,6 +94,10 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(incoming.Embeddings.Provider) != "" { next.Embeddings.Provider = incoming.Embeddings.Provider } + incomingVercel := config.NormalizeVercelConfig(incoming.Vercel) + if strings.TrimSpace(incomingVercel.Token) != "" || strings.TrimSpace(incomingVercel.ProjectID) != "" || strings.TrimSpace(incomingVercel.TeamID) != "" { + next.Vercel = incomingVercel + } if strings.TrimSpace(incoming.Admin.PasswordHash) != "" { next.Admin.PasswordHash = incoming.Admin.PasswordHash } diff --git a/internal/httpapi/admin/configmgmt/handler_config_read.go b/internal/httpapi/admin/configmgmt/handler_config_read.go index e039bd1..74157f9 100644 --- a/internal/httpapi/admin/configmgmt/handler_config_read.go +++ b/internal/httpapi/admin/configmgmt/handler_config_read.go @@ -19,6 +19,12 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) { "env_writeback_enabled": h.Store.IsEnvWritebackEnabled(), "config_path": h.Store.ConfigPath(), "model_aliases": snap.ModelAliases, + "vercel": map[string]any{ + "has_token": strings.TrimSpace(snap.Vercel.Token) != "", + "token_preview": maskSecretPreview(snap.Vercel.Token), + "project_id": snap.Vercel.ProjectID, + "team_id": snap.Vercel.TeamID, + }, } accounts := make([]map[string]any, 0, len(snap.Accounts)) for _, acc := range snap.Accounts { diff --git a/internal/httpapi/admin/shared/helpers.go b/internal/httpapi/admin/shared/helpers.go index 93b6937..bc78abb 100644 --- a/internal/httpapi/admin/shared/helpers.go +++ b/internal/httpapi/admin/shared/helpers.go @@ -78,6 +78,7 @@ func ComputeSyncHash(store ConfigStore) string { } snap := store.Snapshot().Clone() snap.ClearAccountTokens() + snap.ClearVercelCredentials() snap.VercelSyncHash = "" snap.VercelSyncTime = 0 b, _ := json.Marshal(snap) @@ -93,6 +94,7 @@ func SyncHashForJSON(s string) string { cfg.VercelSyncHash = "" cfg.VercelSyncTime = 0 cfg.ClearAccountTokens() + cfg.ClearVercelCredentials() b, err := json.Marshal(cfg) if err != nil { return "" diff --git a/internal/httpapi/admin/vercel/handler_vercel.go b/internal/httpapi/admin/vercel/handler_vercel.go index cfd13e1..4b56df4 100644 --- a/internal/httpapi/admin/vercel/handler_vercel.go +++ b/internal/httpapi/admin/vercel/handler_vercel.go @@ -23,7 +23,7 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"}) return } - opts, err := parseVercelSyncOptions(req) + opts, err := parseVercelSyncOptions(req, h.Store.Snapshot().Vercel) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()}) return @@ -50,6 +50,12 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) { return } savedCreds := h.saveVercelProjectCredentials(r.Context(), client, opts, params, headers, envs) + credentialsWarning := "" + if saved, err := h.saveLocalVercelCredentials(opts); err == nil && saved { + savedCreds = append(savedCreds, "config.vercel") + } else if err != nil { + credentialsWarning = "保存 Vercel 凭据到本地配置失败: " + err.Error() + } manual, deployURL := triggerVercelDeployment(r.Context(), client, opts.ProjectID, params, headers) _ = h.Store.SetVercelSync(syncHashForJSON(cfgJSON), time.Now().Unix()) result := map[string]any{"success": true, "validated_accounts": validated} @@ -66,6 +72,9 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) { if len(savedCreds) > 0 { result["saved_credentials"] = savedCreds } + if credentialsWarning != "" { + result["credentials_warning"] = credentialsWarning + } writeJSON(w, http.StatusOK, result) } @@ -78,7 +87,7 @@ type vercelSyncOptions struct { UsePreconfig bool } -func parseVercelSyncOptions(req map[string]any) (vercelSyncOptions, error) { +func parseVercelSyncOptions(req map[string]any, saved config.VercelConfig) (vercelSyncOptions, error) { vercelToken, _ := req["vercel_token"].(string) projectID, _ := req["project_id"].(string) teamID, _ := req["team_id"].(string) @@ -92,13 +101,13 @@ func parseVercelSyncOptions(req map[string]any) (vercelSyncOptions, error) { } usePreconfig := vercelToken == "__USE_PRECONFIG__" || strings.TrimSpace(vercelToken) == "" if usePreconfig { - vercelToken = strings.TrimSpace(os.Getenv("VERCEL_TOKEN")) + vercelToken = firstNonEmpty(os.Getenv("VERCEL_TOKEN"), saved.Token) } if strings.TrimSpace(projectID) == "" { - projectID = strings.TrimSpace(os.Getenv("VERCEL_PROJECT_ID")) + projectID = firstNonEmpty(os.Getenv("VERCEL_PROJECT_ID"), saved.ProjectID) } if strings.TrimSpace(teamID) == "" { - teamID = strings.TrimSpace(os.Getenv("VERCEL_TEAM_ID")) + teamID = firstNonEmpty(os.Getenv("VERCEL_TEAM_ID"), saved.TeamID) } vercelToken = strings.TrimSpace(vercelToken) projectID = strings.TrimSpace(projectID) @@ -116,6 +125,15 @@ func parseVercelSyncOptions(req map[string]any) (vercelSyncOptions, error) { }, nil } +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + func buildVercelParams(teamID string) url.Values { params := url.Values{} if strings.TrimSpace(teamID) != "" { @@ -178,6 +196,25 @@ func (h *Handler) saveVercelProjectCredentials(ctx context.Context, client *http return saved } +func (h *Handler) saveLocalVercelCredentials(opts vercelSyncOptions) (bool, error) { + if !opts.SaveCreds { + return false, nil + } + err := h.Store.Update(func(c *config.Config) error { + token := opts.VercelToken + if opts.UsePreconfig { + token = c.Vercel.Token + } + c.Vercel = config.NormalizeVercelConfig(config.VercelConfig{ + Token: token, + ProjectID: opts.ProjectID, + TeamID: opts.TeamID, + }) + return nil + }) + return err == nil, err +} + func triggerVercelDeployment(ctx context.Context, client *http.Client, projectID string, params url.Values, headers map[string]string) (bool, string) { projectResp, status, _ := vercelRequest(ctx, client, http.MethodGet, "https://api.vercel.com/v9/projects/"+projectID, params, headers, nil) if status != http.StatusOK { @@ -243,7 +280,7 @@ func (h *Handler) vercelStatus(w http.ResponseWriter, r *http.Request) { func (h *Handler) exportSyncConfig(req map[string]any) (string, string, error) { override, ok := req["config_override"] if !ok || override == nil { - return h.Store.ExportJSONAndBase64() + return encodeVercelSyncConfig(h.Store.Snapshot()) } raw, err := json.Marshal(override) if err != nil { @@ -253,8 +290,13 @@ func (h *Handler) exportSyncConfig(req map[string]any) (string, string, error) { if err := json.Unmarshal(raw, &cfg); err != nil { return "", "", err } + return encodeVercelSyncConfig(cfg) +} + +func encodeVercelSyncConfig(cfg config.Config) (string, string, error) { cfg.DropInvalidAccounts() cfg.ClearAccountTokens() + cfg.ClearVercelCredentials() cfg.VercelSyncHash = "" cfg.VercelSyncTime = 0 b, err := json.Marshal(cfg) @@ -272,6 +314,7 @@ func syncHashForJSON(s string) string { cfg.VercelSyncHash = "" cfg.VercelSyncTime = 0 cfg.ClearAccountTokens() + cfg.ClearVercelCredentials() b, err := json.Marshal(cfg) if err != nil { return "" diff --git a/internal/httpapi/admin/vercel/handler_vercel_test.go b/internal/httpapi/admin/vercel/handler_vercel_test.go new file mode 100644 index 0000000..66aa618 --- /dev/null +++ b/internal/httpapi/admin/vercel/handler_vercel_test.go @@ -0,0 +1,100 @@ +package vercel + +import ( + "encoding/json" + "strings" + "testing" + + "ds2api/internal/config" +) + +func TestParseVercelSyncOptionsFallsBackToSavedConfig(t *testing.T) { + t.Setenv("VERCEL_TOKEN", "") + t.Setenv("VERCEL_PROJECT_ID", "") + t.Setenv("VERCEL_TEAM_ID", "") + + opts, err := parseVercelSyncOptions(map[string]any{ + "vercel_token": "__USE_PRECONFIG__", + }, config.VercelConfig{ + Token: " saved-token ", + ProjectID: " saved-project ", + TeamID: " saved-team ", + }) + if err != nil { + t.Fatalf("parse options error: %v", err) + } + if opts.VercelToken != "saved-token" || opts.ProjectID != "saved-project" || opts.TeamID != "saved-team" { + t.Fatalf("unexpected options: %#v", opts) + } + if !opts.UsePreconfig { + t.Fatal("expected preconfig mode") + } +} + +func TestSaveLocalVercelCredentialsStoresExplicitInput(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"]}`) + store := config.LoadStore() + h := &Handler{Store: store} + + saved, err := h.saveLocalVercelCredentials(vercelSyncOptions{ + VercelToken: " token ", + ProjectID: " project ", + TeamID: " team ", + SaveCreds: true, + }) + if err != nil { + t.Fatalf("save local credentials error: %v", err) + } + if !saved { + t.Fatal("expected credentials to be saved") + } + got := store.Snapshot().Vercel + if got.Token != "token" || got.ProjectID != "project" || got.TeamID != "team" { + t.Fatalf("unexpected saved credentials: %#v", got) + } +} + +func TestSaveLocalVercelCredentialsPreservesPreconfiguredTokenAndUpdatesProject(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"vercel":{"token":"saved-token","project_id":"old-project","team_id":"old-team"}}`) + store := config.LoadStore() + h := &Handler{Store: store} + + saved, err := h.saveLocalVercelCredentials(vercelSyncOptions{ + VercelToken: "resolved-token", + ProjectID: "new-project", + TeamID: "new-team", + SaveCreds: true, + UsePreconfig: true, + }) + if err != nil { + t.Fatalf("save local credentials error: %v", err) + } + if !saved { + t.Fatal("expected project/team updates to be saved") + } + got := store.Snapshot().Vercel + if got.Token != "saved-token" || got.ProjectID != "new-project" || got.TeamID != "new-team" { + t.Fatalf("unexpected saved credentials: %#v", got) + } +} + +func TestExportSyncConfigStripsSavedVercelCredentials(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"vercel":{"token":"secret-token","project_id":"project","team_id":"team"}}`) + store := config.LoadStore() + h := &Handler{Store: store} + + jsonStr, _, err := h.exportSyncConfig(map[string]any{}) + if err != nil { + t.Fatalf("export sync config error: %v", err) + } + if strings.Contains(jsonStr, "secret-token") || strings.Contains(jsonStr, `"vercel"`) { + t.Fatalf("expected sync export to strip Vercel credentials, got %s", jsonStr) + } + var exported config.Config + if err := json.Unmarshal([]byte(jsonStr), &exported); err != nil { + t.Fatalf("exported config is invalid JSON: %v", err) + } + if len(exported.Keys) != 1 || exported.Keys[0] != "k1" { + t.Fatalf("unexpected exported config: %#v", exported) + } +} diff --git a/internal/webui/handler.go b/internal/webui/handler.go index ade79e4..da9649d 100644 --- a/internal/webui/handler.go +++ b/internal/webui/handler.go @@ -55,6 +55,45 @@ func (h *Handler) admin(w http.ResponseWriter, r *http.Request) { http.Error(w, "WebUI not built. Run `cd webui && npm run build` first.", http.StatusNotFound) } +// staticContentTypes pins the Content-Type of common WebUI assets so we do not +// rely on mime.TypeByExtension, which on Windows consults the registry and can +// return the wrong type (e.g. application/xml for .css) when third-party +// software has overwritten HKEY_CLASSES_ROOT entries. Browsers strictly enforce +// stylesheet/script MIME types and will refuse to apply a misidentified asset, +// breaking the /admin page on affected machines. +var staticContentTypes = map[string]string{ + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".htm": "text/html; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".txt": "text/plain; charset=utf-8", + ".wasm": "application/wasm", +} + +// setStaticContentType pins the response Content-Type by file extension so that +// http.ServeFile does not fall back to mime.TypeByExtension (which on Windows +// reads the registry and may return an incorrect type). +func setStaticContentType(w http.ResponseWriter, fullPath string) { + ext := strings.ToLower(filepath.Ext(fullPath)) + if ct, ok := staticContentTypes[ext]; ok { + w.Header().Set("Content-Type", ct) + } +} + func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDir string) { path := strings.TrimPrefix(r.URL.Path, "/admin") path = strings.TrimPrefix(path, "/") @@ -70,6 +109,7 @@ func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDi } else { w.Header().Set("Cache-Control", "no-store, must-revalidate") } + setStaticContentType(w, full) http.ServeFile(w, r, full) return } @@ -82,6 +122,7 @@ func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDi return } w.Header().Set("Cache-Control", "no-store, must-revalidate") + setStaticContentType(w, index) http.ServeFile(w, r, index) } diff --git a/internal/webui/handler_test.go b/internal/webui/handler_test.go new file mode 100644 index 0000000..99832c5 --- /dev/null +++ b/internal/webui/handler_test.go @@ -0,0 +1,102 @@ +package webui + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestServeFromDiskPinsContentType ensures static admin assets are returned +// with an explicit, RFC-compliant Content-Type that does not depend on +// mime.TypeByExtension. On Windows mime.TypeByExtension consults the registry +// (HKEY_CLASSES_ROOT) which third-party software can corrupt — for example +// installing certain editors rewrites .css to application/xml — and Chrome +// then refuses to apply a stylesheet whose Content-Type is not text/css, +// breaking the /admin page entirely. Pinning the type by file extension makes +// the response deterministic across operating systems and machine state. +func TestServeFromDiskPinsContentType(t *testing.T) { + staticDir := t.TempDir() + assetsDir := filepath.Join(staticDir, "assets") + if err := os.MkdirAll(assetsDir, 0o755); err != nil { + t.Fatalf("mkdir assets: %v", err) + } + + files := map[string]string{ + "index.html": "", + "assets/index.css": "body{}", + "assets/index.js": "console.log(1)", + "assets/icon.svg": ``, + "assets/source.js.map": `{"version":3}`, + } + for rel, body := range files { + full := filepath.Join(staticDir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", rel, err) + } + if err := os.WriteFile(full, []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + + h := &Handler{StaticDir: staticDir} + + cases := []struct { + urlPath string + wantPrefix string + wantCacheCtl string + }{ + {"/admin/assets/index.css", "text/css", "public, max-age=31536000, immutable"}, + {"/admin/assets/index.js", "text/javascript", "public, max-age=31536000, immutable"}, + {"/admin/assets/icon.svg", "image/svg+xml", "public, max-age=31536000, immutable"}, + {"/admin/assets/source.js.map", "application/json", "public, max-age=31536000, immutable"}, + // "/admin/index.html" is intentionally omitted: http.ServeFile redirects + // requests for index.html to "./", matching Go's net/http behavior. The + // route the SPA actually lands on is "/admin/" below. + {"/admin/", "text/html", "no-store, must-revalidate"}, + } + + for _, tc := range cases { + t.Run(tc.urlPath, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.urlPath, nil) + rec := httptest.NewRecorder() + h.serveFromDisk(rec, req, staticDir) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + ct := rec.Header().Get("Content-Type") + if !strings.HasPrefix(ct, tc.wantPrefix) { + t.Fatalf("Content-Type = %q, want prefix %q", ct, tc.wantPrefix) + } + if got := rec.Header().Get("Cache-Control"); got != tc.wantCacheCtl { + t.Fatalf("Cache-Control = %q, want %q", got, tc.wantCacheCtl) + } + }) + } +} + +// TestSetStaticContentTypeUnknownExtensionFallsThrough verifies that unknown +// extensions leave the Content-Type header unset, so http.ServeFile can apply +// its own detection (sniffing or mime.TypeByExtension) for cases the pinned +// table does not cover. +func TestSetStaticContentTypeUnknownExtensionFallsThrough(t *testing.T) { + rec := httptest.NewRecorder() + setStaticContentType(rec, "/tmp/data.unknownext") + if got := rec.Header().Get("Content-Type"); got != "" { + t.Fatalf("Content-Type = %q, want empty for unknown extension", got) + } +} + +// TestSetStaticContentTypeIsCaseInsensitive guards against a regression where +// uppercase extensions (e.g. STYLE.CSS shipped from some build pipelines) +// would bypass the pinned table and fall back to the registry on Windows. +func TestSetStaticContentTypeIsCaseInsensitive(t *testing.T) { + rec := httptest.NewRecorder() + setStaticContentType(rec, "/tmp/STYLE.CSS") + if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/css") { + t.Fatalf("Content-Type = %q, want text/css prefix", got) + } +} diff --git a/webui/src/features/vercel/VercelSyncContainer.jsx b/webui/src/features/vercel/VercelSyncContainer.jsx index 5acfaa8..493ae4d 100644 --- a/webui/src/features/vercel/VercelSyncContainer.jsx +++ b/webui/src/features/vercel/VercelSyncContainer.jsx @@ -15,6 +15,8 @@ export default function VercelSyncContainer({ onMessage, authFetch, isVercel = f setProjectId, teamId, setTeamId, + saveCredentials, + setSaveCredentials, loading, result, preconfig, @@ -46,6 +48,8 @@ export default function VercelSyncContainer({ onMessage, authFetch, isVercel = f setProjectId={setProjectId} teamId={teamId} setTeamId={setTeamId} + saveCredentials={saveCredentials} + setSaveCredentials={setSaveCredentials} loading={loading} onSync={handleSync} /> diff --git a/webui/src/features/vercel/VercelSyncForm.jsx b/webui/src/features/vercel/VercelSyncForm.jsx index a394435..67cf748 100644 --- a/webui/src/features/vercel/VercelSyncForm.jsx +++ b/webui/src/features/vercel/VercelSyncForm.jsx @@ -14,6 +14,8 @@ export default function VercelSyncForm({ setProjectId, teamId, setTeamId, + saveCredentials, + setSaveCredentials, loading, onSync, }) { @@ -124,6 +126,19 @@ export default function VercelSyncForm({ onChange={e => setTeamId(e.target.value)} /> + +
diff --git a/webui/src/features/vercel/useVercelSyncState.js b/webui/src/features/vercel/useVercelSyncState.js index 2db27b1..ed0c539 100644 --- a/webui/src/features/vercel/useVercelSyncState.js +++ b/webui/src/features/vercel/useVercelSyncState.js @@ -12,6 +12,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false }) const [vercelToken, setVercelToken] = useState('') const [projectId, setProjectId] = useState('') const [teamId, setTeamId] = useState('') + const [saveCredentials, setSaveCredentials] = useState(true) const [loading, setLoading] = useState(false) const [result, setResult] = useState(null) const [preconfig, setPreconfig] = useState(null) @@ -117,6 +118,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false }) vercel_token: tokenToUse, project_id: projectId, team_id: teamId || undefined, + save_credentials: saveCredentials, }), }) const data = await res.json() @@ -133,7 +135,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false }) } finally { setLoading(false) } - }, [apiFetch, fetchSyncStatus, onMessage, preconfig?.has_token, projectId, t, teamId, vercelToken]) + }, [apiFetch, fetchSyncStatus, onMessage, preconfig?.has_token, projectId, saveCredentials, t, teamId, vercelToken]) return { vercelToken, @@ -142,6 +144,8 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false }) setProjectId, teamId, setTeamId, + saveCredentials, + setSaveCredentials, loading, result, preconfig, diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index 3e609d5..c0db122 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -462,6 +462,8 @@ "projectIdHint": "Find it in Project Settings → General.", "teamIdLabel": "Team ID", "optional": "optional", + "saveCredentials": "Remember Vercel credentials", + "saveCredentialsHint": "Save the token, project ID, and team ID for the next sync.", "syncing": "Syncing...", "syncRedeploy": "Sync & redeploy", "redeployHint": "This triggers a Vercel redeploy and usually takes 30–60 seconds.", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index 596824c..7508a39 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -462,6 +462,8 @@ "projectIdHint": "可在项目设置 (Project Settings) → 常规 (General) 中找到", "teamIdLabel": "团队 ID", "optional": "可选", + "saveCredentials": "记住 Vercel 凭据", + "saveCredentialsHint": "保存访问令牌、项目 ID 和团队 ID,供下次同步直接复用。", "syncing": "正在同步...", "syncRedeploy": "同步并重新部署", "redeployHint": "这将触发 Vercel 的重新部署,大约需要 30-60 秒。",