diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index f8b4e5c..7435c28 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -123,6 +123,8 @@ func main() { r.Use(chimw.Logger) r.Use(chimw.Recoverer) r.Use(apierr.Middleware) + // CORS:Web 用户中心(app.yanmeiai.com)跨域调 /v1/*;原生端不受影响。 + r.Use(httpapi.NewCORS()) r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/server/internal/httpapi/cors.go b/server/internal/httpapi/cors.go new file mode 100644 index 0000000..a56745a --- /dev/null +++ b/server/internal/httpapi/cors.go @@ -0,0 +1,47 @@ +package httpapi + +import ( + "net/http" + "os" + "strings" +) + +// NewCORS 返回一个 CORS 中间件:为白名单 Origin(精确匹配)补 CORS 响应头,并直接 +// 应答 OPTIONS 预检。Web 用户中心(app.yanmeiai.com,浏览器)跨域调用 /v1/*,浏览器 +// 会先发预检、并校验 Access-Control-Allow-Origin;原生移动/桌面客户端不是浏览器、 +// 不受 CORS 约束,故此前无需 CORS。 +// +// Origin 白名单来自 CORS_ORIGINS(逗号分隔),缺省含 usercenter 的正式域与 pages.dev。 +// 认证走 Authorization: Bearer(非 cookie),所以不需要 Allow-Credentials。 +func NewCORS() func(http.Handler) http.Handler { + raw := os.Getenv("CORS_ORIGINS") + if raw == "" { + raw = "https://app.yanmeiai.com,https://pangolin-usercenter.pages.dev" + } + allowed := map[string]bool{} + for _, o := range strings.Split(raw, ",") { + o = strings.TrimSpace(o) + if o != "" { + allowed[o] = true + } + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" && allowed[origin] { + h := w.Header() + h.Set("Access-Control-Allow-Origin", origin) + h.Add("Vary", "Origin") + h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + h.Set("Access-Control-Max-Age", "600") + } + // 预检请求直接 204(不落到业务路由,避免 /v1/... 的 OPTIONS 404)。 + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/server/internal/httpapi/cors_test.go b/server/internal/httpapi/cors_test.go new file mode 100644 index 0000000..55fc407 --- /dev/null +++ b/server/internal/httpapi/cors_test.go @@ -0,0 +1,47 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestCORS(t *testing.T) { + t.Setenv("CORS_ORIGINS", "https://app.yanmeiai.com") + mw := NewCORS() + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) + h := mw(next) + + // 允许的 Origin:补 Allow-Origin,普通请求继续。 + r := httptest.NewRequest("POST", "/v1/auth/login", nil) + r.Header.Set("Origin", "https://app.yanmeiai.com") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://app.yanmeiai.com" { + t.Fatalf("allowed origin: want header, got %q", got) + } + if w.Code != 200 { + t.Fatalf("non-preflight should reach next, got %d", w.Code) + } + + // OPTIONS 预检:204,不落到业务。 + r = httptest.NewRequest("OPTIONS", "/v1/auth/login", nil) + r.Header.Set("Origin", "https://app.yanmeiai.com") + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusNoContent { + t.Fatalf("preflight: want 204, got %d", w.Code) + } + if w.Header().Get("Access-Control-Allow-Methods") == "" { + t.Fatalf("preflight missing Allow-Methods") + } + + // 未白名单 Origin:不补 Allow-Origin。 + r = httptest.NewRequest("POST", "/v1/auth/login", nil) + r.Header.Set("Origin", "https://evil.example.com") + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("disallowed origin should get no header, got %q", got) + } +}