refactor: remove proxy API (#1596)

This commit is contained in:
Levi authored and GitHub committed 2026-10-21 22:48:17 +08:00
1 parent 47cafb7575
commit 4214765d39
98 files changed
+5958 -1225

No files matched your search

+6 -1
View File
@@ -2,6 +2,7 @@ package api
import (
"fmt"
"net/url"
pathpkg "path"
"strings"
)
@@ -75,7 +76,11 @@ func (r RouteSpec) Match(rawPath string) (map[string]string, bool) {
params := make(map[string]string)
for index, part := range r.parts {
if part.param != "" {
params[part.param] = segments[index]
value, err := url.PathUnescape(segments[index])
if err != nil {
return nil, false
}
params[part.param] = value
continue
}
if part.literal != segments[index] {
+34
View File
@@ -2,7 +2,9 @@ package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
@@ -62,6 +64,8 @@ func NewService(downloader *download.Downloader) (*Service, error) {
newRoute(http.MethodGet, "/api/v1/tasks/{id}/status", service.getTaskStatus),
newRoute(http.MethodGet, "/api/v1/tasks", service.getTasks),
newRoute(http.MethodGet, "/api/v1/tasks/{id}/stats", service.getStats),
newRoute(http.MethodPost, "/api/v1/protocols/{protocol}/jobs/{name}/trigger", service.triggerProtocolJob),
newRoute(http.MethodPost, "/api/v1/mirrors/{provider}/candidates", service.mirrorCandidates),
newRoute(http.MethodGet, "/api/v1/config", service.getConfig),
newRoute(http.MethodPut, "/api/v1/config", service.putConfig),
newRoute(http.MethodPost, "/api/v1/extensions", service.installExtension),
@@ -294,6 +298,36 @@ func (s *Service) getStats(ctx *Context) *Response {
return newOKResponse(stats)
}
func (s *Service) triggerProtocolJob(ctx *Context) *Response {
if err := s.Downloader.TriggerProtocolJob(context.Background(), ctx.PathParam("protocol"), ctx.PathParam("name")); err != nil {
status := http.StatusBadRequest
if errors.Is(err, download.ErrUnSupportedProtocol) || errors.Is(err, download.ErrProtocolJobNotFound) {
status = http.StatusNotFound
}
return newStatusResponse(status, restmodel.NewErrorResult(err.Error()))
}
return newNilResponse()
}
func (s *Service) mirrorCandidates(ctx *Context) *Response {
var request restmodel.MirrorCandidatesReq
if response := decodeRequest(ctx.Body, &request); response != nil {
return response
}
urls, err := s.Downloader.GetMirrorCandidates(ctx.PathParam("provider"), request.URL)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, download.ErrMirrorProviderNotFound):
status = http.StatusNotFound
case errors.Is(err, download.ErrInvalidMirrorURL):
status = http.StatusBadRequest
}
return newStatusResponse(status, restmodel.NewErrorResult(err.Error()))
}
return newOKResponse(&restmodel.MirrorCandidatesResp{URLs: urls})
}
func (s *Service) getConfig(_ *Context) *Response {
config, err := s.Downloader.GetConfig()
if err != nil {
+187
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -8,7 +9,10 @@ import (
"path/filepath"
"reflect"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
restmodel "github.com/GopeedLab/gopeed/pkg/rest/model"
@@ -80,3 +84,186 @@ func serviceTestRequest[T any](t *testing.T, service *Service, method, path stri
}
return result.Data
}
func TestFullConfigDispatch(t *testing.T) {
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&httpProtocol.FetcherManager{}}})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Close()
service, err := NewService(d)
if err != nil {
t.Fatal(err)
}
response := service.Dispatch(http.MethodPut, "/api/v1/config", nil, []byte(`{"protocolConfig":{"http":{"connections":3,"useServerCtime":false}}}`), nil)
if response.StatusCode != http.StatusOK {
t.Fatalf("%+v", response)
}
status, body := service.JSON(http.MethodGet, "/api/v1/config", nil, nil, nil)
var result struct {
Data struct {
ProtocolConfig map[string]struct {
Connections int `json:"connections"`
} `json:"protocolConfig"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
t.Fatal(err)
}
if status != http.StatusOK || result.Data.ProtocolConfig["http"].Connections != 3 {
t.Fatalf("%d %s", status, body)
}
if response := service.Dispatch(http.MethodPost, "/api/v1/protocols/http/jobs/missing/trigger", nil, nil, nil); response.StatusCode != http.StatusNotFound {
t.Fatalf("%+v", response)
}
}
func TestNativePathParametersAreDecodedOnce(t *testing.T) {
route := newRoute(http.MethodGet, "/api/v1/tasks/{id}/status", func(c *Context) *Response { return newOKResponse(c.PathParam("id")) })
for _, test := range []struct{ path, want string }{
{"/api/v1/tasks/%E5%BC%80%E5%8F%91%E8%80%85%40name/status", "开发者@name"},
{"/api/v1/tasks/literal%252Fname/status", "literal%2Fname"},
} {
response := (&Service{routes: []RouteSpec{route}}).Dispatch(http.MethodGet, test.path, nil, nil, nil)
raw, _ := json.Marshal(response.Body)
var body struct {
Data string `json:"data"`
}
_ = json.Unmarshal(raw, &body)
if body.Data != test.want {
t.Fatalf("%s => %q", test.path, body.Data)
}
}
}
func TestMirrorCandidates(t *testing.T) {
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&httpProtocol.FetcherManager{}}})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Close()
cfg, _ := d.GetConfig()
cfg.GithubMirror = &base.GithubMirrorConfig{Enabled: true, Mirrors: []base.GithubMirror{
{Type: "jsdelivr", URL: "https://cdn.example/gh"},
{Type: "ghProxy", URL: "https://proxy.example"},
{Type: "ghProxy", URL: "https://proxy.example"},
}}
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
service, err := NewService(d)
if err != nil {
t.Fatal(err)
}
for _, official := range []string{
"https://github.com/GopeedLab/gopeed/releases/download/v2.0.0/package.zip",
"https://raw.githubusercontent.com/owner/repo/main/trackers.txt",
} {
request, _ := json.Marshal(map[string]string{"url": official})
status, raw := service.JSON(http.MethodPost, "/api/v1/mirrors/github/candidates", nil, request, nil)
var body struct {
Data struct {
URLs []string `json:"urls"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatal(err)
}
want := 2
if official == "https://raw.githubusercontent.com/owner/repo/main/trackers.txt" {
want = 3
}
urls := body.Data.URLs
if status != http.StatusOK || len(urls) != want || urls[0] != official || urls[len(urls)-1] != "https://proxy.example/"+official {
t.Fatalf("%d %s", status, raw)
}
}
for _, invalid := range []string{"https://api.github.com/repos/GopeedLab/gopeed/releases?per_page=10", "", "https://example.com/x", "http://github.com/x", "https://github.com.evil.test/x", "https://[email protected]/x", "https://github.com:443/x", "https://github.com:/x", "https://github.com/x#fragment", "https://github.com/x#", "/relative", "https://github.com%2fevil.test/x"} {
request, _ := json.Marshal(map[string]string{"url": invalid})
if response := service.Dispatch(http.MethodPost, "/api/v1/mirrors/github/candidates", nil, request, nil); response.StatusCode != http.StatusBadRequest {
t.Fatalf("accepted %s: %+v", invalid, response)
}
}
cfg.GithubMirror.Enabled = false
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
status, raw := service.JSON(http.MethodPost, "/api/v1/mirrors/github/candidates", nil, []byte(`{"url":"https://github.com/owner/repo"}`), nil)
var body struct {
Data struct {
URLs []string `json:"urls"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &body); err != nil || status != http.StatusOK || len(body.Data.URLs) != 1 || body.Data.URLs[0] != "https://github.com/owner/repo" {
t.Fatalf("%d %s", status, raw)
}
for _, provider := range []string{"unknown", "GitHub"} {
if response := service.Dispatch(http.MethodPost, "/api/v1/mirrors/"+provider+"/candidates", nil, []byte(`{"url":"https://github.com/owner/repo"}`), nil); response.StatusCode != http.StatusNotFound {
t.Fatalf("unknown provider accepted: %+v", response)
}
}
}
type apiJobState struct {
Runs int `json:"runs"`
}
type apiJobConfig struct {
fetcher.ConfigState[apiJobState]
Writable int `json:"writable"`
}
type apiStateJobManager struct{ httpProtocol.FetcherManager }
func (*apiStateJobManager) Name() string { return "state-api" }
func (*apiStateJobManager) DefaultConfig() any { return &apiJobConfig{Writable: 7} }
func (*apiStateJobManager) CronJobs(host fetcher.ProtocolHost) []fetcher.CronJob {
return []fetcher.CronJob{{Name: "refresh", Every: time.Hour, Timeout: time.Second, Run: func(_ context.Context, source fetcher.TriggerSource) error {
if source == fetcher.TriggerScheduled {
return fetcher.ErrJobSkipped
}
return fetcher.UpdateState[apiJobConfig, apiJobState](host, func(cfg *apiJobConfig, state *apiJobState) error { cfg.Writable = 999; state.Runs++; return nil })
}}}
}
func TestFullConfigAndJobTriggerShareProtectedProtocolState(t *testing.T) {
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&apiStateJobManager{}}})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Close()
service, err := NewService(d)
if err != nil {
t.Fatal(err)
}
put := []byte(`{"protocolConfig":{"state-api":{"writable":9,"state":"ignore forged state"}}}`)
if response := service.Dispatch(http.MethodPut, "/api/v1/config", nil, put, nil); response.StatusCode != http.StatusOK {
t.Fatalf("%+v", response)
}
if response := service.Dispatch(http.MethodPost, "/api/v1/protocols/state-api/jobs/refresh/trigger", nil, nil, nil); response.StatusCode != http.StatusOK {
t.Fatalf("%+v", response)
}
status, raw := service.JSON(http.MethodGet, "/api/v1/config", nil, nil, nil)
var response struct {
Data struct {
ProtocolConfig map[string]apiJobConfig `json:"protocolConfig"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &response); err != nil {
t.Fatal(err)
}
cfg := response.Data.ProtocolConfig["state-api"]
if status != http.StatusOK || cfg.Writable != 9 || cfg.State.Runs != 1 {
t.Fatalf("%d %s", status, raw)
}
// Replaying an old full PUT must not undo the manual job's state change.
if response := service.Dispatch(http.MethodPut, "/api/v1/config", nil, put, nil); response.StatusCode != http.StatusOK {
t.Fatalf("%+v", response)
}
_, raw = service.JSON(http.MethodGet, "/api/v1/config", nil, nil, nil)
if err := json.Unmarshal(raw, &response); err != nil {
t.Fatal(err)
}
if response.Data.ProtocolConfig["state-api"].State.Runs != 1 {
t.Fatalf("job state overwritten: %s", raw)
}
}