diff --git a/.env.example b/.env.example index 8f630280..4579c61b 100644 --- a/.env.example +++ b/.env.example @@ -76,3 +76,10 @@ ADMIN_PASSWORD=admin-secret-password-123 # 全局准入策略 (是否开放自主注册与注册是否必须审批) AUTH_REGISTRATION_ENABLED=true AUTH_REQUIRE_APPROVAL=false + +# CORS 允许来源(逗号分隔);main 环境必须显式配置,禁止 * +# dev/test 未配置时使用 localhost 默认值 +# CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:8080 + +# 生产环境必须为 false;main 启动时若 true 将拒绝加载配置 +# ALLOW_IN_MEMORY=false diff --git a/internal/config/config.go b/internal/config/config.go index ba138dfc..d0586595 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -61,6 +61,9 @@ type Config struct { // AllowInMemory 显式允许数据库不可用时降级内存模式(仅限本地 Demo) AllowInMemory bool `json:"allow_in_memory"` + + // CORSAllowedOrigins 允许的浏览器 Origin 列表;空列表时不回显 Access-Control-Allow-Origin + CORSAllowedOrigins []string `json:"cors_allowed_origins"` } func Load(path string) (*Config, error) { @@ -222,6 +225,8 @@ func Load(path string) (*Config, error) { } case "ALLOW_IN_MEMORY": cfg.AllowInMemory = val == "true" || val == "1" + case "CORS_ALLOWED_ORIGINS": + cfg.CORSAllowedOrigins = parseCSVOrigins(val) } } } @@ -327,6 +332,12 @@ func Load(path string) (*Config, error) { if v := os.Getenv("PI_AGENT_RUNNER_URL"); v != "" { cfg.PiAgentRunnerURL = v } + if v := os.Getenv("ALLOW_IN_MEMORY"); v != "" { + cfg.AllowInMemory = v == "true" || v == "1" + } + if v := os.Getenv("CORS_ALLOWED_ORIGINS"); v != "" { + cfg.CORSAllowedOrigins = parseCSVOrigins(v) + } if v := os.Getenv("HTTP_ADDR"); v != "" && strings.HasPrefix(v, ":") { if p, err := strconv.Atoi(strings.TrimPrefix(v, ":")); err == nil { cfg.HTTPPort = p @@ -334,6 +345,7 @@ func Load(path string) (*Config, error) { } } applyDSNOverrides(cfg) + applyCORSDefaults(cfg) if err := validateProductionSecrets(cfg); err != nil { return nil, err @@ -342,6 +354,34 @@ func Load(path string) (*Config, error) { return cfg, nil } +func parseCSVOrigins(raw string) []string { + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +func applyCORSDefaults(cfg *Config) { + if cfg == nil || len(cfg.CORSAllowedOrigins) > 0 { + return + } + if cfg.Env == "dev" || cfg.Env == "test" { + cfg.CORSAllowedOrigins = []string{ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:8080", + "http://127.0.0.1:8080", + "http://localhost:8081", + "http://127.0.0.1:8081", + } + } +} + func validateProductionSecrets(cfg *Config) error { if cfg == nil || cfg.Env == "" || cfg.Env == "dev" || cfg.Env == "test" { return nil @@ -362,6 +402,9 @@ func validateProductionSecrets(cfg *Config) error { } } } + if cfg.AllowInMemory { + return fmt.Errorf("ALLOW_IN_MEMORY must be false in %s environment", cfg.Env) + } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ad1ab940..b126f868 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -69,6 +69,29 @@ func TestConfigLoadDSNOverrides(t *testing.T) { } } +func TestConfigLoadCORSDefaultsForDev(t *testing.T) { + cfg, err := config.Load("/dev/null") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cfg.CORSAllowedOrigins) == 0 { + t.Fatal("expected default CORS origins for dev") + } +} + +func TestConfigLoadRejectsAllowInMemoryInMain(t *testing.T) { + t.Setenv("ENV", "main") + t.Setenv("KB_DB_PASSWORD", "kb-prod-secret-xyz") + t.Setenv("CONTEXT_DB_PASSWORD", "ctx-prod-secret-xyz") + t.Setenv("ADMIN_PASSWORD", "admin-prod-secret-xyz") + t.Setenv("ALLOW_IN_MEMORY", "true") + + _, err := config.Load("/dev/null") + if err == nil { + t.Fatal("expected config load to fail when ALLOW_IN_MEMORY=true in main") + } +} + func TestConfigLoadRejectsWeakSecretsOutsideDev(t *testing.T) { t.Setenv("ENV", "main") t.Setenv("KB_DB_PASSWORD", "") diff --git a/internal/server/cors.go b/internal/server/cors.go new file mode 100644 index 00000000..277e7521 --- /dev/null +++ b/internal/server/cors.go @@ -0,0 +1,27 @@ +package server + +import ( + "net/http" + "strings" +) + +func applyCORSHeaders(w http.ResponseWriter, r *http.Request, allowedOrigins []string) { + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin == "" || len(allowedOrigins) == 0 { + return + } + for _, allowed := range allowedOrigins { + if strings.TrimSpace(allowed) == origin { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + return + } + } +} + +func (s *Server) writeCORS(w http.ResponseWriter, r *http.Request) { + if s.cfg == nil { + return + } + applyCORSHeaders(w, r, s.cfg.CORSAllowedOrigins) +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index d439a3dd..7396b95a 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -112,6 +112,11 @@ func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + var req ChatRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) @@ -133,9 +138,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { req.UserName, req.UserRole, ) - if req.UserID > 0 { - sess.UserID = req.UserID - } + sess.UserID = user.ID if req.ProjectID > 0 { sess.ProjectID = req.ProjectID } @@ -150,11 +153,17 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + var req TaskRunRequestDTO if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) return } + req.UserID = user.ID // 0. 如果关联了项目,在 projectworkflow 服务中记录运行实例 var runRecord *projectworkflow.WorkflowRun diff --git a/internal/server/server.go b/internal/server/server.go index ed57f6df..066e4e9e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -444,7 +444,7 @@ func (s *Server) getOrCreateSession( func (s *Server) corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") + s.writeCORS(w, r) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With") if r.Method == http.MethodOptions { diff --git a/internal/server/sse.go b/internal/server/sse.go index ea199580..d2c68e91 100644 --- a/internal/server/sse.go +++ b/internal/server/sse.go @@ -145,13 +145,19 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { return } + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + req.UserID = user.ID + // 1. 设置 SSE 响应头 + 请求追踪 ID requestID := fmt.Sprintf("req_%d", time.Now().UnixNano()) w.Header().Set("X-Request-Id", requestID) w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") + s.writeCORS(w, r) w.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 等反代缓冲 flusher.Flush() @@ -169,9 +175,7 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { req.UserName, req.UserRole, ) - if req.UserID > 0 { - sess.UserID = req.UserID - } + sess.UserID = req.UserID if req.ProjectID > 0 { sess.ProjectID = req.ProjectID } @@ -308,16 +312,19 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") + s.writeCORS(w, r) w.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 等反代缓冲 flusher.Flush() + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + if req.TaskKey == "" && req.Stage == "" { req.TaskKey = "intake" } - if user := s.principalFromRequest(r); user != nil { - req.UserID = user.ID - } + req.UserID = user.ID req.WorkDir = "" runID := fmt.Sprintf("run_%d", time.Now().UnixNano()) diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index 23191862..94482a4b 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -137,7 +137,7 @@ func (s *Server) handleTaskRunEvents(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") + s.writeCORS(w, r) w.Header().Set("X-Accel-Buffering", "no") flusher.Flush()