mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-13 20:03:46 +00:00
refactor: remove proxy API (#1596)
This commit is contained in:
98 files changed
+5958
-1225
No files matched your search
+6
-1
@@ -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] {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package base
|
||||
|
||||
import "github.com/GopeedLab/gopeed/internal/githubmirror"
|
||||
|
||||
// Preserve downloader configuration type names and the existing JSON schema.
|
||||
type GithubMirror = githubmirror.Mirror
|
||||
type GithubMirrorConfig = githubmirror.Config
|
||||
|
||||
func DefaultGithubMirrorConfig() *GithubMirrorConfig {
|
||||
return githubmirror.DefaultConfig()
|
||||
}
|
||||
@@ -181,6 +181,7 @@ type DownloaderStoreConfig struct {
|
||||
DownloadDir string `json:"downloadDir"` // DownloadDir is the default directory to save the downloaded files
|
||||
MaxRunning int `json:"maxRunning"` // MaxRunning is the max running download count
|
||||
ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
|
||||
GithubMirror *GithubMirrorConfig `json:"githubMirror"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
Proxy *DownloaderProxyConfig `json:"proxy"`
|
||||
Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration
|
||||
@@ -199,6 +200,9 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
|
||||
if cfg.ProtocolConfig == nil {
|
||||
cfg.ProtocolConfig = make(map[string]any)
|
||||
}
|
||||
if cfg.GithubMirror == nil {
|
||||
cfg.GithubMirror = DefaultGithubMirrorConfig()
|
||||
}
|
||||
if cfg.Proxy == nil {
|
||||
cfg.Proxy = &DownloaderProxyConfig{}
|
||||
}
|
||||
@@ -239,6 +243,9 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
|
||||
if cfg.Extra == nil {
|
||||
cfg.Extra = beforeCfg.Extra
|
||||
}
|
||||
if cfg.GithubMirror == nil {
|
||||
cfg.GithubMirror = beforeCfg.GithubMirror
|
||||
}
|
||||
if cfg.Proxy == nil {
|
||||
cfg.Proxy = beforeCfg.Proxy
|
||||
}
|
||||
|
||||
+22
-1
@@ -15,6 +15,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
"Init",
|
||||
&DownloaderStoreConfig{},
|
||||
&DownloaderStoreConfig{
|
||||
GithubMirror: DefaultGithubMirrorConfig(),
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
@@ -36,6 +37,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
MaxRunning: 10,
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
GithubMirror: DefaultGithubMirrorConfig(),
|
||||
MaxRunning: 10,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
@@ -59,7 +61,8 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
MaxRunning: 5,
|
||||
GithubMirror: DefaultGithubMirrorConfig(),
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{
|
||||
"key": "value",
|
||||
},
|
||||
@@ -84,6 +87,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
GithubMirror: DefaultGithubMirrorConfig(),
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{
|
||||
@@ -110,6 +114,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
GithubMirror: DefaultGithubMirrorConfig(),
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
@@ -134,6 +139,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
GithubMirror: DefaultGithubMirrorConfig(),
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
@@ -149,6 +155,20 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Init explicitly disabled mirrors with empty list",
|
||||
fields: &DownloaderStoreConfig{GithubMirror: &GithubMirrorConfig{Enabled: false, Mirrors: []GithubMirror{}}},
|
||||
want: &DownloaderStoreConfig{
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{},
|
||||
GithubMirror: &GithubMirrorConfig{Enabled: false, Mirrors: []GithubMirror{}},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{},
|
||||
Archive: &ArchiveConfig{},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -157,6 +177,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
DownloadDir: tt.fields.DownloadDir,
|
||||
MaxRunning: tt.fields.MaxRunning,
|
||||
ProtocolConfig: tt.fields.ProtocolConfig,
|
||||
GithubMirror: tt.fields.GithubMirror,
|
||||
Extra: tt.fields.Extra,
|
||||
Proxy: tt.fields.Proxy,
|
||||
Webhook: tt.fields.Webhook,
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
btProtocol "github.com/GopeedLab/gopeed/internal/protocol/bt"
|
||||
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
type testJobManager struct {
|
||||
httpProtocol.FetcherManager
|
||||
job fetcher.CronJob
|
||||
}
|
||||
|
||||
func (m *testJobManager) Name() string { return "test" }
|
||||
func (m *testJobManager) CronJobs(fetcher.ProtocolHost) []fetcher.CronJob {
|
||||
return []fetcher.CronJob{m.job}
|
||||
}
|
||||
func testBackendDownloader(t *testing.T, managers ...fetcher.FetcherManager) *Downloader {
|
||||
t.Helper()
|
||||
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: managers})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := d.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
return d
|
||||
}
|
||||
func TestProtocolJobCoalescesAndCancelsOnClose(t *testing.T) {
|
||||
entered := make(chan struct{})
|
||||
var calls atomic.Int32
|
||||
manager := &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Minute, Run: func(ctx context.Context, _ fetcher.TriggerSource) error {
|
||||
if calls.Add(1) == 1 {
|
||||
close(entered)
|
||||
}
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}}}
|
||||
d := testBackendDownloader(t, manager)
|
||||
result := make(chan error, 2)
|
||||
go func() { result <- d.TriggerProtocolJob(context.Background(), "test", "refresh") }()
|
||||
<-entered
|
||||
waiterCtx, cancel := context.WithCancel(context.Background())
|
||||
// A canceled waiter joins the existing work without canceling it.
|
||||
cancel()
|
||||
if err := d.TriggerProtocolJob(waiterCtx, "test", "refresh"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("executed %d times", calls.Load())
|
||||
}
|
||||
if err := d.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-result; !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.TriggerProtocolJob(context.Background(), "test", "missing"); !errors.Is(err, ErrProtocolJobNotFound) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestProtocolJobPersistsSuccess(t *testing.T) {
|
||||
manager := &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Minute, Run: func(context.Context, fetcher.TriggerSource) error { return nil }}}
|
||||
d := testBackendDownloader(t, manager)
|
||||
if err := d.TriggerProtocolJob(context.Background(), "test", "refresh"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var state protocolJobState
|
||||
exists, err := d.storage.Get(bucketProtocolJobs, "test/refresh", &state)
|
||||
if err != nil || !exists || state.LastSuccess.IsZero() {
|
||||
t.Fatalf("state %+v: %v", state, err)
|
||||
}
|
||||
}
|
||||
func TestRaceResourceValidatesResponsesAndCancelsLosers(t *testing.T) {
|
||||
loserCanceled := make(chan struct{})
|
||||
loserStarted := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodConnect:
|
||||
http.Error(w, "official unavailable", http.StatusBadGateway)
|
||||
case strings.HasPrefix(r.URL.Path, "/bad/"):
|
||||
io.WriteString(w, "<html>proxy landing page</html>")
|
||||
case strings.HasPrefix(r.URL.Path, "/good/"):
|
||||
<-loserStarted
|
||||
io.WriteString(w, `{"ok":true}`)
|
||||
case strings.HasPrefix(r.URL.Path, "/slow/"):
|
||||
close(loserStarted)
|
||||
<-r.Context().Done()
|
||||
close(loserCanceled)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
d := testBackendDownloader(t, &httpProtocol.FetcherManager{})
|
||||
cfg, _ := d.GetConfig()
|
||||
cfg.Proxy = &base.DownloaderProxyConfig{Enable: true, Scheme: "http", Host: strings.TrimPrefix(server.URL, "http://")}
|
||||
cfg.GithubMirror = &base.GithubMirrorConfig{Enabled: true, Mirrors: []base.GithubMirror{
|
||||
{Type: "ghProxy", URL: server.URL + "/bad"},
|
||||
{Type: "ghProxy", URL: server.URL + "/slow"},
|
||||
{Type: "ghProxy", URL: server.URL + "/good"},
|
||||
}}
|
||||
if err := d.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := d.fetchResource(context.Background(), "https://raw.githubusercontent.com/owner/repo/main/test.json", 1024, false, func(body []byte) error {
|
||||
if !json.Valid(body) {
|
||||
return errors.New("invalid JSON")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil || string(body) != `{"ok":true}` {
|
||||
t.Fatalf("%s %v", body, err)
|
||||
}
|
||||
select {
|
||||
case <-loserCanceled:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("losing request was not canceled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolConfigInitializesCurrentSchema(t *testing.T) {
|
||||
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&httpProtocol.FetcherManager{}}})
|
||||
d.cfg.DownloaderStoreConfig = &base.DownloaderStoreConfig{
|
||||
GithubMirror: &base.GithubMirrorConfig{Enabled: false, Mirrors: []base.GithubMirror{}},
|
||||
ProtocolConfig: map[string]any{"http": map[string]any{"connections": 3}},
|
||||
}
|
||||
if err := d.storage.Setup([]string{bucketConfig}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.storage.Close()
|
||||
if err := d.initProtocolConfigs(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.cfg.GithubMirror.Enabled || len(d.cfg.GithubMirror.Mirrors) != 0 {
|
||||
t.Fatal("explicit disabled/empty mirrors lost")
|
||||
}
|
||||
value, err := d.readProtocolConfig("http")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(value)
|
||||
var cfg struct {
|
||||
Connections int `json:"connections"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.Connections != 3 {
|
||||
t.Fatalf("current protocol config: %s %v", raw, err)
|
||||
}
|
||||
if err := d.initProtocolConfigs(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolConfigDoesNotImportOldExtraSettings(t *testing.T) {
|
||||
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&btProtocol.FetcherManager{}}})
|
||||
d.cfg.DownloaderStoreConfig = &base.DownloaderStoreConfig{
|
||||
Extra: map[string]any{
|
||||
"githubMirror": "obsolete invalid value",
|
||||
"bt": map[string]any{"customTrackers": []string{"udp://old.example:80/announce"}, "autoUpdateTrackers": true},
|
||||
},
|
||||
ProtocolConfig: map[string]any{"bt": map[string]any{"autoUpdateTrackers": false, "listenPort": 6881}},
|
||||
}
|
||||
if err := d.storage.Setup([]string{bucketConfig}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.storage.Close()
|
||||
if err := d.initProtocolConfigs(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !d.cfg.GithubMirror.Enabled || len(d.cfg.GithubMirror.Mirrors) != 4 {
|
||||
t.Fatal("missing root mirror config must use new defaults")
|
||||
}
|
||||
value, err := d.readProtocolConfig("bt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(value)
|
||||
var cfg struct {
|
||||
ListenPort int `json:"listenPort"`
|
||||
AutoUpdateTrackers bool `json:"autoUpdateTrackers"`
|
||||
CustomTrackers []string `json:"customTrackers"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.ListenPort != 6881 || cfg.AutoUpdateTrackers || len(cfg.CustomTrackers) != 0 {
|
||||
t.Fatalf("old extra settings affected protocol config: %s %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolJobsRunAutomaticallyAndRestoreSuccess(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
executed := make(chan fetcher.TriggerSource, 4)
|
||||
manager := func() *testJobManager {
|
||||
return &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Second, Run: func(_ context.Context, source fetcher.TriggerSource) error { executed <- source; return nil }}}
|
||||
}
|
||||
makeDownloader := func() *Downloader {
|
||||
d := NewDownloader(&DownloaderConfig{Storage: NewBoltStorage(dir), StorageDir: dir, TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{manager()}})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
d := makeDownloader()
|
||||
select {
|
||||
case source := <-executed:
|
||||
if source != fetcher.TriggerScheduled {
|
||||
t.Fatal(source)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("overdue job was not run")
|
||||
}
|
||||
entry := d.protocolJobs.jobs["test/refresh"]
|
||||
entry.mu.Lock()
|
||||
run := entry.active
|
||||
entry.mu.Unlock()
|
||||
if run != nil {
|
||||
<-run.done
|
||||
}
|
||||
if err := d.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d = makeDownloader()
|
||||
defer d.Close()
|
||||
state := d.protocolJobs.jobs["test/refresh"].state
|
||||
if state.LastSuccess.IsZero() {
|
||||
t.Fatal("successful execution not restored")
|
||||
}
|
||||
select {
|
||||
case <-executed:
|
||||
t.Fatal("recently successful job ran again on restart")
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
+85
-16
@@ -2,6 +2,7 @@ package download
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -42,6 +43,7 @@ const (
|
||||
bucketTaskStats = "task_stats"
|
||||
// protocol-level shared client state bucket
|
||||
bucketProtocolState = "protocol_state"
|
||||
bucketProtocolJobs = "protocol_jobs"
|
||||
// downloader config bucket
|
||||
bucketConfig = "config"
|
||||
// downloader extension bucket
|
||||
@@ -99,6 +101,9 @@ type Progress struct {
|
||||
}
|
||||
|
||||
type Downloader struct {
|
||||
backgroundCtx context.Context
|
||||
cancelBackground context.CancelFunc
|
||||
protocolJobs *protocolScheduler
|
||||
tempFiles *tempfiles.Scope
|
||||
webviewProfilesLock sync.Mutex
|
||||
webviewProfiles map[string]*extensionWebViewProfile
|
||||
@@ -125,8 +130,10 @@ type Downloader struct {
|
||||
// Key: fullBaseName (e.g., "/path/archive.7z"), Value: taskID that claimed it
|
||||
claimedExtractions sync.Map
|
||||
|
||||
extensions []*Extension
|
||||
blob *internalblob.Registry
|
||||
extensions []*Extension
|
||||
extensionGitLock sync.Mutex
|
||||
extensionGitWG sync.WaitGroup
|
||||
blob *internalblob.Registry
|
||||
}
|
||||
|
||||
func NewDownloader(cfg *DownloaderConfig) *Downloader {
|
||||
@@ -135,11 +142,14 @@ func NewDownloader(cfg *DownloaderConfig) *Downloader {
|
||||
}
|
||||
cfg.Init()
|
||||
|
||||
backgroundCtx, cancelBackground := context.WithCancel(context.Background())
|
||||
d := &Downloader{
|
||||
cfg: cfg,
|
||||
fetcherCache: make(map[string]fetcher.Fetcher),
|
||||
waitTasks: make([]*Task, 0),
|
||||
storage: cfg.Storage,
|
||||
backgroundCtx: backgroundCtx,
|
||||
cancelBackground: cancelBackground,
|
||||
cfg: cfg,
|
||||
fetcherCache: make(map[string]fetcher.Fetcher),
|
||||
waitTasks: make([]*Task, 0),
|
||||
storage: cfg.Storage,
|
||||
|
||||
lock: &sync.Mutex{},
|
||||
configLock: &sync.RWMutex{},
|
||||
@@ -165,7 +175,7 @@ func (d *Downloader) Setup() error {
|
||||
d.blob = internalblob.NewRegistry("")
|
||||
|
||||
// setup storage
|
||||
if err := d.storage.Setup([]string{bucketTask, bucketSave, bucketTaskStats, bucketProtocolState, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil {
|
||||
if err := d.storage.Setup([]string{bucketTask, bucketSave, bucketTaskStats, bucketProtocolState, bucketProtocolJobs, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil {
|
||||
return err
|
||||
}
|
||||
// load config from storage
|
||||
@@ -181,14 +191,13 @@ func (d *Downloader) Setup() error {
|
||||
FirstLoad: true,
|
||||
}
|
||||
}
|
||||
// init default config
|
||||
d.cfg.DownloaderStoreConfig.Init()
|
||||
// init protocol config, if not exist, use default config
|
||||
// Initialize the current backend configuration schema and defaults.
|
||||
if err := d.initProtocolConfigs(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Attach optional protocol state stores after configuration initialization.
|
||||
for _, fm := range d.cfg.FetchManagers {
|
||||
protocol := fm.Name()
|
||||
if _, ok := d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol]; !ok {
|
||||
d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol] = fm.DefaultConfig()
|
||||
}
|
||||
if sfm, ok := fm.(fetcher.StatefulFetcherManager); ok {
|
||||
sfm.SetStateStore(&protocolStateStore{
|
||||
storage: d.storage,
|
||||
@@ -197,6 +206,10 @@ func (d *Downloader) Setup() error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := d.setupProtocolJobs(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// load tasks from storage
|
||||
var tasks []*Task
|
||||
if err = d.storage.List(bucketTask, &tasks); err != nil {
|
||||
@@ -365,6 +378,7 @@ func (d *Downloader) Setup() error {
|
||||
time.Sleep(time.Millisecond * time.Duration(d.cfg.RefreshInterval))
|
||||
}
|
||||
}()
|
||||
d.protocolJobs.start()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1181,7 +1195,14 @@ func (d *Downloader) doDelete(task *Task, force bool) (err error) {
|
||||
}
|
||||
|
||||
func (d *Downloader) Close() error {
|
||||
d.closed.Store(true)
|
||||
if d.closed.Swap(true) {
|
||||
return nil
|
||||
}
|
||||
d.cancelBackground()
|
||||
d.waitForExtensionGit()
|
||||
if d.protocolJobs != nil {
|
||||
d.protocolJobs.close()
|
||||
}
|
||||
d.webviewProxyLock.Lock()
|
||||
if d.webviewProxy != nil {
|
||||
_ = d.webviewProxy.Close()
|
||||
@@ -1336,7 +1357,7 @@ func (d *Downloader) GetTasksByFilter(filter *TaskFilter) []*Task {
|
||||
func (d *Downloader) GetConfig() (*base.DownloaderStoreConfig, error) {
|
||||
d.configLock.RLock()
|
||||
defer d.configLock.RUnlock()
|
||||
return d.cfg.DownloaderStoreConfig, nil
|
||||
return util.DeepClone(d.cfg.DownloaderStoreConfig), nil
|
||||
}
|
||||
|
||||
func (d *Downloader) PutConfig(v *base.DownloaderStoreConfig) error {
|
||||
@@ -1344,12 +1365,60 @@ func (d *Downloader) PutConfig(v *base.DownloaderStoreConfig) error {
|
||||
return errors.New("config is nil")
|
||||
}
|
||||
next := util.DeepClone(v)
|
||||
changedProtocols := []string{}
|
||||
d.configLock.Lock()
|
||||
defer d.configLock.Unlock()
|
||||
if d.closed.Load() {
|
||||
d.configLock.Unlock()
|
||||
return errors.New("downloader is closed")
|
||||
}
|
||||
if next.GithubMirror == nil {
|
||||
next.GithubMirror = util.DeepClone(d.cfg.GithubMirror)
|
||||
}
|
||||
if err := next.GithubMirror.Validate(); err != nil {
|
||||
d.configLock.Unlock()
|
||||
return err
|
||||
}
|
||||
if next.ProtocolConfig == nil {
|
||||
next.ProtocolConfig = map[string]any{}
|
||||
}
|
||||
for name := range next.ProtocolConfig {
|
||||
if _, err := d.protocolManager(name); err != nil {
|
||||
d.configLock.Unlock()
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, fm := range d.cfg.FetchManagers {
|
||||
name := fm.Name()
|
||||
value, exists := next.ProtocolConfig[name]
|
||||
if !exists {
|
||||
value = d.cfg.ProtocolConfig[name]
|
||||
}
|
||||
value, err := preserveProtocolState(value, d.cfg.ProtocolConfig[name])
|
||||
if err == nil {
|
||||
value, err = normalizeProtocol(fm, value)
|
||||
}
|
||||
if err != nil {
|
||||
d.configLock.Unlock()
|
||||
return err
|
||||
}
|
||||
next.ProtocolConfig[name] = value
|
||||
oldJSON, _ := json.Marshal(d.cfg.ProtocolConfig[name])
|
||||
newJSON, _ := json.Marshal(value)
|
||||
if !bytes.Equal(oldJSON, newJSON) {
|
||||
changedProtocols = append(changedProtocols, name)
|
||||
}
|
||||
}
|
||||
if err := d.storage.Put(bucketConfig, "config", next); err != nil {
|
||||
d.configLock.Unlock()
|
||||
return err
|
||||
}
|
||||
d.cfg.DownloaderStoreConfig = next
|
||||
d.configLock.Unlock()
|
||||
if d.protocolJobs != nil {
|
||||
for _, name := range changedProtocols {
|
||||
d.protocolJobs.changed(name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1241,7 +1241,7 @@ func TestDownloader_Protocol_Config(t *testing.T) {
|
||||
"trackerSubscribeUrls": []string{
|
||||
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt",
|
||||
},
|
||||
"trackers": []string{
|
||||
"customTrackers": []string{
|
||||
"udp://tracker.coppersurfer.tk:6969/announce",
|
||||
"udp://tracker.leechers-paradise.org:6969/announce",
|
||||
},
|
||||
@@ -1261,8 +1261,20 @@ func TestDownloader_Protocol_Config(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !test.JsonEqual(storeCfg, newStoreCfg) {
|
||||
t.Errorf("GetConfig() got = %v, want %v", test.ToJson(storeCfg), test.ToJson(newStoreCfg))
|
||||
if newStoreCfg.DownloadDir != storeCfg.DownloadDir || newStoreCfg.Extra["theme"] != "dark" {
|
||||
t.Fatalf("downloader config was not saved: %+v", newStoreCfg)
|
||||
}
|
||||
var savedHTTP struct {
|
||||
Connections int `json:"connections"`
|
||||
}
|
||||
if err := util.MapToStruct(newStoreCfg.ProtocolConfig["http"], &savedHTTP); err != nil || savedHTTP.Connections != 4 {
|
||||
t.Fatalf("HTTP config: %+v %v", savedHTTP, err)
|
||||
}
|
||||
var savedBT struct {
|
||||
CustomTrackers []string `json:"customTrackers"`
|
||||
}
|
||||
if err := util.MapToStruct(newStoreCfg.ProtocolConfig["bt"], &savedBT); err != nil || len(savedBT.CustomTrackers) != 2 {
|
||||
t.Fatalf("BT derived trackers: %+v %v", savedBT, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2002,6 +2014,9 @@ func TestDownloader_ContinueOnStartupUsesContinueAllSemantics(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Extra = map[string]any{"autoStartTasks": true}
|
||||
if err := downloader.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := downloader.ContinueOnStartup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -2010,6 +2025,9 @@ func TestDownloader_ContinueOnStartupUsesContinueAllSemantics(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg.AutoStartTasks = true
|
||||
if err := downloader.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := downloader.ContinueOnStartup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+73
-36
@@ -1,17 +1,19 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/githubmirror"
|
||||
"github.com/GopeedLab/gopeed/internal/logger"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
||||
@@ -196,50 +198,85 @@ func (d *Downloader) getExtension(identity string) *Extension {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Downloader) fetchExtensionByGit(url string, handler func(tempExtPath string, devMode bool) (*Extension, error)) (*Extension, error) {
|
||||
if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
|
||||
url = "https://" + url
|
||||
}
|
||||
// waitForExtensionGit waits for clones and their install/check handlers before
|
||||
// shutdown releases storage and temporary files. Close cancels backgroundCtx first.
|
||||
func (d *Downloader) waitForExtensionGit() {
|
||||
d.extensionGitLock.Lock()
|
||||
d.extensionGitLock.Unlock()
|
||||
d.extensionGitWG.Wait()
|
||||
}
|
||||
|
||||
// resolve project path
|
||||
parentPath, projectPath := path.Split(url)
|
||||
// resolve project name and sub path
|
||||
pathArr := strings.SplitN(projectPath, "#", 2)
|
||||
projectPath = strings.TrimSuffix(pathArr[0], gitSuffix)
|
||||
subPath := ""
|
||||
if len(pathArr) > 1 {
|
||||
subPath = pathArr[1]
|
||||
func (d *Downloader) fetchExtensionByGit(rawURL string, handler func(string, bool) (*Extension, error)) (*Extension, error) {
|
||||
// Pair admission with shutdown so no operation is added after its wait starts.
|
||||
d.extensionGitLock.Lock()
|
||||
if d.closed.Load() {
|
||||
d.extensionGitLock.Unlock()
|
||||
return nil, fmt.Errorf("downloader is closed")
|
||||
}
|
||||
|
||||
// Use unique suffix to avoid concurrent conflicts when multiple git operations
|
||||
// target the same extension (e.g., install and update check happening simultaneously)
|
||||
tempExtDir, err := os.MkdirTemp(d.cfg.TempDir, "gopeed-extension-")
|
||||
d.extensionGitWG.Add(1)
|
||||
d.extensionGitLock.Unlock()
|
||||
defer d.extensionGitWG.Done()
|
||||
ctx := d.backgroundCtx
|
||||
if !strings.HasPrefix(rawURL, "https://") && !strings.HasPrefix(rawURL, "http://") {
|
||||
rawURL = "https://" + rawURL
|
||||
}
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("invalid extension repository URL")
|
||||
}
|
||||
subPath := filepath.FromSlash(parsed.Fragment)
|
||||
if filepath.IsAbs(subPath) || subPath == ".." || strings.HasPrefix(filepath.Clean(subPath), ".."+string(filepath.Separator)) {
|
||||
return nil, fmt.Errorf("invalid extension directory")
|
||||
}
|
||||
parsed.Fragment = ""
|
||||
parsed.Path = strings.TrimSuffix(strings.TrimRight(parsed.Path, "/"), gitSuffix) + gitSuffix
|
||||
officialURL := parsed.String()
|
||||
cfg, err := d.GetConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := d.tempFiles.Track(tempExtDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer d.tempFiles.Remove(tempExtDir)
|
||||
|
||||
proxyOptions := transport.ProxyOptions{}
|
||||
proxyUrl := d.cfg.DownloaderStoreConfig.Proxy.ToUrl()
|
||||
if proxyUrl != nil {
|
||||
proxyOptions.URL = proxyUrl.Scheme + "://" + proxyUrl.Host
|
||||
proxyOptions.Username = proxyUrl.User.Username()
|
||||
proxyOptions.Password, _ = proxyUrl.User.Password()
|
||||
if proxyURL := cfg.Proxy.ToUrl(); proxyURL != nil {
|
||||
proxyOptions.URL = proxyURL.Scheme + "://" + proxyURL.Host
|
||||
if proxyURL.User != nil {
|
||||
proxyOptions.Username = proxyURL.User.Username()
|
||||
proxyOptions.Password, _ = proxyURL.User.Password()
|
||||
}
|
||||
}
|
||||
// clone project to extension temp dir
|
||||
gitUrl := parentPath + projectPath + gitSuffix
|
||||
if _, err := git.PlainClone(tempExtDir, false, &git.CloneOptions{
|
||||
URL: gitUrl,
|
||||
Depth: 1,
|
||||
ProxyOptions: proxyOptions,
|
||||
}); err != nil {
|
||||
tempDir, err := os.MkdirTemp(d.cfg.TempDir, "gopeed-extension-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return handler(filepath.Join(tempExtDir, subPath), false)
|
||||
if err = d.tempFiles.Track(tempDir); err != nil {
|
||||
_ = os.RemoveAll(tempDir)
|
||||
return nil, err
|
||||
}
|
||||
defer d.tempFiles.Remove(tempDir)
|
||||
repoDir := filepath.Join(tempDir, "repo")
|
||||
repo, err := githubmirror.Fallback(ctx, cfg.GithubMirror, officialURL,
|
||||
githubmirror.Options[*git.Repository]{Kind: githubmirror.Repository},
|
||||
func(ctx context.Context, candidate string) (*git.Repository, error) {
|
||||
if err := os.RemoveAll(repoDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return git.PlainCloneContext(ctx, repoDir, false, &git.CloneOptions{URL: candidate, Depth: 1, ProxyOptions: proxyOptions})
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Preserve the canonical source instead of persisting a selected mirror.
|
||||
repoConfig, err := repo.Config()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if remote := repoConfig.Remotes["origin"]; remote != nil {
|
||||
remote.URLs = []string{officialURL}
|
||||
}
|
||||
if err = repo.SetConfig(repoConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return handler(filepath.Join(repoDir, subPath), false)
|
||||
}
|
||||
|
||||
func (d *Downloader) parseExtensionByPath(path string) (*Extension, error) {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cgi"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
// Exercise real Git clone/install/check/update without depending on GitHub or
|
||||
// public mirror availability. The proxy deliberately rejects the official URL.
|
||||
// github.com/gopeed-test/repo is a placeholder: the successful mirror serves
|
||||
// the temporary local repository through git http-backend instead.
|
||||
func newExtensionGitFixture(t *testing.T) (*Downloader, func(string), *atomic.Int32, *atomic.Int32) {
|
||||
t.Helper()
|
||||
git, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
t.Skip("Git executable required for HTTP Git fixture")
|
||||
}
|
||||
root := t.TempDir()
|
||||
bare := filepath.Join(root, "repo.git")
|
||||
work := t.TempDir()
|
||||
command := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(git, args...)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, output)
|
||||
}
|
||||
}
|
||||
command("-c", "init.defaultBranch=main", "init", "--bare", bare)
|
||||
command("clone", bare, work)
|
||||
command("-C", work, "config", "user.name", "Gopeed Test")
|
||||
command("-C", work, "config", "user.email", "[email protected]")
|
||||
publish := func(version string) {
|
||||
t.Helper()
|
||||
directory := filepath.Join(work, "nested", "extension")
|
||||
if err := os.MkdirAll(directory, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := map[string]any{"name": "mirror-test", "author": "test", "title": "Mirror test", "version": version, "repository": map[string]string{"url": "https://github.com/gopeed-test/repo", "directory": "nested/extension"}}
|
||||
body, _ := json.Marshal(manifest)
|
||||
if err := os.WriteFile(filepath.Join(directory, "manifest.json"), body, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command("-C", work, "add", ".")
|
||||
command("-C", work, "commit", "-m", version)
|
||||
command("-C", work, "push", "origin", "HEAD:main")
|
||||
}
|
||||
publish("1.0.0")
|
||||
var official, badMirror atomic.Int32
|
||||
backend := &cgi.Handler{Path: git, Args: []string{"http-backend"}, Env: []string{"GIT_PROJECT_ROOT=" + root, "GIT_HTTP_EXPORT_ALL=1"}}
|
||||
mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/https://github.com/gopeed-test")
|
||||
r.RequestURI = r.URL.RequestURI()
|
||||
backend.ServeHTTP(w, r)
|
||||
}))
|
||||
t.Cleanup(mirror.Close)
|
||||
failed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { badMirror.Add(1); http.Error(w, "mirror offline", 502) }))
|
||||
t.Cleanup(failed.Close)
|
||||
direct := &http.Transport{}
|
||||
t.Cleanup(direct.CloseIdleConnections)
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodConnect || r.URL.Hostname() == "github.com" {
|
||||
official.Add(1)
|
||||
http.Error(w, "official unavailable", 502)
|
||||
return
|
||||
}
|
||||
request := r.Clone(r.Context())
|
||||
request.RequestURI = ""
|
||||
response, err := direct.RoundTrip(request)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 502)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
for key, values := range response.Header {
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(response.StatusCode)
|
||||
_, _ = io.Copy(w, response.Body)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
d := testBackendDownloader(t, &httpProtocol.FetcherManager{})
|
||||
config, _ := d.GetConfig()
|
||||
config.Proxy = &base.DownloaderProxyConfig{Enable: true, Scheme: "http", Host: strings.TrimPrefix(proxy.URL, "http://")}
|
||||
config.GithubMirror = &base.GithubMirrorConfig{Enabled: true, Mirrors: []base.GithubMirror{{Type: "ghProxy", URL: failed.URL}, {Type: "ghProxy", URL: mirror.URL}}}
|
||||
if err := d.PutConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d, publish, &official, &badMirror
|
||||
}
|
||||
func testInstallExtensionGitURL(t *testing.T, raw string) {
|
||||
d, _, official, badMirror := newExtensionGitFixture(t)
|
||||
extension, err := d.InstallExtensionByGit(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if extension.Version != "1.0.0" || extension.Identity != "test@mirror-test" {
|
||||
t.Fatalf("%+v", extension)
|
||||
}
|
||||
if official.Load() == 0 || badMirror.Load() == 0 {
|
||||
t.Fatal("ordered fallback was not exercised")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(d.ExtensionPath(extension), "manifest.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestExtensionGitMirrorInstallAndUpdate(t *testing.T) {
|
||||
d, publish, official, badMirror := newExtensionGitFixture(t)
|
||||
extension, err := d.InstallExtensionByGit("https://github.com/gopeed-test/repo#nested/extension")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publish("1.1.0")
|
||||
version, err := d.UpgradeCheckExtension(extension.Identity)
|
||||
if err != nil || version != "1.1.0" {
|
||||
t.Fatalf("%s %v", version, err)
|
||||
}
|
||||
if err = d.UpgradeExtension(extension.Identity); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := d.GetExtension(extension.Identity)
|
||||
if err != nil || updated.Version != "1.1.0" {
|
||||
t.Fatalf("%+v %v", updated, err)
|
||||
}
|
||||
if updated.Repository.Url != "https://github.com/gopeed-test/repo" {
|
||||
t.Fatal("mirror URL replaced canonical repository")
|
||||
}
|
||||
if official.Load() != 3 || badMirror.Load() != 3 {
|
||||
t.Fatalf("attempts official=%d mirror=%d", official.Load(), badMirror.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionGitShutdownWaitsForHandler(t *testing.T) {
|
||||
d, _, _, _ := newExtensionGitFixture(t)
|
||||
started := make(chan string, 1)
|
||||
release := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
select {
|
||||
case <-release:
|
||||
default:
|
||||
close(release)
|
||||
}
|
||||
})
|
||||
finished := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := d.fetchExtensionByGit("https://github.com/gopeed-test/repo#nested/extension", func(path string, _ bool) (*Extension, error) {
|
||||
started <- path
|
||||
<-release
|
||||
return d.parseExtensionByPath(path)
|
||||
})
|
||||
finished <- err
|
||||
}()
|
||||
var path string
|
||||
select {
|
||||
case path = <-started:
|
||||
case err := <-finished:
|
||||
t.Fatalf("Git operation finished before handler: %v", err)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Git handler did not start")
|
||||
}
|
||||
closed := make(chan error, 1)
|
||||
go func() { closed <- d.Close() }()
|
||||
select {
|
||||
case <-d.backgroundCtx.Done():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("shutdown did not cancel Git context")
|
||||
}
|
||||
select {
|
||||
case err := <-closed:
|
||||
t.Fatalf("shutdown returned before handler finished: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(path, "manifest.json")); err != nil {
|
||||
t.Fatalf("shutdown removed files still used by handler: %v", err)
|
||||
}
|
||||
if _, err := d.InstallExtensionByGit("https://github.com/gopeed-test/repo"); err == nil || !strings.Contains(err.Error(), "downloader is closed") {
|
||||
t.Fatalf("expected new Git operations to be rejected during shutdown: %v", err)
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case err := <-finished:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Git handler did not finish")
|
||||
}
|
||||
select {
|
||||
case err := <-closed:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("shutdown did not finish after Git handler")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
gitclient "github.com/go-git/go-git/v5/plumbing/transport/client"
|
||||
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// go-git exposes a process-wide protocol registry. Register once during
|
||||
// initialization, rather than changing it during concurrent clones. Proxy
|
||||
// settings still come from each clone's ProxyOptions.
|
||||
transport := githttp.NewClient(newExtensionGitHTTPClient(10*time.Second, 60*time.Second))
|
||||
gitclient.InstallProtocol("http", transport)
|
||||
gitclient.InstallProtocol("https", transport)
|
||||
}
|
||||
|
||||
func newExtensionGitHTTPClient(connectTimeout, idleTimeout time.Duration) *http.Client {
|
||||
dialer := &net.Dialer{Timeout: connectTimeout, KeepAlive: 30 * time.Second}
|
||||
// A socket read deadline must apply to one transfer, rather than a shared
|
||||
// HTTP/2 connection carrying traffic from other requests.
|
||||
protocols := new(http.Protocols)
|
||||
protocols.SetHTTP1(true)
|
||||
return &http.Client{Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
conn, err := dialer.DialContext(ctx, network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &gitIdleConn{Conn: conn, timeout: idleTimeout}, nil
|
||||
},
|
||||
TLSHandshakeTimeout: connectTimeout,
|
||||
IdleConnTimeout: idleTimeout,
|
||||
Protocols: protocols,
|
||||
}}
|
||||
}
|
||||
|
||||
type gitIdleConn struct {
|
||||
net.Conn
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func (c *gitIdleConn) Read(p []byte) (int, error) {
|
||||
if err := c.Conn.SetReadDeadline(time.Now().Add(c.timeout)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return c.Conn.Read(p)
|
||||
}
|
||||
|
||||
// Refresh an already-pending read when a pooled HTTP/1 connection starts the
|
||||
// next request; time spent idle in the pool must not consume its read allowance.
|
||||
func (c *gitIdleConn) Write(p []byte) (int, error) {
|
||||
if err := c.Conn.SetReadDeadline(time.Now().Add(c.timeout)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return c.Conn.Write(p)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httptrace"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/githubmirror"
|
||||
)
|
||||
|
||||
func TestExtensionGitHTTPTransportAllowsSlowStreaming(t *testing.T) {
|
||||
const chunks = 24
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
for range chunks {
|
||||
if _, err := io.WriteString(w, "x"); err != nil {
|
||||
return
|
||||
}
|
||||
w.(http.Flusher).Flush()
|
||||
select {
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newExtensionGitHTTPClient(time.Second, time.Second)
|
||||
defer client.CloseIdleConnections()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
// A regularly streaming response lasts longer than the idle allowance.
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil || len(body) != chunks {
|
||||
t.Fatalf("received %d bytes, error=%v", len(body), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionGitHTTPTransportTimesOutStalledReads(t *testing.T) {
|
||||
for _, bodyStarted := range []bool{false, true} {
|
||||
name := "headers"
|
||||
if bodyStarted {
|
||||
name = "body"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if bodyStarted {
|
||||
_, _ = io.WriteString(w, "partial")
|
||||
w.(http.Flusher).Flush()
|
||||
}
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newExtensionGitHTTPClient(time.Second, 100*time.Millisecond)
|
||||
defer client.CloseIdleConnections()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
||||
response, err := client.Do(request)
|
||||
if err == nil {
|
||||
defer response.Body.Close()
|
||||
_, err = io.ReadAll(response.Body)
|
||||
}
|
||||
var timeout net.Error
|
||||
if !errors.As(err, &timeout) || !timeout.Timeout() || ctx.Err() != nil {
|
||||
t.Fatalf("expected idle timeout before parent deadline: error=%v context=%v", err, ctx.Err())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionGitIdleTimeoutFallsBackThroughProxy(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Hostname() == "github.com" {
|
||||
_, _ = io.WriteString(w, "partial")
|
||||
w.(http.Flusher).Flush()
|
||||
<-r.Context().Done()
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, "complete")
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newExtensionGitHTTPClient(time.Second, 100*time.Millisecond)
|
||||
defer client.CloseIdleConnections()
|
||||
proxy, _ := url.Parse(server.URL)
|
||||
client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy)
|
||||
config := &githubmirror.Config{Enabled: true, Mirrors: []githubmirror.Mirror{{Type: "ghProxy", URL: server.URL}}}
|
||||
source := "http://github.com/test/repo.git"
|
||||
var tried []string
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
result, err := githubmirror.Fallback(ctx, config, source, githubmirror.Options[string]{Kind: githubmirror.Repository}, func(ctx context.Context, candidate string) (string, error) {
|
||||
tried = append(tried, candidate)
|
||||
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, candidate, nil)
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(response.Body)
|
||||
return string(body), err
|
||||
})
|
||||
if err != nil || result != "complete" || !reflect.DeepEqual(tried, []string{source, server.URL + "/" + source}) {
|
||||
t.Fatalf("result=%q tried=%v error=%v", result, tried, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionGitHTTPTransportCancelsStalledBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.(http.Flusher).Flush()
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newExtensionGitHTTPClient(time.Second, time.Minute)
|
||||
defer client.CloseIdleConnections()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { _, err := io.ReadAll(response.Body); done <- err }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("parent cancellation did not interrupt the body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionGitHTTPTransportRefreshesReusedConnection(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/slow" {
|
||||
select {
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newExtensionGitHTTPClient(time.Second, time.Second)
|
||||
defer client.CloseIdleConnections()
|
||||
response, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = io.Copy(io.Discard, response.Body)
|
||||
_ = response.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
reused := false
|
||||
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{GotConn: func(info httptrace.GotConnInfo) { reused = info.Reused }})
|
||||
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/slow", nil)
|
||||
response, err = client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil || string(body) != "ok" || !reused {
|
||||
t.Fatalf("reused=%v body=%q error=%v", reused, body, err)
|
||||
}
|
||||
}
|
||||
@@ -1070,54 +1070,15 @@ func TestDownloader_Extension_BlobRecoverOnError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDownloader_InstallExtensionByGit(t *testing.T) {
|
||||
setupDownloader(func(downloader *Downloader) {
|
||||
if _, err := downloader.InstallExtensionByGit("https://github.com/GopeedLab/gopeed-extension-samples#github-release-sample"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/GopeedLab/gopeed/releases",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rr.Res.Files) == 1 {
|
||||
t.Fatal("resolve error")
|
||||
}
|
||||
})
|
||||
testInstallExtensionGitURL(t, "https://github.com/gopeed-test/repo#nested/extension")
|
||||
}
|
||||
|
||||
func TestDownloader_InstallExtensionByGitSimple(t *testing.T) {
|
||||
setupDownloader(func(downloader *Downloader) {
|
||||
if _, err := downloader.InstallExtensionByGit("github.com/GopeedLab/gopeed-extension-samples#github-release-sample"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/GopeedLab/gopeed/releases",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rr.Res.Files) == 1 {
|
||||
t.Fatal("resolve error")
|
||||
}
|
||||
})
|
||||
testInstallExtensionGitURL(t, "github.com/gopeed-test/repo#nested/extension")
|
||||
}
|
||||
|
||||
func TestDownloader_InstallExtensionByGitFull(t *testing.T) {
|
||||
setupDownloader(func(downloader *Downloader) {
|
||||
if _, err := downloader.InstallExtensionByGit("https://github.com/GopeedLab/gopeed-extension-samples.git#github-release-sample"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/GopeedLab/gopeed/releases",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rr.Res.Files) == 1 {
|
||||
t.Fatal("resolve error")
|
||||
}
|
||||
})
|
||||
testInstallExtensionGitURL(t, "https://github.com/gopeed-test/repo.git#nested/extension")
|
||||
}
|
||||
|
||||
func TestDownloader_UpgradeExtension(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/githubmirror"
|
||||
"github.com/GopeedLab/gopeed/internal/httpclient"
|
||||
)
|
||||
|
||||
func (d *Downloader) resourceClient(publicOnly bool) (*http.Client, error) {
|
||||
cfg, err := d.GetConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options := httpclient.Options{
|
||||
Client: httpclient.ClientOptions{Timeout: 30 * time.Second},
|
||||
Transport: httpclient.TransportOptions{Proxy: cfg.Proxy.ToHandler(), TLSHandshakeTimeout: 10 * time.Second},
|
||||
}
|
||||
if publicOnly {
|
||||
// A configured upstream proxy is trusted, but target URLs are still checked.
|
||||
options.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
return validatePublicURL(req.Context(), req.URL.String())
|
||||
}
|
||||
if cfg.Proxy == nil || !cfg.Proxy.Enable {
|
||||
options.Transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips, err := publicIPs(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var last error
|
||||
for _, ip := range ips {
|
||||
conn, err := (&net.Dialer{Timeout: 10 * time.Second}).DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
last = err
|
||||
}
|
||||
return nil, last
|
||||
}
|
||||
}
|
||||
}
|
||||
return httpclient.NewClient(options)
|
||||
}
|
||||
func publicIPs(ctx context.Context, host string) ([]net.IPAddr, error) {
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, errors.New("target has no addresses")
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if !ip.IP.IsGlobalUnicast() || ip.IP.IsPrivate() || ip.IP.IsLoopback() || ip.IP.IsLinkLocalUnicast() || ip.IP.IsUnspecified() {
|
||||
return nil, errors.New("private or local resource target is not allowed")
|
||||
}
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
func validatePublicURL(ctx context.Context, raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u == nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil {
|
||||
return errors.New("invalid resource URL")
|
||||
}
|
||||
_, err = publicIPs(ctx, u.Hostname())
|
||||
return err
|
||||
}
|
||||
func (d *Downloader) fetchPublicResource(ctx context.Context, raw string, limit int64, validate func([]byte) error) ([]byte, error) {
|
||||
return d.fetchResource(ctx, raw, limit, true, validate)
|
||||
}
|
||||
|
||||
// fetchResource races complete, validated responses. A successful HEAD alone
|
||||
// cannot win, and failed candidates never discard a usable official response.
|
||||
func (d *Downloader) fetchResource(ctx context.Context, raw string, limit int64, publicOnly bool, validate func([]byte) error) ([]byte, error) {
|
||||
cfg, err := d.GetConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := d.resourceClient(publicOnly)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.CloseIdleConnections()
|
||||
return githubmirror.Race(ctx, cfg.GithubMirror, raw,
|
||||
githubmirror.Options[[]byte]{Kind: githubmirror.Resource, Timeout: 30 * time.Second},
|
||||
func(ctx context.Context, candidate string) ([]byte, error) {
|
||||
if publicOnly {
|
||||
if err := validatePublicURL(ctx, candidate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, candidate, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("resource returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(body)) > limit {
|
||||
return nil, errors.New("resource exceeds size limit")
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return nil, errors.New("empty resource")
|
||||
}
|
||||
if validate != nil {
|
||||
if err := validate(body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return body, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// githubResourceCandidates accepts official GitHub HTTPS resource URLs only.
|
||||
func githubResourceCandidates(config *githubmirror.Config, raw string) ([]string, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u == nil || u.Scheme != "https" || u.User != nil || u.Port() != "" || u.Fragment != "" || strings.Contains(raw, "#") {
|
||||
return nil, ErrInvalidMirrorURL
|
||||
}
|
||||
u.Host = strings.ToLower(u.Host)
|
||||
switch u.Host {
|
||||
case "github.com", "raw.githubusercontent.com":
|
||||
default:
|
||||
return nil, ErrInvalidMirrorURL
|
||||
}
|
||||
return githubmirror.Candidates(config, u.String(), githubmirror.Resource), nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMirrorProviderNotFound = errors.New("mirror provider not found")
|
||||
ErrInvalidMirrorURL = errors.New("invalid mirror resource URL")
|
||||
)
|
||||
|
||||
// Each provider owns URL validation and candidate generation using its own
|
||||
// configuration. Providers only generate URLs; they must not fetch resources.
|
||||
type mirrorCandidateProvider func(config *base.DownloaderStoreConfig, raw string) ([]string, error)
|
||||
|
||||
// Add providers here without changing the public API or its client transport.
|
||||
var mirrorCandidateProviders = map[string]mirrorCandidateProvider{
|
||||
"github": func(config *base.DownloaderStoreConfig, raw string) ([]string, error) {
|
||||
return githubResourceCandidates(config.GithubMirror, raw)
|
||||
},
|
||||
}
|
||||
|
||||
func (d *Downloader) GetMirrorCandidates(provider, raw string) ([]string, error) {
|
||||
candidates, ok := mirrorCandidateProviders[provider]
|
||||
if !ok {
|
||||
return nil, ErrMirrorProviderNotFound
|
||||
}
|
||||
config, err := d.GetConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return candidates(config, raw)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
)
|
||||
|
||||
func (d *Downloader) protocolManager(name string) (fetcher.FetcherManager, error) {
|
||||
for _, fm := range d.cfg.FetchManagers {
|
||||
if fm.Name() == name {
|
||||
return fm, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnSupportedProtocol, name)
|
||||
}
|
||||
func jsonObject(v any) (map[string]json.RawMessage, error) {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var object map[string]json.RawMessage
|
||||
if err = json.Unmarshal(raw, &object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if object == nil {
|
||||
object = map[string]json.RawMessage{}
|
||||
}
|
||||
return object, nil
|
||||
}
|
||||
func normalizeProtocol(fm fetcher.FetcherManager, v any) (any, error) {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := fm.DefaultConfig()
|
||||
if target == nil {
|
||||
return nil, errors.New("protocol does not expose configuration")
|
||||
}
|
||||
// Managers may return either typed pointers or value/map defaults.
|
||||
valueDefault := reflect.TypeOf(target).Kind() != reflect.Pointer
|
||||
if valueDefault {
|
||||
pointer := reflect.New(reflect.TypeOf(target))
|
||||
defaults, err := json.Marshal(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = json.Unmarshal(defaults, pointer.Interface()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target = pointer.Interface()
|
||||
}
|
||||
if err = json.Unmarshal(raw, target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if normalizer, ok := fm.(fetcher.ConfigNormalizer); ok {
|
||||
raw, err = json.Marshal(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizer.NormalizeConfig(raw)
|
||||
}
|
||||
if valueDefault {
|
||||
return reflect.ValueOf(target).Elem().Interface(), nil
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// preserveProtocolState ignores all client-supplied state, including null or
|
||||
// malformed state values, before decoding/validating the writable config.
|
||||
func preserveProtocolState(next, previous any) (any, error) {
|
||||
object, err := jsonObject(next)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := jsonObject(previous)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Match encoding/json field folding as well as the canonical spelling.
|
||||
for key := range object {
|
||||
if strings.EqualFold(key, "state") {
|
||||
delete(object, key)
|
||||
}
|
||||
}
|
||||
if state, ok := before["state"]; ok {
|
||||
object["state"] = state
|
||||
}
|
||||
return object, nil
|
||||
}
|
||||
func (d *Downloader) readProtocolConfig(name string) (any, error) {
|
||||
if _, err := d.protocolManager(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.configLock.RLock()
|
||||
defer d.configLock.RUnlock()
|
||||
value := d.cfg.ProtocolConfig[name]
|
||||
return *util.DeepClone(&value), nil
|
||||
}
|
||||
|
||||
// updateProtocolState is the only internal write path for backend-owned state.
|
||||
// It never publishes callback edits outside the reserved state section.
|
||||
func (d *Downloader) updateProtocolState(name string, change func(config, state json.RawMessage) (any, error)) error {
|
||||
fm, err := d.protocolManager(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.configLock.Lock()
|
||||
defer d.configLock.Unlock()
|
||||
if d.closed.Load() {
|
||||
return errors.New("downloader is closed")
|
||||
}
|
||||
next := util.DeepClone(d.cfg.DownloaderStoreConfig)
|
||||
object, err := jsonObject(next.ProtocolConfig[name])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, ok := object["state"]
|
||||
if !ok {
|
||||
return fmt.Errorf("protocol %s does not expose persistent state", name)
|
||||
}
|
||||
raw, err := json.Marshal(object)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := change(raw, state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Keep the transaction's original config isolated from callback mutations.
|
||||
object["state"], err = json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
normalized, err := normalizeProtocol(fm, object)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next.ProtocolConfig[name] = normalized
|
||||
if err = d.storage.Put(bucketConfig, "config", next); err != nil {
|
||||
return err
|
||||
}
|
||||
d.cfg.DownloaderStoreConfig.ProtocolConfig = next.ProtocolConfig
|
||||
return nil
|
||||
}
|
||||
|
||||
// initProtocolConfigs loads the current schema and initializes missing defaults.
|
||||
// This release intentionally does not import settings from older config layouts.
|
||||
func (d *Downloader) initProtocolConfigs() error {
|
||||
cfg := d.cfg.DownloaderStoreConfig
|
||||
cfg.Init()
|
||||
if err := cfg.GithubMirror.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, fm := range d.cfg.FetchManagers {
|
||||
value, ok := cfg.ProtocolConfig[fm.Name()]
|
||||
if !ok {
|
||||
value = map[string]any{}
|
||||
}
|
||||
normalized, err := normalizeProtocol(fm, value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s config: %w", fm.Name(), err)
|
||||
}
|
||||
cfg.ProtocolConfig[fm.Name()] = normalized
|
||||
}
|
||||
return d.storage.Put(bucketConfig, "config", cfg)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
)
|
||||
|
||||
var ErrProtocolJobNotFound = errors.New("protocol job not found")
|
||||
var jobNamePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||
|
||||
type protocolJobState struct {
|
||||
LastSuccess time.Time `json:"lastSuccess"`
|
||||
Failures int `json:"failures"`
|
||||
}
|
||||
type protocolJobRun struct {
|
||||
done chan struct{}
|
||||
err error
|
||||
source fetcher.TriggerSource
|
||||
}
|
||||
type registeredJob struct {
|
||||
protocol string
|
||||
spec fetcher.CronJob
|
||||
mu sync.Mutex
|
||||
state protocolJobState
|
||||
active *protocolJobRun
|
||||
next time.Time
|
||||
wake chan struct{}
|
||||
force atomic.Bool
|
||||
}
|
||||
type protocolScheduler struct {
|
||||
d *Downloader
|
||||
jobs map[string]*registeredJob
|
||||
wg sync.WaitGroup
|
||||
// gate prevents adding work while shutdown waits for it.
|
||||
gate sync.Mutex
|
||||
stopped bool
|
||||
}
|
||||
|
||||
func (d *Downloader) setupProtocolJobs() error {
|
||||
scheduler := &protocolScheduler{d: d, jobs: map[string]*registeredJob{}}
|
||||
for _, fm := range d.cfg.FetchManagers {
|
||||
provider, ok := fm.(fetcher.CronProvider)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name := fm.Name()
|
||||
host := fetcher.ProtocolHost{
|
||||
Read: func(dst any) error {
|
||||
value, err := d.readProtocolConfig(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return util.MapToStruct(value, dst)
|
||||
},
|
||||
UpdateState: func(change func(config, state json.RawMessage) (any, error)) error {
|
||||
return d.updateProtocolState(name, change)
|
||||
},
|
||||
Fetch: func(ctx context.Context, raw string, limit int64, validate func([]byte) error) ([]byte, error) {
|
||||
return d.fetchPublicResource(ctx, raw, limit, validate)
|
||||
},
|
||||
}
|
||||
for _, spec := range provider.CronJobs(host) {
|
||||
if !jobNamePattern.MatchString(spec.Name) || spec.Every <= 0 || spec.Timeout <= 0 || spec.Run == nil {
|
||||
return fmt.Errorf("invalid protocol job: %s/%s", name, spec.Name)
|
||||
}
|
||||
key := name + "/" + spec.Name
|
||||
if _, ok := scheduler.jobs[key]; ok {
|
||||
return fmt.Errorf("duplicate protocol job: %s", key)
|
||||
}
|
||||
entry := ®isteredJob{protocol: name, spec: spec, wake: make(chan struct{}, 1)}
|
||||
if _, err := d.storage.Get(bucketProtocolJobs, key, &entry.state); err != nil {
|
||||
return err
|
||||
}
|
||||
scheduler.jobs[key] = entry
|
||||
}
|
||||
}
|
||||
d.protocolJobs = scheduler
|
||||
return nil
|
||||
}
|
||||
func (s *protocolScheduler) start() {
|
||||
for key, entry := range s.jobs {
|
||||
s.wg.Add(1)
|
||||
go s.loop(key, entry)
|
||||
}
|
||||
}
|
||||
func (s *protocolScheduler) signal(entry *registeredJob) {
|
||||
select {
|
||||
case entry.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
func (s *protocolScheduler) changed(protocol string) {
|
||||
for _, entry := range s.jobs {
|
||||
if protocol == "" || entry.protocol == protocol {
|
||||
entry.force.Store(true)
|
||||
s.signal(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
func (s *protocolScheduler) loop(key string, entry *registeredJob) {
|
||||
defer s.wg.Done()
|
||||
entry.mu.Lock()
|
||||
if entry.next.IsZero() {
|
||||
entry.next = entry.state.LastSuccess.Add(entry.spec.Every)
|
||||
if entry.next.Before(time.Now()) {
|
||||
entry.next = time.Now().Add(time.Second)
|
||||
}
|
||||
}
|
||||
next := entry.next
|
||||
entry.mu.Unlock()
|
||||
timer := time.NewTimer(time.Until(next))
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.d.backgroundCtx.Done():
|
||||
return
|
||||
case <-entry.wake:
|
||||
entry.mu.Lock()
|
||||
next = entry.next
|
||||
entry.mu.Unlock()
|
||||
if entry.force.Swap(false) {
|
||||
next = time.Now()
|
||||
}
|
||||
case <-timer.C:
|
||||
_ = s.trigger(s.d.backgroundCtx, key, fetcher.TriggerScheduled)
|
||||
entry.mu.Lock()
|
||||
next = entry.next
|
||||
entry.mu.Unlock()
|
||||
}
|
||||
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer.Reset(max(time.Until(next), time.Millisecond))
|
||||
}
|
||||
}
|
||||
func (s *protocolScheduler) trigger(ctx context.Context, key string, source fetcher.TriggerSource) error {
|
||||
entry, ok := s.jobs[key]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrProtocolJobNotFound, key)
|
||||
}
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.gate.Lock()
|
||||
if s.stopped || s.d.closed.Load() {
|
||||
s.gate.Unlock()
|
||||
return errors.New("downloader is closed")
|
||||
}
|
||||
entry.mu.Lock()
|
||||
run := entry.active
|
||||
if run == nil {
|
||||
run = &protocolJobRun{done: make(chan struct{}), source: source}
|
||||
entry.active = run
|
||||
s.wg.Add(1)
|
||||
go s.execute(key, entry, run, source)
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
s.gate.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-run.done:
|
||||
// A manual request must still run when the scheduled attempt it
|
||||
// joined was skipped (for example, automatic refresh is disabled).
|
||||
if source == fetcher.TriggerManual && run.source == fetcher.TriggerScheduled && errors.Is(run.err, fetcher.ErrJobSkipped) {
|
||||
continue
|
||||
}
|
||||
return run.err
|
||||
}
|
||||
}
|
||||
}
|
||||
func (s *protocolScheduler) execute(key string, entry *registeredJob, run *protocolJobRun, source fetcher.TriggerSource) {
|
||||
defer s.wg.Done()
|
||||
ctx, cancel := context.WithTimeout(s.d.backgroundCtx, entry.spec.Timeout)
|
||||
defer cancel()
|
||||
err := func() (err error) {
|
||||
defer func() {
|
||||
if v := recover(); v != nil {
|
||||
err = fmt.Errorf("protocol job panic: %v", v)
|
||||
}
|
||||
}()
|
||||
return entry.spec.Run(ctx, source)
|
||||
}()
|
||||
if err == nil {
|
||||
err = ctx.Err()
|
||||
}
|
||||
entry.mu.Lock()
|
||||
switch {
|
||||
case err == nil:
|
||||
entry.state.LastSuccess = time.Now().UTC()
|
||||
entry.state.Failures = 0
|
||||
case errors.Is(err, fetcher.ErrJobSkipped), errors.Is(err, fetcher.ErrConfigChanged), errors.Is(err, context.Canceled):
|
||||
default:
|
||||
entry.state.Failures++
|
||||
}
|
||||
if storeErr := s.d.storage.Put(bucketProtocolJobs, key, entry.state); storeErr != nil {
|
||||
err = errors.Join(err, storeErr)
|
||||
}
|
||||
switch {
|
||||
case err == nil:
|
||||
entry.next = entry.state.LastSuccess.Add(entry.spec.Every)
|
||||
case errors.Is(err, fetcher.ErrJobSkipped):
|
||||
entry.next = time.Now().Add(entry.spec.Every)
|
||||
case errors.Is(err, fetcher.ErrConfigChanged):
|
||||
entry.next = time.Now().Add(time.Second)
|
||||
default:
|
||||
retry := time.Minute * time.Duration(1<<min(max(entry.state.Failures-1, 0), 6))
|
||||
entry.next = time.Now().Add(retry)
|
||||
}
|
||||
run.err = err
|
||||
entry.active = nil
|
||||
close(run.done)
|
||||
entry.mu.Unlock()
|
||||
if err != nil && !errors.Is(err, fetcher.ErrJobSkipped) && !errors.Is(err, context.Canceled) {
|
||||
s.d.Logger.Warn().Err(err).Str("job", key).Str("source", string(source)).Msg("protocol job failed")
|
||||
}
|
||||
if source == fetcher.TriggerManual {
|
||||
s.signal(entry)
|
||||
}
|
||||
}
|
||||
func (s *protocolScheduler) close() {
|
||||
s.gate.Lock()
|
||||
s.stopped = true
|
||||
s.gate.Unlock()
|
||||
s.wg.Wait()
|
||||
}
|
||||
func (d *Downloader) TriggerProtocolJob(ctx context.Context, protocol, name string) error {
|
||||
if d.protocolJobs == nil {
|
||||
return ErrProtocolJobNotFound
|
||||
}
|
||||
err := d.protocolJobs.trigger(ctx, protocol+"/"+name, fetcher.TriggerManual)
|
||||
if errors.Is(err, fetcher.ErrJobSkipped) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
)
|
||||
|
||||
type jobWaitContext struct {
|
||||
context.Context
|
||||
waiting chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (c *jobWaitContext) Done() <-chan struct{} {
|
||||
c.once.Do(func() { close(c.waiting) })
|
||||
return c.Context.Done()
|
||||
}
|
||||
|
||||
func TestManualProtocolJobRunsAfterJoinedScheduledSkip(t *testing.T) {
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var manualCalls atomic.Int32
|
||||
manager := &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Second,
|
||||
Run: func(ctx context.Context, source fetcher.TriggerSource) error {
|
||||
if source == fetcher.TriggerManual {
|
||||
manualCalls.Add(1)
|
||||
return nil
|
||||
}
|
||||
close(entered)
|
||||
select {
|
||||
case <-release:
|
||||
return fetcher.ErrJobSkipped
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
},
|
||||
}}
|
||||
d := testBackendDownloader(t, manager)
|
||||
scheduled := make(chan error, 1)
|
||||
go func() {
|
||||
scheduled <- d.protocolJobs.trigger(context.Background(), "test/refresh", fetcher.TriggerScheduled)
|
||||
}()
|
||||
<-entered
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
waiter := &jobWaitContext{Context: ctx, waiting: make(chan struct{})}
|
||||
manual := make(chan error, 1)
|
||||
go func() { manual <- d.TriggerProtocolJob(waiter, "test", "refresh") }()
|
||||
<-waiter.waiting
|
||||
close(release)
|
||||
if err := <-scheduled; !errors.Is(err, fetcher.ErrJobSkipped) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-manual; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if manualCalls.Load() != 1 {
|
||||
t.Fatal("manual refresh was swallowed by skipped automatic work")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualProtocolJobFailureKeepsScheduledBackoff(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
failure := errors.New("offline")
|
||||
manager := &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Second,
|
||||
Run: func(context.Context, fetcher.TriggerSource) error { calls.Add(1); return failure },
|
||||
}}
|
||||
d := testBackendDownloader(t, manager)
|
||||
if err := d.TriggerProtocolJob(context.Background(), "test", "refresh"); !errors.Is(err, failure) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := d.protocolJobs.jobs["test/refresh"]
|
||||
entry.mu.Lock()
|
||||
next := entry.next
|
||||
entry.mu.Unlock()
|
||||
if time.Until(next) < 59*time.Second {
|
||||
t.Fatal("manual failure did not schedule retry backoff")
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("manual failure immediately retried %d times", calls.Load())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
btProtocol "github.com/GopeedLab/gopeed/internal/protocol/bt"
|
||||
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
// State tests initialize config without starting network clients or cron loops.
|
||||
func protocolConfigDownloader(t *testing.T, storage Storage, managers ...fetcher.FetcherManager) *Downloader {
|
||||
t.Helper()
|
||||
if storage == nil {
|
||||
storage = NewMemStorage()
|
||||
}
|
||||
d := NewDownloader(&DownloaderConfig{Storage: storage, StorageDir: t.TempDir(), FetchManagers: managers})
|
||||
d.cfg.DownloaderStoreConfig = &base.DownloaderStoreConfig{}
|
||||
if err := storage.Setup([]string{bucketConfig}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.cancelBackground(); _ = storage.Close() })
|
||||
if err := d.initProtocolConfigs(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
func protocolObject(t *testing.T, d *Downloader, name string) map[string]any {
|
||||
t.Helper()
|
||||
cfg, err := d.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(cfg.ProtocolConfig[name])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
func configureBT(t *testing.T, d *Downloader, urls ...string) {
|
||||
t.Helper()
|
||||
cfg, _ := d.GetConfig()
|
||||
cfg.ProtocolConfig["bt"] = map[string]any{"autoUpdateTrackers": false, "trackerSubscribeUrls": urls, "customTrackers": []string{"udp://custom.example:80/announce"}}
|
||||
if err := d.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func seedBTState(t *testing.T, d *Downloader) {
|
||||
t.Helper()
|
||||
if err := d.updateProtocolState("bt", func(_, _ json.RawMessage) (any, error) {
|
||||
return map[string]any{
|
||||
"trackerSources": map[string][]string{"https://example.com/list": {"udp://cached.example:80/announce"}},
|
||||
"trackerSubscriptionStatuses": map[string]any{"https://example.com/list": map[string]any{"success": true, "trackerCount": 1, "lastFetchTime": time.Date(2026, 10, 19, 1, 2, 3, 0, time.UTC)}},
|
||||
"lastTrackerUpdateTime": time.Date(2026, 10, 19, 1, 2, 3, 0, time.UTC),
|
||||
}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullConfigValidatesProtocolsAndPreservesExplicitValues(t *testing.T) {
|
||||
d := protocolConfigDownloader(t, nil, &httpProtocol.FetcherManager{}, &btProtocol.FetcherManager{})
|
||||
configureBT(t, d)
|
||||
cfg, _ := d.GetConfig()
|
||||
cfg.ProtocolConfig["http"] = map[string]any{"connections": 0, "useServerCtime": false}
|
||||
if err := d.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := protocolObject(t, d, "http")
|
||||
if got["connections"] != float64(0) || got["useServerCtime"] != false {
|
||||
t.Fatal(got)
|
||||
}
|
||||
for _, invalid := range []any{map[string]any{"connections": "bad"}, map[string]any{"useServerCtime": "bad"}} {
|
||||
cfg, _ := d.GetConfig()
|
||||
cfg.ProtocolConfig["http"] = invalid
|
||||
if err := d.PutConfig(cfg); err == nil {
|
||||
t.Fatalf("invalid protocol config accepted: %v", invalid)
|
||||
}
|
||||
}
|
||||
cfg, _ = d.GetConfig()
|
||||
cfg.DownloadDir = "must-not-publish"
|
||||
cfg.ProtocolConfig["bt"] = map[string]any{"listenPort": -1}
|
||||
if err := d.PutConfig(cfg); err == nil {
|
||||
t.Fatal("invalid BT config accepted")
|
||||
}
|
||||
saved, _ := d.GetConfig()
|
||||
if saved.DownloadDir == "must-not-publish" {
|
||||
t.Fatal("invalid full PUT partially published")
|
||||
}
|
||||
cfg, _ = d.GetConfig()
|
||||
cfg.ProtocolConfig["unknown"] = map[string]any{"state": map[string]any{"injected": true}}
|
||||
if err := d.PutConfig(cfg); err == nil {
|
||||
t.Fatal("unregistered protocol accepted")
|
||||
}
|
||||
// Returned configs are detached snapshots.
|
||||
snapshot, _ := d.GetConfig()
|
||||
snapshot.ProtocolConfig["http"] = map[string]any{"connections": 999}
|
||||
if !reflect.DeepEqual(protocolObject(t, d, "http"), got) {
|
||||
t.Fatal("snapshot changed live config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolConfigIgnoresUnknownFields(t *testing.T) {
|
||||
for _, mode := range []string{"stored", "put"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
d := protocolConfigDownloader(t, nil, &btProtocol.FetcherManager{})
|
||||
cfg, _ := d.GetConfig()
|
||||
cfg.ProtocolConfig["bt"] = map[string]any{
|
||||
"listenPort": 6881,
|
||||
"autoUpdateTrackers": false,
|
||||
"trackers": []string{"udp://obsolete.example:80/announce"},
|
||||
"state": map[string]any{"obsolete": true},
|
||||
}
|
||||
if mode == "stored" {
|
||||
if err := d.storage.Put(bucketConfig, "config", cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var restored base.DownloaderStoreConfig
|
||||
if exists, err := d.storage.Get(bucketConfig, "config", &restored); err != nil || !exists {
|
||||
t.Fatalf("load stored config: %v %v", exists, err)
|
||||
}
|
||||
d.cfg.DownloaderStoreConfig = &restored
|
||||
if err := d.initProtocolConfigs(); err != nil {
|
||||
t.Fatalf("load config containing removed fields: %v", err)
|
||||
}
|
||||
} else if err := d.PutConfig(cfg); err != nil {
|
||||
t.Fatalf("save config containing unknown fields: %v", err)
|
||||
}
|
||||
actual := protocolObject(t, d, "bt")
|
||||
if actual["listenPort"] != float64(6881) || actual["autoUpdateTrackers"] != false {
|
||||
t.Fatalf("known fields changed: %v", actual)
|
||||
}
|
||||
if _, exists := actual["trackers"]; exists {
|
||||
t.Fatal("removed field retained")
|
||||
}
|
||||
if len(actual["customTrackers"].([]any)) != 0 {
|
||||
t.Fatal("removed trackers migrated into custom trackers")
|
||||
}
|
||||
if _, exists := actual["state"].(map[string]any)["obsolete"]; exists {
|
||||
t.Fatal("unknown state field retained")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTStateSurvivesOmittedNullForgedAndStaleFullConfig(t *testing.T) {
|
||||
for _, mode := range []string{"omitted", "null", "forged", "wrong-type", "stale", "omitted-protocol", "omitted-timestamp", "case-variant", "unicode-variant"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
d := protocolConfigDownloader(t, nil, &btProtocol.FetcherManager{})
|
||||
configureBT(t, d, "https://example.com/list")
|
||||
stale, _ := d.GetConfig()
|
||||
seedBTState(t, d)
|
||||
expected := protocolObject(t, d, "bt")["state"]
|
||||
cfg, _ := d.GetConfig()
|
||||
bt := protocolObject(t, d, "bt")
|
||||
switch mode {
|
||||
case "omitted":
|
||||
delete(bt, "state")
|
||||
case "null":
|
||||
bt["state"] = nil
|
||||
case "forged":
|
||||
bt["state"] = map[string]any{"lastTrackerUpdateTime": "2000-01-01T00:00:00Z", "trackerSources": map[string]any{}, "unknown": true}
|
||||
case "wrong-type":
|
||||
bt["state"] = "ignore this before decoding"
|
||||
case "stale":
|
||||
cfg = stale
|
||||
bt = map[string]any{"autoUpdateTrackers": false, "trackerSubscribeUrls": []string{"https://example.com/list"}, "customTrackers": []string{"udp://custom.example:80/announce"}, "state": map[string]any{}}
|
||||
case "omitted-protocol":
|
||||
delete(cfg.ProtocolConfig, "bt")
|
||||
case "case-variant":
|
||||
bt["STATE"] = "ignore this alias"
|
||||
case "unicode-variant":
|
||||
bt["ſtate"] = "ignore Unicode case folding"
|
||||
case "omitted-timestamp":
|
||||
bt["state"] = map[string]any{"trackerSources": map[string]any{}}
|
||||
}
|
||||
if mode != "omitted-protocol" {
|
||||
bt["listenPort"] = 6881
|
||||
cfg.ProtocolConfig["bt"] = bt
|
||||
}
|
||||
if err := d.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual := protocolObject(t, d, "bt")
|
||||
if !reflect.DeepEqual(actual["state"], expected) {
|
||||
t.Fatalf("backend state replaced: %v", actual)
|
||||
}
|
||||
if _, exists := actual["trackers"]; exists {
|
||||
t.Fatal("derived tracker list persisted")
|
||||
}
|
||||
if mode != "omitted-protocol" && actual["listenPort"] != float64(6881) {
|
||||
t.Fatal("writable config did not change")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTConfigRemovalPrunesOnlyRemovedState(t *testing.T) {
|
||||
d := protocolConfigDownloader(t, nil, &btProtocol.FetcherManager{})
|
||||
configureBT(t, d, "https://example.com/list")
|
||||
seedBTState(t, d)
|
||||
configureBT(t, d, "https://new.example/list")
|
||||
state := protocolObject(t, d, "bt")["state"].(map[string]any)
|
||||
if len(state["trackerSources"].(map[string]any)) != 0 || len(state["trackerSubscriptionStatuses"].(map[string]any)) != 0 {
|
||||
t.Fatal("removed subscription retained state")
|
||||
}
|
||||
}
|
||||
|
||||
type counterState struct {
|
||||
Count int `json:"count"`
|
||||
Other int `json:"other"`
|
||||
}
|
||||
type counterConfig struct {
|
||||
fetcher.ConfigState[counterState]
|
||||
Writable int `json:"writable"`
|
||||
}
|
||||
type counterManager struct{ httpProtocol.FetcherManager }
|
||||
|
||||
func (*counterManager) Name() string { return "counter" }
|
||||
func (*counterManager) DefaultConfig() any { return &counterConfig{Writable: 7} }
|
||||
func counterHost(d *Downloader) fetcher.ProtocolHost {
|
||||
return fetcher.ProtocolHost{UpdateState: func(change func(config, state json.RawMessage) (any, error)) error {
|
||||
return d.updateProtocolState("counter", change)
|
||||
}}
|
||||
}
|
||||
|
||||
func TestProtocolUpdateStateCommitsOnlyStateAndUsesLatestConfig(t *testing.T) {
|
||||
d := protocolConfigDownloader(t, nil, &counterManager{})
|
||||
host := counterHost(d)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := fetcher.UpdateState[counterConfig, counterState](host, func(cfg *counterConfig, state *counterState) error { cfg.Writable = 999; state.Count++; return nil }); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
actual := protocolObject(t, d, "counter")
|
||||
if actual["writable"] != float64(7) || actual["state"].(map[string]any)["count"] != float64(20) {
|
||||
t.Fatal(actual)
|
||||
}
|
||||
// Rejecting a callback must not publish its partial mutation.
|
||||
err := fetcher.UpdateState[counterConfig, counterState](host, func(_ *counterConfig, state *counterState) error { state.Count = 1000; return errors.New("reject") })
|
||||
if err == nil || !reflect.DeepEqual(protocolObject(t, d, "counter"), actual) {
|
||||
t.Fatal("failed callback published state")
|
||||
}
|
||||
if err := d.updateProtocolState("counter", func(_, _ json.RawMessage) (any, error) { return map[string]any{"count": "bad"}, nil }); err == nil {
|
||||
t.Fatal("invalid state accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolStateAndFullPutDoNotLoseConcurrentUpdates(t *testing.T) {
|
||||
d := protocolConfigDownloader(t, nil, &counterManager{})
|
||||
snapshot, _ := d.GetConfig()
|
||||
snapshot.ProtocolConfig["counter"] = map[string]any{"writable": 21}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := d.PutConfig(snapshot); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := fetcher.UpdateState[counterConfig, counterState](counterHost(d), func(cfg *counterConfig, state *counterState) error { state.Count++; return nil }); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
actual := protocolObject(t, d, "counter")
|
||||
if actual["writable"] != float64(21) || actual["state"].(map[string]any)["count"] != float64(20) {
|
||||
t.Fatal(actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTSubscriptionStatusesSurviveStorageRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
storage := NewBoltStorage(dir)
|
||||
d := protocolConfigDownloader(t, storage, &btProtocol.FetcherManager{})
|
||||
configureBT(t, d, "https://example.com/list")
|
||||
seedBTState(t, d)
|
||||
expected := protocolObject(t, d, "bt")["state"]
|
||||
if err := storage.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened := NewBoltStorage(dir)
|
||||
if err := reopened.Setup([]string{bucketConfig}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
var persisted base.DownloaderStoreConfig
|
||||
if exists, err := reopened.Get(bucketConfig, "config", &persisted); err != nil || !exists {
|
||||
t.Fatalf("reload persisted config: %v %v", exists, err)
|
||||
}
|
||||
raw, _ := json.Marshal(persisted.ProtocolConfig["bt"])
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(cfg["state"], expected) {
|
||||
t.Fatalf("state lost after reopening storage: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
type rejectedConfigStorage struct{ Storage }
|
||||
|
||||
func (*rejectedConfigStorage) Put(string, string, any) error {
|
||||
return errors.New("storage unavailable")
|
||||
}
|
||||
|
||||
func TestProtocolStateAndPutStorageFailureDoNotPublish(t *testing.T) {
|
||||
d := protocolConfigDownloader(t, nil, &counterManager{})
|
||||
before := protocolObject(t, d, "counter")
|
||||
d.storage = &rejectedConfigStorage{Storage: d.storage}
|
||||
err := fetcher.UpdateState[counterConfig, counterState](counterHost(d), func(_ *counterConfig, state *counterState) error { state.Count = 99; return nil })
|
||||
if err == nil || !reflect.DeepEqual(protocolObject(t, d, "counter"), before) {
|
||||
t.Fatal("failed state persistence published a change")
|
||||
}
|
||||
cfg, _ := d.GetConfig()
|
||||
cfg.ProtocolConfig["counter"] = map[string]any{"writable": 99}
|
||||
if err := d.PutConfig(cfg); err == nil || !reflect.DeepEqual(protocolObject(t, d, "counter"), before) {
|
||||
t.Fatal("failed config persistence published a change")
|
||||
}
|
||||
}
|
||||
@@ -191,13 +191,16 @@ func ensureScriptExecutable(t *testing.T, scriptPath string) {
|
||||
}
|
||||
|
||||
func setupScriptTest(t *testing.T, fn func(downloader *Downloader)) {
|
||||
defaultDownloader.Setup()
|
||||
defaultDownloader.cfg.StorageDir = ".test_storage"
|
||||
defaultDownloader.cfg.DownloadDir = ".test_download"
|
||||
defer func() {
|
||||
defaultDownloader.Clear()
|
||||
os.RemoveAll(defaultDownloader.cfg.StorageDir)
|
||||
os.RemoveAll(defaultDownloader.cfg.DownloadDir)
|
||||
}()
|
||||
fn(defaultDownloader)
|
||||
t.Helper()
|
||||
// Each test owns its runtime; a closed Downloader cannot be reused.
|
||||
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir()})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := d.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
fn(d)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -858,13 +857,16 @@ func TestWebhook_TriggerOnPause(t *testing.T) {
|
||||
}
|
||||
|
||||
func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) {
|
||||
defaultDownloader.Setup()
|
||||
defaultDownloader.cfg.StorageDir = ".test_storage"
|
||||
defaultDownloader.cfg.DownloadDir = ".test_download"
|
||||
defer func() {
|
||||
defaultDownloader.Clear()
|
||||
os.RemoveAll(defaultDownloader.cfg.StorageDir)
|
||||
os.RemoveAll(defaultDownloader.cfg.DownloadDir)
|
||||
}()
|
||||
fn(defaultDownloader)
|
||||
t.Helper()
|
||||
// Each test owns its runtime; a closed Downloader cannot be reused.
|
||||
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir()})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := d.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
fn(d)
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
@@ -337,43 +335,6 @@ func UpdateExtension(w http.ResponseWriter, r *http.Request) {
|
||||
WriteJson(w, model.NewNilResult())
|
||||
}
|
||||
|
||||
func DoProxy(w http.ResponseWriter, r *http.Request) {
|
||||
target := r.Header.Get("X-Target-Uri")
|
||||
if target == "" {
|
||||
writeError(w, "param invalid: X-Target-Uri")
|
||||
return
|
||||
}
|
||||
targetUrl, err := url.Parse(target)
|
||||
if err != nil {
|
||||
writeError(w, err.Error())
|
||||
return
|
||||
}
|
||||
r.RequestURI = ""
|
||||
r.URL = targetUrl
|
||||
r.Host = targetUrl.Host
|
||||
r.Header.Del("Cookie")
|
||||
r.Header.Del("X-Api-Token")
|
||||
r.Header.Del("X-Target-Uri")
|
||||
resp, err := http.DefaultClient.Do(r)
|
||||
if err != nil {
|
||||
writeError(w, err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for k, vv := range resp.Header {
|
||||
for _, v := range vv {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
buf, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
writeError(w, err.Error())
|
||||
return
|
||||
}
|
||||
w.Write(buf)
|
||||
}
|
||||
|
||||
func GetStats(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
taskId := vars["id"]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package model
|
||||
|
||||
type MirrorCandidatesReq struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type MirrorCandidatesResp struct {
|
||||
URLs []string `json:"urls"`
|
||||
}
|
||||
+31
-28
@@ -216,7 +216,6 @@ func buildServer(startCfg *model.StartConfig, continueOnStartup bool) (*http.Ser
|
||||
WriteStatusJson(w, response.StatusCode, response.Body)
|
||||
})
|
||||
}
|
||||
r.Path("/api/web/proxy").HandlerFunc(DoProxy)
|
||||
if startCfg.MCPEnable {
|
||||
r.Path("/mcp").Handler(mcpserver.NewHandler(Downloader))
|
||||
} else {
|
||||
@@ -231,35 +230,39 @@ func buildServer(startCfg *model.StartConfig, continueOnStartup bool) (*http.Ser
|
||||
if enableWebAuth {
|
||||
webSessions = newWebSessionStore()
|
||||
}
|
||||
if startCfg.WebEnable {
|
||||
if enableWebAuth {
|
||||
r.Methods(http.MethodPost).Path("/api/web/login").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var loginReq model.WebAuth
|
||||
if ReadJson(r, w, &loginReq) {
|
||||
if loginReq.Username == startCfg.WebAuth.Username && loginReq.Password == startCfg.WebAuth.Password {
|
||||
sessionID, expiresAt, err := webSessions.create()
|
||||
if err != nil {
|
||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: webAuthCookieName,
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
MaxAge: int(webAuthSessionTTL.Seconds()),
|
||||
Expires: expiresAt,
|
||||
HttpOnly: true,
|
||||
Secure: requestUsesHTTPS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
WriteJson(w, model.NewNilResult())
|
||||
if enableWebAuth {
|
||||
r.Methods(http.MethodPost).Path("/api/web/login").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var loginReq model.WebAuth
|
||||
if ReadJson(r, w, &loginReq) {
|
||||
if loginReq.Username == startCfg.WebAuth.Username && loginReq.Password == startCfg.WebAuth.Password {
|
||||
sessionID, expiresAt, err := webSessions.create()
|
||||
if err != nil {
|
||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: webAuthCookieName,
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
MaxAge: int(webAuthSessionTTL.Seconds()),
|
||||
Expires: expiresAt,
|
||||
HttpOnly: true,
|
||||
Secure: requestUsesHTTPS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
WriteJson(w, model.NewNilResult())
|
||||
return
|
||||
}
|
||||
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
|
||||
})
|
||||
}
|
||||
}
|
||||
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
|
||||
})
|
||||
}
|
||||
// Reserve the API namespace before the web file handler, including unknown
|
||||
// endpoints and unsupported methods on otherwise known API paths.
|
||||
r.Path("/api").Handler(http.NotFoundHandler())
|
||||
r.PathPrefix("/api/").Handler(http.NotFoundHandler())
|
||||
if startCfg.WebEnable {
|
||||
r.PathPrefix("/fs/tasks").Handler(http.FileServer(new(taskFileSystem)))
|
||||
r.PathPrefix("/fs/extensions").Handler(http.FileServer(new(extensionFileSystem)))
|
||||
r.PathPrefix("/").Handler(gzipMiddleware(http.FileServer(newEmbedCacheFileSystem(http.FS(startCfg.WebFS)))))
|
||||
@@ -325,7 +328,7 @@ func buildServer(startCfg *model.StartConfig, continueOnStartup bool) (*http.Ser
|
||||
})
|
||||
|
||||
server := &http.Server{Handler: handlers.CORS(
|
||||
handlers.AllowedHeaders([]string{"Accept", "Content-Type", "Authorization", "X-Api-Token", "X-Target-Uri", "Mcp-Method", "Mcp-Name", "Mcp-Protocol-Version", "Mcp-Session-Id", "Last-Event-ID"}),
|
||||
handlers.AllowedHeaders([]string{"Accept", "Content-Type", "Authorization", "X-Api-Token", "Mcp-Method", "Mcp-Name", "Mcp-Protocol-Version", "Mcp-Session-Id", "Last-Event-ID"}),
|
||||
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}),
|
||||
handlers.AllowedOrigins([]string{"*"}),
|
||||
handlers.ExposedHeaders([]string{"Mcp-Session-Id"}),
|
||||
|
||||
+44
-24
@@ -2,7 +2,6 @@ package rest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -15,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
@@ -669,29 +669,49 @@ func TestWebFsEnhance(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestDoProxy(t *testing.T) {
|
||||
doTest(func() {
|
||||
code, respBody := doHttpRequest0(http.MethodGet, "/api/web/proxy", map[string]string{
|
||||
"X-Target-Uri": "https://github.com/GopeedLab/gopeed/raw/695da7ea87d2b455552b709d3cb4d7879484d4d1/README.md",
|
||||
}, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("DoProxy() got = %v, want %v", code, http.StatusOK)
|
||||
}
|
||||
want := "4ee193b676f1ebb2ad810e016350d52a"
|
||||
got := fmt.Sprintf("%x", md5.Sum(respBody))
|
||||
if got != want {
|
||||
t.Errorf("DoProxy() got = %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
doTest(func() {
|
||||
code, _ := doHttpRequest0(http.MethodGet, "/api/web/proxy", map[string]string{
|
||||
"X-Target-Uri": "https://github.com/GopeedLab/gopeed/raw/695da7ea87d2b455552b709d3cb4d7879484d4d1/NOT_FOUND",
|
||||
}, nil)
|
||||
if code != http.StatusNotFound {
|
||||
t.Errorf("DoProxy() got = %v, want %v", code, http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
func TestUnknownAPIPathsReturn404(t *testing.T) {
|
||||
for _, webEnabled := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("web=%t", webEnabled), func(t *testing.T) {
|
||||
doTest0(func(cfg *model.StartConfig) {
|
||||
cfg.WebEnable = webEnabled
|
||||
cfg.WebFS = fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{Data: []byte("<html>app</html>")},
|
||||
"api/static.txt": &fstest.MapFile{Data: []byte("must not serve API paths as files")},
|
||||
"apiary": &fstest.MapFile{Data: []byte("namespace boundary")},
|
||||
}
|
||||
}, func() {
|
||||
paths := []string{"/api", "/api/", "/api/missing", "/api/v1/missing", "/api/web/missing", "/api/web/login", "/api/static.txt"}
|
||||
for _, path := range paths {
|
||||
for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodHead} {
|
||||
code, body := doHttpRequest0(method, path, nil, nil)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("%s %s status %d, want 404: %s", method, path, code, body)
|
||||
}
|
||||
if strings.Contains(string(body), "<html>app</html>") {
|
||||
t.Fatalf("%s %s fell through to web files", method, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Known API routes still run, while unsupported methods use the API fallback.
|
||||
code, _ := doHttpRequest0(http.MethodGet, "/api/v1/info", nil, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("known API status %d, want 200", code)
|
||||
}
|
||||
code, _ = doHttpRequest0(http.MethodPost, "/api/v1/info", nil, nil)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("unsupported API method status %d, want 404", code)
|
||||
}
|
||||
if webEnabled {
|
||||
for path, want := range map[string]string{"/": "<html>app</html>", "/apiary": "namespace boundary"} {
|
||||
code, body := doHttpRequest0(http.MethodGet, path, map[string]string{"Accept-Encoding": "identity"}, nil)
|
||||
if code != http.StatusOK || !strings.Contains(string(body), want) {
|
||||
t.Fatalf("web file %s status %d body %s", path, code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestWebhook(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user