diff --git a/cmd/fanqie/main.go b/cmd/fanqie/main.go index 9e78bfd..a9e523c 100644 --- a/cmd/fanqie/main.go +++ b/cmd/fanqie/main.go @@ -138,28 +138,30 @@ func setupRoutes() { http.HandleFunc("/self-info", handlers.RequireAccessKey(handlers.HandleSelfInfo)) http.HandleFunc("/vacuum", handlers.RequireAccessKey(handlers.HandleVacuum)) - // API端点 - http.HandleFunc("/search", handlers.HandleSearch) - http.HandleFunc("/detail", handlers.HandleDetail) - http.HandleFunc("/info", handlers.HandleDetail) - http.HandleFunc("/catalog", handlers.HandleCatalog) - http.HandleFunc("/directory/all_items", handlers.HandleCatalog) - http.HandleFunc("/multi-detail", handlers.HandleMultiDetail) - http.HandleFunc("/multi-content", handlers.HandleMultiContent) - http.HandleFunc("/content", handlers.HandleContent) - http.HandleFunc("/item-summary", handlers.HandleItemSummary) - http.HandleFunc("/category-front", handlers.HandleCategoryFront) - http.HandleFunc("/category-landing", handlers.HandleCategoryLanding) - http.HandleFunc("/audio", handlers.HandleAudio) + http.HandleFunc("/healthz", handlers.HandleHealth) + + // API端点(protect_all 开启时要求访问密钥;代理路由不包,URL 由服务端生成不含 key) + http.HandleFunc("/search", handlers.OptionalAccessKey(handlers.HandleSearch)) + http.HandleFunc("/detail", handlers.OptionalAccessKey(handlers.HandleDetail)) + http.HandleFunc("/info", handlers.OptionalAccessKey(handlers.HandleDetail)) + http.HandleFunc("/catalog", handlers.OptionalAccessKey(handlers.HandleCatalog)) + http.HandleFunc("/directory/all_items", handlers.OptionalAccessKey(handlers.HandleCatalog)) + http.HandleFunc("/multi-detail", handlers.OptionalAccessKey(handlers.HandleMultiDetail)) + http.HandleFunc("/multi-content", handlers.OptionalAccessKey(handlers.HandleMultiContent)) + http.HandleFunc("/content", handlers.OptionalAccessKey(handlers.HandleContent)) + http.HandleFunc("/item-summary", handlers.OptionalAccessKey(handlers.HandleItemSummary)) + http.HandleFunc("/category-front", handlers.OptionalAccessKey(handlers.HandleCategoryFront)) + http.HandleFunc("/category-landing", handlers.OptionalAccessKey(handlers.HandleCategoryLanding)) + http.HandleFunc("/audio", handlers.OptionalAccessKey(handlers.HandleAudio)) http.HandleFunc("/audio/proxy/", handlers.HandleAudioProxy) - http.HandleFunc("/video", handlers.HandleVideo) - http.HandleFunc("/manga", handlers.HandleManga) + http.HandleFunc("/video", handlers.OptionalAccessKey(handlers.HandleVideo)) + http.HandleFunc("/manga", handlers.OptionalAccessKey(handlers.HandleManga)) http.HandleFunc("/manga/image/", handlers.HandleMangaImageProxy) - + // 评论相关端点 - http.HandleFunc("/chapter_review", handlers.HandleChapterReview) - http.HandleFunc("/review", handlers.HandleReview) - http.HandleFunc("/review_html", handlers.HandleReviewHTML) + http.HandleFunc("/chapter_review", handlers.OptionalAccessKey(handlers.HandleChapterReview)) + http.HandleFunc("/review", handlers.OptionalAccessKey(handlers.HandleReview)) + http.HandleFunc("/review_html", handlers.OptionalAccessKey(handlers.HandleReviewHTML)) // 表情图片静态文件服务 http.Handle("/emoji/", http.StripPrefix("/emoji/", http.FileServer(http.Dir("emoji")))) diff --git a/internal/config/config.go b/internal/config/config.go index 779628b..f88f4ff 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,9 +19,10 @@ type Config struct { // ServerConfig 服务器配置 type ServerConfig struct { - Port string `mapstructure:"port"` - Host string `mapstructure:"host"` - AccessKey string `mapstructure:"access_key"` + Port string `mapstructure:"port"` + Host string `mapstructure:"host"` + AccessKey string `mapstructure:"access_key"` + ProtectAll bool `mapstructure:"protect_all"` // 业务接口也要求访问密钥(公网部署建议开启) } // FanqieConfig 番茄小说配置 diff --git a/internal/handlers/common.go b/internal/handlers/common.go index d06f07b..c7d7097 100644 --- a/internal/handlers/common.go +++ b/internal/handlers/common.go @@ -1,6 +1,7 @@ package handlers import ( + "crypto/subtle" "encoding/json" "fanqie/internal/config" "fanqie/internal/fqapi" @@ -122,19 +123,33 @@ func RequireAccessKey(handler http.HandlerFunc) http.HandlerFunc { key = r.Header.Get("X-Access-Key") } - // 验证密钥 - if key != cfg.Server.AccessKey { + // 验证密钥(常量时间比较,防时序攻击) + if subtle.ConstantTimeCompare([]byte(key), []byte(cfg.Server.AccessKey)) != 1 { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusUnauthorized) fmt.Fprintf(w, `{"code": -1, "msg": "访问被拒绝:无效的访问密钥"}`) return } - + // 验证通过,继续处理请求 handler(w, r) } } +// OptionalAccessKey 业务接口保护(仅 server.protect_all 开启时校验) +func OptionalAccessKey(handler http.HandlerFunc) http.HandlerFunc { + cfg := config.GetAppConfig() + if cfg == nil || !cfg.Server.ProtectAll { + return handler + } + return RequireAccessKey(handler) +} + +// HandleHealth 存活探针(供容器/反向代理使用) +func HandleHealth(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "ok") +} + // HandleMain 主页处理 func HandleMain(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") diff --git a/internal/handlers/error_response_test.go b/internal/handlers/error_response_test.go index 31aeade..7f8420e 100644 --- a/internal/handlers/error_response_test.go +++ b/internal/handlers/error_response_test.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "net/http" "net/http/httptest" "testing" ) @@ -40,3 +41,22 @@ func TestHandleDetailMissingParam(t *testing.T) { t.Errorf("缺参应返回 400, got %d, body: %s", w.Code, w.Body.String()) } } + +// TestOptionalAccessKeyDisabled protect_all 未启用(或配置未加载)时直接放行 +func TestOptionalAccessKeyDisabled(t *testing.T) { + called := false + h := OptionalAccessKey(func(w http.ResponseWriter, r *http.Request) { called = true }) + h(httptest.NewRecorder(), httptest.NewRequest("GET", "/search?query=x", nil)) + if !called { + t.Error("protect_all 未启用时应直接放行") + } +} + +// TestHandleHealth 存活探针返回 200 ok +func TestHandleHealth(t *testing.T) { + w := httptest.NewRecorder() + HandleHealth(w, httptest.NewRequest("GET", "/healthz", nil)) + if w.Code != 200 || w.Body.String() != "ok" { + t.Errorf("应返回 200 ok, got %d %q", w.Code, w.Body.String()) + } +} diff --git a/openapi.yaml b/openapi.yaml index 92d2c2d..5196cfe 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -50,6 +50,20 @@ paths: message: { type: string, example: success } data: { nullable: true } + /healthz: + get: + tags: [系统] + summary: 存活探针 + description: 供容器/反向代理探活,不校验访问密钥。 + operationId: getHealthz + responses: + "200": + description: 服务存活 + content: + text/plain: + schema: { type: string } + example: ok + /status: get: tags: [系统] @@ -750,7 +764,7 @@ components: responses: Unauthorized: - description: 访问密钥缺失或无效 + description: 访问密钥缺失或无效(业务接口仅在 server.protect_all 开启时需要) content: application/json: schema: