From 7870a61bb09faf5a99c28a6d43a43256ab79f245 Mon Sep 17 00:00:00 2001 From: lin <94952195+lwz762@users.noreply.github.com> Date: Mon, 4 May 2026 10:09:50 +0800 Subject: [PATCH 1/3] fix(webui): pin Content-Type for /admin static assets http.ServeFile relies on mime.TypeByExtension, which on Windows reads HKEY_CLASSES_ROOT to resolve the MIME type. Third-party software (some editors and registry-cleaning tools) can rewrite ".css" to "application/xml", causing Chrome to refuse the stylesheet and breaking the /admin panel with a fully unstyled page. The same class of bug affects ".js" -> "text/plain" in some setups. Pin the Content-Type by file extension before delegating to ServeFile, covering the WebUI asset surface (css, js, mjs, html, json, map, svg, common image and font formats, wasm). Unknown extensions still fall through to ServeFile's default detection. Tests cover the pinned types, case-insensitive extension matching, and the unknown-extension passthrough. --- internal/webui/handler.go | 41 +++++++++++++ internal/webui/handler_test.go | 102 +++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 internal/webui/handler_test.go 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) + } +} From 76884c0d947c9c31e3614452daadf020cd21790f Mon Sep 17 00:00:00 2001 From: NgoQuocViet2001 Date: Mon, 4 May 2026 21:06:26 +0700 Subject: [PATCH 2/3] feat(admin): remember Vercel sync credentials --- API.en.md | 18 +++- API.md | 18 +++- internal/config/codec.go | 8 ++ internal/config/config.go | 23 ++++ internal/config/config_edge_test.go | 8 ++ internal/httpapi/admin/auth/deps.go | 1 + internal/httpapi/admin/auth/handler_auth.go | 31 +++++- .../httpapi/admin/auth/handler_auth_test.go | 38 +++++++ .../admin/configmgmt/handler_config_import.go | 4 + .../admin/configmgmt/handler_config_read.go | 6 ++ internal/httpapi/admin/shared/helpers.go | 2 + .../httpapi/admin/vercel/handler_vercel.go | 55 ++++++++-- .../admin/vercel/handler_vercel_test.go | 100 ++++++++++++++++++ .../features/vercel/VercelSyncContainer.jsx | 4 + webui/src/features/vercel/VercelSyncForm.jsx | 15 +++ .../src/features/vercel/useVercelSyncState.js | 6 +- webui/src/locales/en.json | 2 + webui/src/locales/zh.json | 2 + 18 files changed, 321 insertions(+), 20 deletions(-) create mode 100644 internal/httpapi/admin/auth/handler_auth_test.go create mode 100644 internal/httpapi/admin/vercel/handler_vercel_test.go 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/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/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 秒。", From 70076c217fc7c90796e52124e14f063a034e2df6 Mon Sep 17 00:00:00 2001 From: "CJACK." <155826701+CJackHwang@users.noreply.github.com> Date: Mon, 4 May 2026 23:16:33 +0800 Subject: [PATCH 3/3] Update VERSION --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index cca25a9..1d068c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.4.1 +4.4.2