diff --git a/internal/service/api_config_connection.go b/internal/service/api_config_connection.go index feb8b53..f7d20f5 100644 --- a/internal/service/api_config_connection.go +++ b/internal/service/api_config_connection.go @@ -24,6 +24,14 @@ func (s *ApiConfigService) TestConnection(ctx context.Context, provider string) switch provider { case "tmdb": return s.testTMDb(cfg) + case "bangumi": + return s.testBangumi(ctx, cfg) + case "thetvdb": + return s.testTheTVDB(ctx, cfg) + case "fanart": + return s.testFanart(ctx, cfg) + case "douban": + return s.testDouban(ctx, cfg) case "openai": return s.testOpenAI(cfg) case "deepseek": diff --git a/internal/service/api_config_metadata_connection.go b/internal/service/api_config_metadata_connection.go new file mode 100644 index 0000000..de5e40d --- /dev/null +++ b/internal/service/api_config_metadata_connection.go @@ -0,0 +1,191 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/truewhile/MeBox/internal/model" +) + +// Bangumi's public metadata endpoints work without a token. If one was saved, +// also check /v0/me: public endpoints alone may accept an expired token. +func (s *ApiConfigService) testBangumi(ctx context.Context, cfg *model.APIConfig) (string, error) { + req, err := newMetadataProbeRequest(ctx, cfg, "https://api.bgm.tv", "/v0/subjects/253", http.MethodGet, nil) + if err != nil { + return "error", err + } + if token := strings.TrimSpace(cfg.APIKey); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + identityReq := req.Clone(ctx) + identityURL := *req.URL + identityURL.Path = strings.TrimSuffix(identityURL.Path, "/subjects/253") + "/me" + identityReq.URL = &identityURL + var identity struct { + ID int `json:"id"` + } + if result, err := testMetadataProbeJSON(identityReq, "Bangumi", &identity); err != nil { + return result, err + } + if identity.ID <= 0 { + return "error", errors.New("Bangumi returned an invalid user response") + } + } + var subject struct { + ID int `json:"id"` + Name string `json:"name"` + } + if result, err := testMetadataProbeJSON(req, "Bangumi", &subject); err != nil { + return result, err + } + if subject.ID != 253 || strings.TrimSpace(subject.Name) == "" { + return "error", errors.New("Bangumi returned an invalid subject response") + } + return "success", nil +} + +func (s *ApiConfigService) testTheTVDB(ctx context.Context, cfg *model.APIConfig) (string, error) { + key := strings.TrimSpace(cfg.APIKey) + if key == "" { + return "error", errors.New("TheTVDB API key is required") + } + body, err := json.Marshal(map[string]string{"apikey": key}) + if err != nil { + return "error", err + } + req, err := newMetadataProbeRequest(ctx, cfg, "https://api4.thetvdb.com/v4", "/login", http.MethodPost, strings.NewReader(string(body))) + if err != nil { + return "error", err + } + req.Header.Set("Content-Type", "application/json") + var response struct { + Status string `json:"status"` + Data struct { + Token string `json:"token"` + } `json:"data"` + } + if result, err := testMetadataProbeJSON(req, "TheTVDB", &response); err != nil { + return result, err + } + if response.Status != "success" || strings.TrimSpace(response.Data.Token) == "" { + return "error", errors.New("TheTVDB login did not return a valid token") + } + return "success", nil +} + +func (s *ApiConfigService) testFanart(ctx context.Context, cfg *model.APIConfig) (string, error) { + key := strings.TrimSpace(cfg.APIKey) + if key == "" { + return "error", errors.New("Fanart API key is required") + } + req, err := newMetadataProbeRequest(ctx, cfg, "https://webservice.fanart.tv/v3", "/movies/550", http.MethodGet, nil) + if err != nil { + return "error", err + } + query := req.URL.Query() + query.Set("api_key", key) + req.URL.RawQuery = query.Encode() + var response struct { + TMDbID json.Number `json:"tmdb_id"` + Name string `json:"name"` + } + if result, err := testMetadataProbeJSON(req, "Fanart", &response); err != nil { + return result, err + } + if response.TMDbID.String() != "550" || strings.TrimSpace(response.Name) == "" { + return "error", errors.New("Fanart returned an invalid movie response") + } + return "success", nil +} + +func (s *ApiConfigService) testDouban(ctx context.Context, cfg *model.APIConfig) (string, error) { + req, err := newMetadataProbeRequest(ctx, cfg, "https://movie.douban.com", "/j/subject_suggest", http.MethodGet, nil) + if err != nil { + return "error", err + } + query := req.URL.Query() + query.Set("q", "Inception") + req.URL.RawQuery = query.Encode() + req.Header.Set("User-Agent", userAgents[0]) + req.Header.Set("Referer", "https://movie.douban.com/") + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + if cookie := strings.TrimSpace(cfg.APIKey); cookie != "" { + req.Header.Set("Cookie", cookie) + } + var suggestions []struct { + ID string `json:"id"` + Title string `json:"title"` + } + if result, err := testMetadataProbeJSON(req, "Douban", &suggestions); err != nil { + return result, err + } + // An empty array is a valid search result; null or an error object is not. + if suggestions == nil { + return "error", errors.New("Douban returned an invalid search response (login or verification may be required)") + } + for _, suggestion := range suggestions { + if suggestion.ID == "" || strings.TrimSpace(suggestion.Title) == "" { + return "error", errors.New("Douban returned an invalid search suggestion") + } + } + return "success", nil +} + +func newMetadataProbeRequest(ctx context.Context, cfg *model.APIConfig, defaultBase, path, method string, body io.Reader) (*http.Request, error) { + base := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") + if base == "" { + base = defaultBase + } + req, err := http.NewRequestWithContext(ctx, method, base+path, body) + if err != nil { + // The saved base URL may contain credentials. Never echo it to the UI. + return nil, fmt.Errorf("%s: invalid base URL", cfg.Provider) + } + req.Header.Set("User-Agent", "MeBox/0.1 (https://github.com/truewhile/MeBox)") + req.Header.Set("Accept", "application/json") + return req, nil +} + +// Decode a bounded, complete JSON document rather than accepting any HTTP 200 +// (reverse proxies and anti-bot/login pages often return 200 as well). +func testMetadataProbeJSON(req *http.Request, provider string, out any) (string, error) { + client := NewExternalHTTPClient(10 * time.Second) + defer client.CloseIdleConnections() + resp, err := client.Do(req) + if err != nil { + // url.Error includes the full request URL, including Fanart's API key. + var urlErr *url.Error + if errors.As(err, &urlErr) { + err = urlErr.Err + } + return "error", fmt.Errorf("%s connection failed: %w", provider, err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + return "invalid", fmt.Errorf("%s authentication failed (HTTP 401): check configured credentials", provider) + } + if resp.StatusCode == http.StatusForbidden { + return "error", fmt.Errorf("%s access denied (HTTP 403): check credentials, permissions or anti-bot verification", provider) + } + if resp.StatusCode != http.StatusOK { + return "error", fmt.Errorf("%s API returned status %d", provider, resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1)) + if err != nil { + return "error", fmt.Errorf("%s response read failed: %w", provider, err) + } + if len(body) > 1<<20 { + return "error", fmt.Errorf("%s response exceeds 1 MiB", provider) + } + if err := json.Unmarshal(body, out); err != nil { + // Do not include the response body: it may echo credentials. + return "error", fmt.Errorf("%s returned invalid JSON (login or verification page may have intercepted the request)", provider) + } + return "success", nil +} diff --git a/internal/service/api_config_metadata_connection_test.go b/internal/service/api_config_metadata_connection_test.go new file mode 100644 index 0000000..7906f68 --- /dev/null +++ b/internal/service/api_config_metadata_connection_test.go @@ -0,0 +1,252 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/repository" +) + +type metadataProbeFixture struct { + provider string + path string + key string + body string +} + +var metadataProbeFixtures = []metadataProbeFixture{ + {"bangumi", "/v0/subjects/253", "", `{"id":253,"name":"カウボーイビバップ","type":2}`}, + {"thetvdb", "/login", "tvdb-test-secret", `{"status":"success","data":{"token":"test-jwt"}}`}, + {"fanart", "/movies/550", "fanart+test&secret", `{"name":"Fight Club","tmdb_id":"550","imdb_id":"tt0137523","movieposter":[]}`}, + {"douban", "/j/subject_suggest", "bid=test-cookie", `[{"id":"3541415","title":"盗梦空间","year":"2010","type":"movie"}]`}, +} + +func newMetadataConnectionTestService(t *testing.T) *ApiConfigService { + t.Helper() + log := zap.NewNop() + return NewApiConfigService(&config.Config{}, log, repository.New(newServiceTestDB(t)), NewCryptoService("metadata-test-secret", log)) +} + +func saveMetadataProbeConfig(t *testing.T, svc *ApiConfigService, provider, baseURL, key string) { + t.Helper() + if _, err := svc.Upsert(t.Context(), provider, key, baseURL, "", true); err != nil { + t.Fatal(err) + } +} + +// Removing a dispatch branch, ignoring the saved URL/key, or using the wrong +// endpoint must fail this integration test (real database and HTTP server). +func TestMetadataConnectionUsesSavedConfig(t *testing.T) { + svc := newMetadataConnectionTestService(t) + for _, fixture := range metadataProbeFixtures { + t.Run(fixture.provider, func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.URL.Path != "/proxy"+fixture.path { + t.Errorf("request path = %q", r.URL.Path) + } + if r.Header.Get("User-Agent") == "" { + t.Error("missing User-Agent") + } + if fixture.provider == "thetvdb" { + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if r.Method != http.MethodPost || body["apikey"] != fixture.key || r.Header.Get("Content-Type") != "application/json" { + t.Error("TheTVDB login must POST the saved, decrypted API key as JSON") + } + } else if r.Method != http.MethodGet { + t.Errorf("method = %s, want GET", r.Method) + } + if fixture.provider == "fanart" && r.URL.Query().Get("api_key") != fixture.key { + t.Error("Fanart API key was not escaped/passed correctly") + } + if fixture.provider == "douban" { + if r.Header.Get("Cookie") != fixture.key || r.Header.Get("Referer") != "https://movie.douban.com/" || r.URL.Query().Get("q") == "" { + t.Error("Douban search must carry the configured cookie, referer and query") + } + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(fixture.body)) + })) + defer server.Close() + saveMetadataProbeConfig(t, svc, fixture.provider, " "+server.URL+"/proxy/ ", fixture.key) + result, err := svc.TestConnection(t.Context(), fixture.provider) + if err != nil || result != "success" { + t.Fatalf("TestConnection = %q, %v, want success", result, err) + } + if calls != 1 { + t.Errorf("HTTP calls = %d, want 1", calls) + } + }) + } +} + +func TestMetadataConnectionRejectsBadResponses(t *testing.T) { + svc := newMetadataConnectionTestService(t) + for _, fixture := range metadataProbeFixtures { + for _, response := range []struct { + name string + status int + body string + result string + }{ + {"unauthorized", 401, `{"error":"denied"}`, "invalid"}, + {"forbidden", 403, `Access denied`, "error"}, + {"rate limited", 429, `{}`, "error"}, + {"upstream error", 503, `{}`, "error"}, + {"challenge page", 200, `Please log in or complete verification`, "error"}, + {"empty body", 200, ``, "error"}, + {"wrong schema", 200, `{"error":"intercepted"}`, "error"}, + {"null", 200, `null`, "error"}, + {"trailing garbage", 200, fixture.body + `blocked`, "error"}, + } { + t.Run(fixture.provider+"/"+response.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(response.status) + _, _ = w.Write([]byte(response.body)) + })) + defer server.Close() + saveMetadataProbeConfig(t, svc, fixture.provider, server.URL, fixture.key) + result, err := svc.TestConnection(t.Context(), fixture.provider) + if err == nil || result != response.result { + t.Fatalf("TestConnection = %q, %v, want %q and error", result, err, response.result) + } + if fixture.key != "" && strings.Contains(err.Error(), fixture.key) { + t.Error("error exposes configured credentials") + } + }) + } + } +} + +func TestMetadataConnectionRequiresAPIKeys(t *testing.T) { + svc := newMetadataConnectionTestService(t) + for _, provider := range []string{"thetvdb", "fanart"} { + t.Run(provider, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("must reject missing key before making a request") + })) + defer server.Close() + saveMetadataProbeConfig(t, svc, provider, server.URL, " ") + result, err := svc.TestConnection(t.Context(), provider) + if result != "error" || err == nil || !strings.Contains(err.Error(), "API key") { + t.Fatalf("TestConnection = %q, %v, want missing API key error", result, err) + } + }) + } +} + +func TestMetadataConnectionBangumiValidatesConfiguredToken(t *testing.T) { + svc := newMetadataConnectionTestService(t) + for _, valid := range []bool{true, false} { + name := "valid" + if !valid { + name = "invalid" + } + t.Run(name, func(t *testing.T) { + meCalls, subjectCalls := 0, 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer bangumi-test-token" { + t.Error("missing saved Bangumi bearer token") + } + switch r.URL.Path { + case "/v0/me": + meCalls++ + if !valid { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(`{"id":42,"username":"test-user"}`)) + case "/v0/subjects/253": + subjectCalls++ + _, _ = w.Write([]byte(`{"id":253,"name":"カウボーイビバップ"}`)) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + saveMetadataProbeConfig(t, svc, "bangumi", server.URL, "bangumi-test-token") + result, err := svc.TestConnection(t.Context(), "bangumi") + if valid && (result != "success" || err != nil || subjectCalls != 1) { + t.Fatalf("valid token: result=%q err=%v subjectCalls=%d", result, err, subjectCalls) + } + if !valid && (result != "invalid" || err == nil || subjectCalls != 0) { + t.Fatalf("invalid token: result=%q err=%v subjectCalls=%d", result, err, subjectCalls) + } + if meCalls != 1 { + t.Errorf("identity requests = %d, want 1", meCalls) + } + }) + } +} + +func TestMetadataConnectionDoubanDoesNotRequireCookie(t *testing.T) { + svc := newMetadataConnectionTestService(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Cookie") != "" { + t.Error("unexpected Cookie for public search") + } + _, _ = w.Write([]byte(`[]`)) + })) + defer server.Close() + saveMetadataProbeConfig(t, svc, "douban", server.URL, "") + result, err := svc.TestConnection(t.Context(), "douban") + if result != "success" || err != nil { + t.Fatalf("public search with empty results = %q, %v", result, err) + } +} + +func TestMetadataConnectionHonorsContext(t *testing.T) { + svc := newMetadataConnectionTestService(t) + for _, fixture := range metadataProbeFixtures { + t.Run(fixture.provider, func(t *testing.T) { + started := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + close(started) + <-r.Context().Done() + })) + defer server.Close() + saveMetadataProbeConfig(t, svc, fixture.provider, server.URL, fixture.key) + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + go func() { + select { + case <-started: + cancel() + case <-ctx.Done(): + } + }() + result, err := svc.TestConnection(ctx, fixture.provider) + if result != "error" || !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled request = %q, %v, want context.Canceled", result, err) + } + }) + } +} + +func TestMetadataConnectionTransportErrorsHideKeys(t *testing.T) { + svc := newMetadataConnectionTestService(t) + server := httptest.NewServer(http.NotFoundHandler()) + server.Close() + key := "sensitive-fanart-key" + saveMetadataProbeConfig(t, svc, "fanart", server.URL, key) + result, err := svc.TestConnection(t.Context(), "fanart") + if result != "error" || err == nil || strings.Contains(err.Error(), key) { + t.Fatalf("transport failure = %q, %v, want error without credentials", result, err) + } +}