mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-05 00:45:29 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70076c217f | ||
|
|
554fae6b3f | ||
|
|
76884c0d94 | ||
|
|
269d7cd8f9 | ||
|
|
7870a61bb0 | ||
|
|
ec4f178908 |
18
API.en.md
18
API.en.md
@@ -660,11 +660,13 @@ Requires JWT: `Authorization: Bearer <jwt>`
|
|||||||
|
|
||||||
### `GET /admin/vercel/config`
|
### `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
|
```json
|
||||||
{
|
{
|
||||||
"has_token": true,
|
"has_token": true,
|
||||||
|
"token_preview": "vc****en",
|
||||||
|
"token_source": "config",
|
||||||
"project_id": "prj_xxx",
|
"project_id": "prj_xxx",
|
||||||
"team_id": null
|
"team_id": null
|
||||||
}
|
}
|
||||||
@@ -685,6 +687,12 @@ Returns sanitized config, including both `keys` and `api_keys`.
|
|||||||
"env_source_present": true,
|
"env_source_present": true,
|
||||||
"env_writeback_enabled": true,
|
"env_writeback_enabled": true,
|
||||||
"config_path": "/data/config.json",
|
"config_path": "/data/config.json",
|
||||||
|
"vercel": {
|
||||||
|
"has_token": true,
|
||||||
|
"token_preview": "vc****en",
|
||||||
|
"project_id": "prj_xxx",
|
||||||
|
"team_id": ""
|
||||||
|
},
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
"identifier": "user@example.com",
|
"identifier": "user@example.com",
|
||||||
@@ -1096,11 +1104,11 @@ The success payload includes `sample_id`, `dir`, `meta_path`, and `upstream_path
|
|||||||
|
|
||||||
| Field | Required | Notes |
|
| Field | Required | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `vercel_token` | ❌ | If empty or `__USE_PRECONFIG__`, read env |
|
| `vercel_token` | ❌ | If empty or `__USE_PRECONFIG__`, read env, then saved config |
|
||||||
| `project_id` | ❌ | Fallback: `VERCEL_PROJECT_ID` |
|
| `project_id` | ❌ | Fallback: `VERCEL_PROJECT_ID`, then saved config |
|
||||||
| `team_id` | ❌ | Fallback: `VERCEL_TEAM_ID` |
|
| `team_id` | ❌ | Fallback: `VERCEL_TEAM_ID`, then saved config |
|
||||||
| `auto_validate` | ❌ | Default `true` |
|
| `auto_validate` | ❌ | Default `true` |
|
||||||
| `save_credentials` | ❌ | Default `true` |
|
| `save_credentials` | ❌ | Default `true`; saves explicitly supplied Vercel credentials for the next sync |
|
||||||
|
|
||||||
**Success response**:
|
**Success response**:
|
||||||
|
|
||||||
|
|||||||
18
API.md
18
API.md
@@ -671,11 +671,13 @@ data: {"type":"message_stop"}
|
|||||||
|
|
||||||
### `GET /admin/vercel/config`
|
### `GET /admin/vercel/config`
|
||||||
|
|
||||||
返回 Vercel 预配置状态。
|
返回 Vercel 预配置状态。优先读取环境变量,其次回退到已保存的 `vercel` 配置块。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"has_token": true,
|
"has_token": true,
|
||||||
|
"token_preview": "vc****en",
|
||||||
|
"token_source": "config",
|
||||||
"project_id": "prj_xxx",
|
"project_id": "prj_xxx",
|
||||||
"team_id": null
|
"team_id": null
|
||||||
}
|
}
|
||||||
@@ -696,6 +698,12 @@ data: {"type":"message_stop"}
|
|||||||
"env_source_present": true,
|
"env_source_present": true,
|
||||||
"env_writeback_enabled": true,
|
"env_writeback_enabled": true,
|
||||||
"config_path": "/data/config.json",
|
"config_path": "/data/config.json",
|
||||||
|
"vercel": {
|
||||||
|
"has_token": true,
|
||||||
|
"token_preview": "vc****en",
|
||||||
|
"project_id": "prj_xxx",
|
||||||
|
"team_id": ""
|
||||||
|
},
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
"identifier": "user@example.com",
|
"identifier": "user@example.com",
|
||||||
@@ -1109,11 +1117,11 @@ data: {"type":"message_stop"}
|
|||||||
|
|
||||||
| 字段 | 必填 | 说明 |
|
| 字段 | 必填 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `vercel_token` | ❌ | 空或 `__USE_PRECONFIG__` 则读环境变量 |
|
| `vercel_token` | ❌ | 空或 `__USE_PRECONFIG__` 则读环境变量,再回退到已保存配置 |
|
||||||
| `project_id` | ❌ | 空则读 `VERCEL_PROJECT_ID` |
|
| `project_id` | ❌ | 空则读 `VERCEL_PROJECT_ID`,再回退到已保存配置 |
|
||||||
| `team_id` | ❌ | 空则读 `VERCEL_TEAM_ID` |
|
| `team_id` | ❌ | 空则读 `VERCEL_TEAM_ID`,再回退到已保存配置 |
|
||||||
| `auto_validate` | ❌ | 默认 `true` |
|
| `auto_validate` | ❌ | 默认 `true` |
|
||||||
| `save_credentials` | ❌ | 默认 `true` |
|
| `save_credentials` | ❌ | 默认 `true`;保存本次显式填写的 Vercel 凭据,供下次同步复用 |
|
||||||
|
|
||||||
**成功响应**:
|
**成功响应**:
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ func (c Config) MarshalJSON() ([]byte, error) {
|
|||||||
if c.ThinkingInjection.Enabled != nil || strings.TrimSpace(c.ThinkingInjection.Prompt) != "" {
|
if c.ThinkingInjection.Enabled != nil || strings.TrimSpace(c.ThinkingInjection.Prompt) != "" {
|
||||||
m["thinking_injection"] = c.ThinkingInjection
|
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 != "" {
|
if c.VercelSyncHash != "" {
|
||||||
m["_vercel_sync_hash"] = 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 {
|
if err := json.Unmarshal(v, &c.ThinkingInjection); err != nil {
|
||||||
return fmt.Errorf("invalid field %q: %w", k, err)
|
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":
|
case "_vercel_sync_hash":
|
||||||
if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil {
|
if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil {
|
||||||
return fmt.Errorf("invalid field %q: %w", k, err)
|
return fmt.Errorf("invalid field %q: %w", k, err)
|
||||||
@@ -164,6 +171,7 @@ func (c Config) Clone() Config {
|
|||||||
Enabled: cloneBoolPtr(c.ThinkingInjection.Enabled),
|
Enabled: cloneBoolPtr(c.ThinkingInjection.Enabled),
|
||||||
Prompt: c.ThinkingInjection.Prompt,
|
Prompt: c.ThinkingInjection.Prompt,
|
||||||
},
|
},
|
||||||
|
Vercel: c.Vercel,
|
||||||
VercelSyncHash: c.VercelSyncHash,
|
VercelSyncHash: c.VercelSyncHash,
|
||||||
VercelSyncTime: c.VercelSyncTime,
|
VercelSyncTime: c.VercelSyncTime,
|
||||||
AdditionalFields: map[string]any{},
|
AdditionalFields: map[string]any{},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type Config struct {
|
|||||||
AutoDelete AutoDeleteConfig `json:"auto_delete"`
|
AutoDelete AutoDeleteConfig `json:"auto_delete"`
|
||||||
CurrentInputFile CurrentInputFileConfig `json:"current_input_file,omitempty"`
|
CurrentInputFile CurrentInputFileConfig `json:"current_input_file,omitempty"`
|
||||||
ThinkingInjection ThinkingInjectionConfig `json:"thinking_injection,omitempty"`
|
ThinkingInjection ThinkingInjectionConfig `json:"thinking_injection,omitempty"`
|
||||||
|
Vercel VercelConfig `json:"vercel,omitempty"`
|
||||||
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
|
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
|
||||||
VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
|
VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
|
||||||
AdditionalFields map[string]any `json:"-"`
|
AdditionalFields map[string]any `json:"-"`
|
||||||
@@ -99,6 +100,7 @@ func (c *Config) NormalizeCredentials() {
|
|||||||
c.Accounts[i].Remark = strings.TrimSpace(c.Accounts[i].Remark)
|
c.Accounts[i].Remark = strings.TrimSpace(c.Accounts[i].Remark)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.Vercel = NormalizeVercelConfig(c.Vercel)
|
||||||
c.normalizeModelAliases()
|
c.normalizeModelAliases()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,3 +177,24 @@ type ThinkingInjectionConfig struct {
|
|||||||
Enabled *bool `json:"enabled,omitempty"`
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
Prompt string `json:"prompt,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{}
|
||||||
|
}
|
||||||
|
|||||||
@@ -173,6 +173,11 @@ func TestConfigJSONRoundtrip(t *testing.T) {
|
|||||||
Runtime: RuntimeConfig{
|
Runtime: RuntimeConfig{
|
||||||
TokenRefreshIntervalHours: 12,
|
TokenRefreshIntervalHours: 12,
|
||||||
},
|
},
|
||||||
|
Vercel: VercelConfig{
|
||||||
|
Token: " vercel-token ",
|
||||||
|
ProjectID: " prj_123 ",
|
||||||
|
TeamID: " team_123 ",
|
||||||
|
},
|
||||||
VercelSyncHash: "hash123",
|
VercelSyncHash: "hash123",
|
||||||
VercelSyncTime: 1234567890,
|
VercelSyncTime: 1234567890,
|
||||||
AdditionalFields: map[string]any{
|
AdditionalFields: map[string]any{
|
||||||
@@ -205,6 +210,9 @@ func TestConfigJSONRoundtrip(t *testing.T) {
|
|||||||
if decoded.AutoDelete.Mode != "single" {
|
if decoded.AutoDelete.Mode != "single" {
|
||||||
t.Fatalf("unexpected auto delete mode: %#v", decoded.AutoDelete.Mode)
|
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" {
|
if decoded.VercelSyncHash != "hash123" {
|
||||||
t.Fatalf("unexpected vercel sync hash: %q", decoded.VercelSyncHash)
|
t.Fatalf("unexpected vercel sync hash: %q", decoded.VercelSyncHash)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ type Handler struct {
|
|||||||
|
|
||||||
var writeJSON = adminshared.WriteJSON
|
var writeJSON = adminshared.WriteJSON
|
||||||
var intFrom = adminshared.IntFrom
|
var intFrom = adminshared.IntFrom
|
||||||
|
var maskSecretPreview = adminshared.MaskSecretPreview
|
||||||
|
|
||||||
func nilIfEmpty(s string) any { return adminshared.NilIfEmpty(s) }
|
func nilIfEmpty(s string) any { return adminshared.NilIfEmpty(s) }
|
||||||
|
|||||||
@@ -61,9 +61,34 @@ func (h *Handler) verify(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) getVercelConfig(w http.ResponseWriter, _ *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{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"has_token": strings.TrimSpace(os.Getenv("VERCEL_TOKEN")) != "",
|
"has_token": token != "",
|
||||||
"project_id": strings.TrimSpace(os.Getenv("VERCEL_PROJECT_ID")),
|
"token_preview": maskSecretPreview(token),
|
||||||
"team_id": nilIfEmpty(strings.TrimSpace(os.Getenv("VERCEL_TEAM_ID"))),
|
"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 "", ""
|
||||||
|
}
|
||||||
|
|||||||
38
internal/httpapi/admin/auth/handler_auth_test.go
Normal file
38
internal/httpapi/admin/auth/handler_auth_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,10 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
|
|||||||
if strings.TrimSpace(incoming.Embeddings.Provider) != "" {
|
if strings.TrimSpace(incoming.Embeddings.Provider) != "" {
|
||||||
next.Embeddings.Provider = 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) != "" {
|
if strings.TrimSpace(incoming.Admin.PasswordHash) != "" {
|
||||||
next.Admin.PasswordHash = incoming.Admin.PasswordHash
|
next.Admin.PasswordHash = incoming.Admin.PasswordHash
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
|
|||||||
"env_writeback_enabled": h.Store.IsEnvWritebackEnabled(),
|
"env_writeback_enabled": h.Store.IsEnvWritebackEnabled(),
|
||||||
"config_path": h.Store.ConfigPath(),
|
"config_path": h.Store.ConfigPath(),
|
||||||
"model_aliases": snap.ModelAliases,
|
"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))
|
accounts := make([]map[string]any, 0, len(snap.Accounts))
|
||||||
for _, acc := range snap.Accounts {
|
for _, acc := range snap.Accounts {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ func ComputeSyncHash(store ConfigStore) string {
|
|||||||
}
|
}
|
||||||
snap := store.Snapshot().Clone()
|
snap := store.Snapshot().Clone()
|
||||||
snap.ClearAccountTokens()
|
snap.ClearAccountTokens()
|
||||||
|
snap.ClearVercelCredentials()
|
||||||
snap.VercelSyncHash = ""
|
snap.VercelSyncHash = ""
|
||||||
snap.VercelSyncTime = 0
|
snap.VercelSyncTime = 0
|
||||||
b, _ := json.Marshal(snap)
|
b, _ := json.Marshal(snap)
|
||||||
@@ -93,6 +94,7 @@ func SyncHashForJSON(s string) string {
|
|||||||
cfg.VercelSyncHash = ""
|
cfg.VercelSyncHash = ""
|
||||||
cfg.VercelSyncTime = 0
|
cfg.VercelSyncTime = 0
|
||||||
cfg.ClearAccountTokens()
|
cfg.ClearAccountTokens()
|
||||||
|
cfg.ClearVercelCredentials()
|
||||||
b, err := json.Marshal(cfg)
|
b, err := json.Marshal(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"})
|
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
opts, err := parseVercelSyncOptions(req)
|
opts, err := parseVercelSyncOptions(req, h.Store.Snapshot().Vercel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -50,6 +50,12 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
savedCreds := h.saveVercelProjectCredentials(r.Context(), client, opts, params, headers, envs)
|
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)
|
manual, deployURL := triggerVercelDeployment(r.Context(), client, opts.ProjectID, params, headers)
|
||||||
_ = h.Store.SetVercelSync(syncHashForJSON(cfgJSON), time.Now().Unix())
|
_ = h.Store.SetVercelSync(syncHashForJSON(cfgJSON), time.Now().Unix())
|
||||||
result := map[string]any{"success": true, "validated_accounts": validated}
|
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 {
|
if len(savedCreds) > 0 {
|
||||||
result["saved_credentials"] = savedCreds
|
result["saved_credentials"] = savedCreds
|
||||||
}
|
}
|
||||||
|
if credentialsWarning != "" {
|
||||||
|
result["credentials_warning"] = credentialsWarning
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, result)
|
writeJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +87,7 @@ type vercelSyncOptions struct {
|
|||||||
UsePreconfig bool
|
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)
|
vercelToken, _ := req["vercel_token"].(string)
|
||||||
projectID, _ := req["project_id"].(string)
|
projectID, _ := req["project_id"].(string)
|
||||||
teamID, _ := req["team_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) == ""
|
usePreconfig := vercelToken == "__USE_PRECONFIG__" || strings.TrimSpace(vercelToken) == ""
|
||||||
if usePreconfig {
|
if usePreconfig {
|
||||||
vercelToken = strings.TrimSpace(os.Getenv("VERCEL_TOKEN"))
|
vercelToken = firstNonEmpty(os.Getenv("VERCEL_TOKEN"), saved.Token)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(projectID) == "" {
|
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) == "" {
|
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)
|
vercelToken = strings.TrimSpace(vercelToken)
|
||||||
projectID = strings.TrimSpace(projectID)
|
projectID = strings.TrimSpace(projectID)
|
||||||
@@ -116,6 +125,15 @@ func parseVercelSyncOptions(req map[string]any) (vercelSyncOptions, error) {
|
|||||||
}, nil
|
}, 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 {
|
func buildVercelParams(teamID string) url.Values {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
if strings.TrimSpace(teamID) != "" {
|
if strings.TrimSpace(teamID) != "" {
|
||||||
@@ -178,6 +196,25 @@ func (h *Handler) saveVercelProjectCredentials(ctx context.Context, client *http
|
|||||||
return saved
|
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) {
|
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)
|
projectResp, status, _ := vercelRequest(ctx, client, http.MethodGet, "https://api.vercel.com/v9/projects/"+projectID, params, headers, nil)
|
||||||
if status != http.StatusOK {
|
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) {
|
func (h *Handler) exportSyncConfig(req map[string]any) (string, string, error) {
|
||||||
override, ok := req["config_override"]
|
override, ok := req["config_override"]
|
||||||
if !ok || override == nil {
|
if !ok || override == nil {
|
||||||
return h.Store.ExportJSONAndBase64()
|
return encodeVercelSyncConfig(h.Store.Snapshot())
|
||||||
}
|
}
|
||||||
raw, err := json.Marshal(override)
|
raw, err := json.Marshal(override)
|
||||||
if err != nil {
|
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 {
|
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
return encodeVercelSyncConfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeVercelSyncConfig(cfg config.Config) (string, string, error) {
|
||||||
cfg.DropInvalidAccounts()
|
cfg.DropInvalidAccounts()
|
||||||
cfg.ClearAccountTokens()
|
cfg.ClearAccountTokens()
|
||||||
|
cfg.ClearVercelCredentials()
|
||||||
cfg.VercelSyncHash = ""
|
cfg.VercelSyncHash = ""
|
||||||
cfg.VercelSyncTime = 0
|
cfg.VercelSyncTime = 0
|
||||||
b, err := json.Marshal(cfg)
|
b, err := json.Marshal(cfg)
|
||||||
@@ -272,6 +314,7 @@ func syncHashForJSON(s string) string {
|
|||||||
cfg.VercelSyncHash = ""
|
cfg.VercelSyncHash = ""
|
||||||
cfg.VercelSyncTime = 0
|
cfg.VercelSyncTime = 0
|
||||||
cfg.ClearAccountTokens()
|
cfg.ClearAccountTokens()
|
||||||
|
cfg.ClearVercelCredentials()
|
||||||
b, err := json.Marshal(cfg)
|
b, err := json.Marshal(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
100
internal/httpapi/admin/vercel/handler_vercel_test.go
Normal file
100
internal/httpapi/admin/vercel/handler_vercel_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
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) {
|
func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDir string) {
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/admin")
|
path := strings.TrimPrefix(r.URL.Path, "/admin")
|
||||||
path = strings.TrimPrefix(path, "/")
|
path = strings.TrimPrefix(path, "/")
|
||||||
@@ -70,6 +109,7 @@ func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDi
|
|||||||
} else {
|
} else {
|
||||||
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
||||||
}
|
}
|
||||||
|
setStaticContentType(w, full)
|
||||||
http.ServeFile(w, r, full)
|
http.ServeFile(w, r, full)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -82,6 +122,7 @@ func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDi
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
||||||
|
setStaticContentType(w, index)
|
||||||
http.ServeFile(w, r, index)
|
http.ServeFile(w, r, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
102
internal/webui/handler_test.go
Normal file
102
internal/webui/handler_test.go
Normal file
@@ -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": "<!doctype html><html></html>",
|
||||||
|
"assets/index.css": "body{}",
|
||||||
|
"assets/index.js": "console.log(1)",
|
||||||
|
"assets/icon.svg": `<svg xmlns="http://www.w3.org/2000/svg"></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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ export default function VercelSyncContainer({ onMessage, authFetch, isVercel = f
|
|||||||
setProjectId,
|
setProjectId,
|
||||||
teamId,
|
teamId,
|
||||||
setTeamId,
|
setTeamId,
|
||||||
|
saveCredentials,
|
||||||
|
setSaveCredentials,
|
||||||
loading,
|
loading,
|
||||||
result,
|
result,
|
||||||
preconfig,
|
preconfig,
|
||||||
@@ -46,6 +48,8 @@ export default function VercelSyncContainer({ onMessage, authFetch, isVercel = f
|
|||||||
setProjectId={setProjectId}
|
setProjectId={setProjectId}
|
||||||
teamId={teamId}
|
teamId={teamId}
|
||||||
setTeamId={setTeamId}
|
setTeamId={setTeamId}
|
||||||
|
saveCredentials={saveCredentials}
|
||||||
|
setSaveCredentials={setSaveCredentials}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSync={handleSync}
|
onSync={handleSync}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export default function VercelSyncForm({
|
|||||||
setProjectId,
|
setProjectId,
|
||||||
teamId,
|
teamId,
|
||||||
setTeamId,
|
setTeamId,
|
||||||
|
saveCredentials,
|
||||||
|
setSaveCredentials,
|
||||||
loading,
|
loading,
|
||||||
onSync,
|
onSync,
|
||||||
}) {
|
}) {
|
||||||
@@ -124,6 +126,19 @@ export default function VercelSyncForm({
|
|||||||
onChange={e => setTeamId(e.target.value)}
|
onChange={e => setTeamId(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-ring"
|
||||||
|
checked={saveCredentials}
|
||||||
|
onChange={e => setSaveCredentials(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="space-y-1">
|
||||||
|
<span className="block font-medium">{t('vercel.saveCredentials')}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{t('vercel.saveCredentialsHint')}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false })
|
|||||||
const [vercelToken, setVercelToken] = useState('')
|
const [vercelToken, setVercelToken] = useState('')
|
||||||
const [projectId, setProjectId] = useState('')
|
const [projectId, setProjectId] = useState('')
|
||||||
const [teamId, setTeamId] = useState('')
|
const [teamId, setTeamId] = useState('')
|
||||||
|
const [saveCredentials, setSaveCredentials] = useState(true)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [result, setResult] = useState(null)
|
const [result, setResult] = useState(null)
|
||||||
const [preconfig, setPreconfig] = useState(null)
|
const [preconfig, setPreconfig] = useState(null)
|
||||||
@@ -117,6 +118,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false })
|
|||||||
vercel_token: tokenToUse,
|
vercel_token: tokenToUse,
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
team_id: teamId || undefined,
|
team_id: teamId || undefined,
|
||||||
|
save_credentials: saveCredentials,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -133,7 +135,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false })
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [apiFetch, fetchSyncStatus, onMessage, preconfig?.has_token, projectId, t, teamId, vercelToken])
|
}, [apiFetch, fetchSyncStatus, onMessage, preconfig?.has_token, projectId, saveCredentials, t, teamId, vercelToken])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
vercelToken,
|
vercelToken,
|
||||||
@@ -142,6 +144,8 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false })
|
|||||||
setProjectId,
|
setProjectId,
|
||||||
teamId,
|
teamId,
|
||||||
setTeamId,
|
setTeamId,
|
||||||
|
saveCredentials,
|
||||||
|
setSaveCredentials,
|
||||||
loading,
|
loading,
|
||||||
result,
|
result,
|
||||||
preconfig,
|
preconfig,
|
||||||
|
|||||||
@@ -462,6 +462,8 @@
|
|||||||
"projectIdHint": "Find it in Project Settings → General.",
|
"projectIdHint": "Find it in Project Settings → General.",
|
||||||
"teamIdLabel": "Team ID",
|
"teamIdLabel": "Team ID",
|
||||||
"optional": "optional",
|
"optional": "optional",
|
||||||
|
"saveCredentials": "Remember Vercel credentials",
|
||||||
|
"saveCredentialsHint": "Save the token, project ID, and team ID for the next sync.",
|
||||||
"syncing": "Syncing...",
|
"syncing": "Syncing...",
|
||||||
"syncRedeploy": "Sync & redeploy",
|
"syncRedeploy": "Sync & redeploy",
|
||||||
"redeployHint": "This triggers a Vercel redeploy and usually takes 30–60 seconds.",
|
"redeployHint": "This triggers a Vercel redeploy and usually takes 30–60 seconds.",
|
||||||
|
|||||||
@@ -462,6 +462,8 @@
|
|||||||
"projectIdHint": "可在项目设置 (Project Settings) → 常规 (General) 中找到",
|
"projectIdHint": "可在项目设置 (Project Settings) → 常规 (General) 中找到",
|
||||||
"teamIdLabel": "团队 ID",
|
"teamIdLabel": "团队 ID",
|
||||||
"optional": "可选",
|
"optional": "可选",
|
||||||
|
"saveCredentials": "记住 Vercel 凭据",
|
||||||
|
"saveCredentialsHint": "保存访问令牌、项目 ID 和团队 ID,供下次同步直接复用。",
|
||||||
"syncing": "正在同步...",
|
"syncing": "正在同步...",
|
||||||
"syncRedeploy": "同步并重新部署",
|
"syncRedeploy": "同步并重新部署",
|
||||||
"redeployHint": "这将触发 Vercel 的重新部署,大约需要 30-60 秒。",
|
"redeployHint": "这将触发 Vercel 的重新部署,大约需要 30-60 秒。",
|
||||||
|
|||||||
Reference in New Issue
Block a user