diff --git a/bind/common.go b/bind/common.go new file mode 100644 index 00000000..9f5f172c --- /dev/null +++ b/bind/common.go @@ -0,0 +1,64 @@ +package bind + +import ( + "encoding/json" + "errors" + "github.com/GopeedLab/gopeed/pkg/api" + "github.com/GopeedLab/gopeed/pkg/api/model" + "sync" +) + +type instanceArray struct { + mu sync.RWMutex + array []*api.Instance +} + +// append appends an instance to the array. +// Returns the index of the instance in the array. +func (ia *instanceArray) append(instance *api.Instance) int { + ia.mu.Lock() + defer ia.mu.Unlock() + + ia.array = append(ia.array, instance) + return len(ia.array) - 1 +} + +func (ia *instanceArray) get(index int) (*api.Instance, bool) { + ia.mu.RLock() + defer ia.mu.RUnlock() + if index < 0 || index >= len(ia.array) { + return nil, false + } + return ia.array[index], true +} + +var instances = &instanceArray{ + mu: sync.RWMutex{}, + array: make([]*api.Instance, 0), +} + +func Create(config *model.StartConfig) string { + config.ProductionMode = true + instance, err := api.Create(config) + if err != nil { + return BuildResult(err) + } + return BuildResult(instances.append(instance)) +} + +func Invoke(index int, request *api.Request) string { + instance, ok := instances.get(index) + if !ok { + return BuildResult(errors.New("instance not found")) + } + return BuildResult(api.Invoke(instance, request)) +} + +func BuildResult(data any) string { + if err, ok := data.(error); ok { + buf, _ := json.Marshal(model.NewErrorResult[any](err.Error())) + return string(buf) + } + buf, _ := json.Marshal(model.NewOkResult(data)) + return string(buf) +} diff --git a/bind/desktop/main.go b/bind/desktop/main.go index 5b63770f..32cc5109 100644 --- a/bind/desktop/main.go +++ b/bind/desktop/main.go @@ -1,29 +1,37 @@ package main +/* +#include +*/ import "C" import ( "encoding/json" - "github.com/GopeedLab/gopeed/pkg/rest" - "github.com/GopeedLab/gopeed/pkg/rest/model" + "github.com/GopeedLab/gopeed/bind" + "github.com/GopeedLab/gopeed/pkg/api" + "github.com/GopeedLab/gopeed/pkg/api/model" + "unsafe" ) func main() {} -//export Start -func Start(cfg *C.char) (int, *C.char) { +//export Create +func Create(cfg *C.char) *C.char { var config model.StartConfig if err := json.Unmarshal([]byte(C.GoString(cfg)), &config); err != nil { - return 0, C.CString(err.Error()) + return C.CString(bind.BuildResult(err)) } - config.ProductionMode = true - realPort, err := rest.Start(&config) - if err != nil { - return 0, C.CString(err.Error()) - } - return realPort, nil + defer C.free(unsafe.Pointer(cfg)) + + return C.CString(bind.Create(&config)) } -//export Stop -func Stop() { - rest.Stop() +//export Invoke +func Invoke(index int, req *C.char) *C.char { + var request api.Request + if err := json.Unmarshal([]byte(C.GoString(req)), &request); err != nil { + return C.CString(bind.BuildResult(err)) + } + defer C.free(unsafe.Pointer(req)) + + return C.CString(bind.Invoke(index, &request)) } diff --git a/bind/mobile/main.go b/bind/mobile/main.go index 0a81ba53..04acf2e4 100644 --- a/bind/mobile/main.go +++ b/bind/mobile/main.go @@ -3,20 +3,15 @@ package libgopeed // #cgo LDFLAGS: -static-libstdc++ import "C" import ( - "encoding/json" - "github.com/GopeedLab/gopeed/pkg/rest" - "github.com/GopeedLab/gopeed/pkg/rest/model" + "github.com/GopeedLab/gopeed/bind" + "github.com/GopeedLab/gopeed/pkg/api" + "github.com/GopeedLab/gopeed/pkg/api/model" ) -func Start(cfg string) (int, error) { - var config model.StartConfig - if err := json.Unmarshal([]byte(cfg), &config); err != nil { - return 0, err - } - config.ProductionMode = true - return rest.Start(&config) +func Create(config *model.StartConfig) string { + return bind.Create(config) } -func Stop() { - rest.Stop() +func Invoke(index int, request *api.Request) string { + return bind.Invoke(index, request) } diff --git a/cmd/api/main.go b/cmd/api/main.go index c9ad6759..b95c868b 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -2,7 +2,7 @@ package main import ( "github.com/GopeedLab/gopeed/cmd" - "github.com/GopeedLab/gopeed/pkg/rest/model" + "github.com/GopeedLab/gopeed/pkg/api/model" ) // only for local development diff --git a/cmd/server.go b/cmd/server.go index 667e01a8..84375bee 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -3,10 +3,9 @@ package cmd import ( _ "embed" "fmt" + "github.com/GopeedLab/gopeed/pkg/api" + "github.com/GopeedLab/gopeed/pkg/api/model" "github.com/GopeedLab/gopeed/pkg/base" - "github.com/GopeedLab/gopeed/pkg/rest" - "github.com/GopeedLab/gopeed/pkg/rest/model" - "net/http" "os" "os/signal" "path/filepath" @@ -18,20 +17,21 @@ var banner string func Start(cfg *model.StartConfig) { fmt.Println(banner) - srv, listener, err := rest.BuildServer(cfg) + instance, err := api.Create(cfg) if err != nil { panic(err) } - downloadCfg, err := rest.Downloader.GetConfig() - if err != nil { - panic(err) + downloadCfgResult := instance.GetConfig() + if downloadCfgResult.HasError() { + panic(downloadCfgResult.Msg) } + downloadCfg := downloadCfgResult.Data if downloadCfg.FirstLoad { // Set default download config if cfg.DownloadConfig != nil { cfg.DownloadConfig.Merge(downloadCfg) // TODO Use PatchConfig - rest.Downloader.PutConfig(cfg.DownloadConfig) + instance.PutConfig(cfg.DownloadConfig) downloadCfg = cfg.DownloadConfig } @@ -48,25 +48,26 @@ func Start(cfg *model.StartConfig) { } if downloadDir != "" { downloadCfg.DownloadDir = downloadDir - rest.Downloader.PutConfig(downloadCfg) + instance.PutConfig(downloadCfg) } } } - watchExit() - - fmt.Printf("Server start success on http://%s\n", listener.Addr().String()) - if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed { - panic(err) + startResult := instance.Start() + if startResult.HasError() { + panic(startResult.Msg) } + fmt.Printf("Server start success on http://%s:%s\n", startResult.Data.Host, startResult.Data.Port) + + watchExit(instance) } -func watchExit() { +func watchExit(instance *api.Instance) { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) go func() { sig := <-sigs fmt.Printf("Server is shutting down due to signal: %s\n", sig) - rest.Downloader.Close() + instance.Stop() os.Exit(0) }() } diff --git a/cmd/web/flags.go b/cmd/web/flags.go index 3d0537b5..e27bbd70 100644 --- a/cmd/web/flags.go +++ b/cmd/web/flags.go @@ -10,7 +10,7 @@ import ( ) type args struct { - Address *string `json:"address"` + Host *string `json:"host"` Port *int `json:"port"` Username *string `json:"username"` Password *string `json:"password"` @@ -24,7 +24,7 @@ type args struct { func parse() *args { var cliArgs args - cliArgs.Address = flag.String("A", "0.0.0.0", "Bind Address") + cliArgs.Host = flag.String("H", "0.0.0.0", "Bind Host") cliArgs.Port = flag.Int("P", 9999, "Bind Port") cliArgs.Username = flag.String("u", "gopeed", "HTTP Basic Auth Username") cliArgs.Password = flag.String("p", "", "HTTP Basic Auth Pwd") diff --git a/cmd/web/main.go b/cmd/web/main.go index d6265570..e4e2c5ef 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -5,9 +5,9 @@ package main import ( "embed" - "fmt" "github.com/GopeedLab/gopeed/cmd" - "github.com/GopeedLab/gopeed/pkg/rest/model" + "github.com/GopeedLab/gopeed/pkg/api/model" + "github.com/GopeedLab/gopeed/pkg/base" "io/fs" "os" "path/filepath" @@ -42,12 +42,18 @@ func main() { dir = filepath.Dir(exe) } + if args.DownloadConfig == nil { + args.DownloadConfig = &base.DownloaderStoreConfig{} + } + args.DownloadConfig.Http = &base.DownloaderHttpConfig{ + Host: *args.Host, + Port: *args.Port, + ApiToken: *args.ApiToken, + } + cfg := &model.StartConfig{ - Network: "tcp", - Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port), Storage: model.StorageBolt, StorageDir: filepath.Join(dir, "storage"), - ApiToken: *args.ApiToken, DownloadConfig: args.DownloadConfig, ProductionMode: true, WebEnable: true, diff --git a/internal/protocol/bt/fetcher.go b/internal/protocol/bt/fetcher.go index 1a45a6f9..480a82d6 100644 --- a/internal/protocol/bt/fetcher.go +++ b/internal/protocol/bt/fetcher.go @@ -332,7 +332,7 @@ func (f *Fetcher) doUpload(fromUpload bool) { } } -// Get torrent stats maybe panic, see https://github.com/anacrolix/torrent/issues/972 +// get torrent stats maybe panic, see https://github.com/anacrolix/torrent/issues/972 func (f *Fetcher) torrentStats() torrent.TorrentStats { defer func() { if r := recover(); r != nil { diff --git a/internal/protocol/http/fetcher.go b/internal/protocol/http/fetcher.go index 82e65aa7..769a6041 100644 --- a/internal/protocol/http/fetcher.go +++ b/internal/protocol/http/fetcher.go @@ -146,7 +146,7 @@ func (f *Fetcher) Resolve(req *base.Request) error { file.Name = filename } } - // Get file filePath by URL + // get file filePath by URL if file.Name == "" { file.Name = path.Base(httpReq.URL.Path) } @@ -496,7 +496,7 @@ func (fm *FetcherManager) ParseName(u string) string { if err != nil { return "" } - // Get filePath by URL + // get filePath by URL name = path.Base(url.Path) // If file name is empty, use host name if name == "" || name == "/" || name == "." { diff --git a/pkg/api/api.go b/pkg/api/api.go new file mode 100644 index 00000000..c7f3eccc --- /dev/null +++ b/pkg/api/api.go @@ -0,0 +1,414 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "github.com/GopeedLab/gopeed/pkg/api/model" + "github.com/GopeedLab/gopeed/pkg/base" + "github.com/GopeedLab/gopeed/pkg/download" + "github.com/pkg/errors" + "net" + "net/http" + "reflect" + "runtime" + "sync" +) + +type Instance struct { + startCfg *model.StartConfig + downloader *download.Downloader + running bool + + httpLock sync.Mutex + srv *http.Server + listener net.Listener + runningPort int +} + +func Create(startCfg *model.StartConfig) (*Instance, error) { + if startCfg == nil { + startCfg = &model.StartConfig{} + } + startCfg.Init() + + i := &Instance{ + startCfg: startCfg, + } + + return i, nil +} + +func (i *Instance) Start() *model.Result[*model.StartResult] { + // avoid repeat start + if i.running { + // If the http server is not enabled, return nil + if i.runningPort == 0 { + return model.NewOkResult[*model.StartResult](nil) + } + host, _, _ := net.SplitHostPort(i.listener.Addr().String()) + return model.NewOkResult(&model.StartResult{ + Host: host, + Port: i.runningPort, + }) + } + + startCfg := i.startCfg + downloadCfg := &download.DownloaderConfig{ + ProductionMode: startCfg.ProductionMode, + RefreshInterval: startCfg.RefreshInterval, + } + if startCfg.Storage == model.StorageBolt { + downloadCfg.Storage = download.NewBoltStorage(startCfg.StorageDir) + } else { + downloadCfg.Storage = download.NewMemStorage() + } + downloadCfg.StorageDir = startCfg.StorageDir + downloadCfg.Init() + downloader := download.NewDownloader(downloadCfg) + if err := downloader.Setup(); err != nil { + return model.NewErrorResult[*model.StartResult](err.Error()) + } + i.downloader = downloader + i.running = true + + config, err := i.downloader.GetConfig() + if err != nil { + return model.NewErrorResult[*model.StartResult](err.Error()) + } + httpCfg := config.Http + if httpCfg == nil || !httpCfg.Enable { + return model.NewOkResult[*model.StartResult](nil) + } + + i.httpLock = sync.Mutex{} + startResult := i.StartHttp(httpCfg) + if startResult.HasError() { + i.downloader.Logger.Error().Err(err).Msgf("listen http failed: %s", startResult.Msg) + return model.NewOkResult[*model.StartResult](nil) + } + + go func() { + if err := i.srv.Serve(i.listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + panic(err) + } + }() + + addr, _ := i.listener.Addr().(*net.TCPAddr) + i.runningPort = addr.Port + return model.NewOkResult(&model.StartResult{ + Host: httpCfg.Host, + Port: i.runningPort, + }) +} + +func (i *Instance) Stop() *model.Result[any] { + i.StopHttp() + if i.downloader != nil { + if err := i.downloader.Close(); err != nil { + i.downloader.Logger.Warn().Err(err).Msg("close downloader failed") + } + i.downloader = nil + i.running = false + } + + return model.NewNilResult() +} + +func (i *Instance) StartHttp(httpCfg *base.DownloaderHttpConfig) *model.Result[int] { + i.httpLock.Lock() + defer i.httpLock.Unlock() + + return i.startHttp(httpCfg) +} + +func (i *Instance) startHttp(httpCfg *base.DownloaderHttpConfig) *model.Result[int] { + if i.srv != nil { + return model.NewErrorResult[int]("server already started") + } + if err := ListenHttp(httpCfg, i); err != nil { + return model.NewErrorResult[int](err.Error()) + } + return model.NewOkResult(i.runningPort) +} + +func (i *Instance) StopHttp() *model.Result[any] { + i.httpLock.Lock() + defer i.httpLock.Unlock() + + return i.stopHttp() +} + +func (i *Instance) stopHttp() *model.Result[any] { + if i.srv != nil { + if err := i.srv.Shutdown(context.Background()); err != nil { + i.downloader.Logger.Warn().Err(err).Msg("shutdown http server failed") + } + i.srv = nil + i.listener = nil + i.runningPort = 0 + } + return model.NewNilResult() +} + +func (i *Instance) RestartHttp(httpCfg *base.DownloaderHttpConfig) *model.Result[int] { + i.httpLock.Lock() + defer i.httpLock.Unlock() + + return i.restartHttp(httpCfg) +} + +func (i *Instance) restartHttp(httpCfg *base.DownloaderHttpConfig) *model.Result[int] { + i.stopHttp() + return i.startHttp(httpCfg) +} + +func (i *Instance) Info() *model.Result[map[string]any] { + return model.NewOkResult(map[string]any{ + "version": base.Version, + "runtime": runtime.Version(), + "os": runtime.GOOS, + "arch": runtime.GOARCH, + "inDocker": base.InDocker == "true", + }) +} + +func (i *Instance) Resolve(req *base.Request) *model.Result[*download.ResolveResult] { + rr, err := i.downloader.Resolve(req) + if err != nil { + return model.NewErrorResult[*download.ResolveResult](err.Error()) + } + return model.NewOkResult(rr) +} + +func (i *Instance) CreateTask(req *model.CreateTask) *model.Result[string] { + var ( + taskId string + err error + ) + if req.Rid != "" { + taskId, err = i.downloader.Create(req.Rid, req.Opt) + } else if req.Req != nil { + taskId, err = i.downloader.CreateDirect(req.Req, req.Opt) + } else { + return model.NewErrorResult[string]("param invalid: rid or req", model.CodeInvalidParam) + } + if err != nil { + return model.NewErrorResult[string](err.Error()) + } + return model.NewOkResult(taskId) +} + +func (i *Instance) CreateTaskBatch(req *model.CreateTaskBatch) *model.Result[[]string] { + if len(req.Reqs) == 0 { + return model.NewErrorResult[[]string]("param invalid: reqs", model.CodeInvalidParam) + } + taskIds, err := i.downloader.CreateDirectBatch(req.Reqs, req.Opt) + if err != nil { + return model.NewErrorResult[[]string](err.Error()) + } + return model.NewOkResult(taskIds) +} + +func (i *Instance) PauseTask(taskId string) *model.Result[any] { + err := i.downloader.Pause(&download.TaskFilter{ + IDs: []string{taskId}, + }) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) PauseTasks(filter *download.TaskFilter) *model.Result[any] { + err := i.downloader.Pause(filter) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) ContinueTask(taskId string) *model.Result[any] { + err := i.downloader.Continue(&download.TaskFilter{ + IDs: []string{taskId}, + }) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) ContinueTasks(filter *download.TaskFilter) *model.Result[any] { + err := i.downloader.Continue(filter) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) DeleteTask(taskId string, force bool) *model.Result[any] { + err := i.downloader.Delete(&download.TaskFilter{ + IDs: []string{taskId}, + }, force) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) DeleteTasks(filter *download.TaskFilter, force bool) *model.Result[any] { + err := i.downloader.Delete(filter, force) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) GetTask(taskId string) *model.Result[*download.Task] { + if taskId == "" { + return model.NewErrorResult[*download.Task]("param invalid: id", model.CodeInvalidParam) + } + task := i.downloader.GetTask(taskId) + if task == nil { + return model.NewErrorResult[*download.Task]("task not found", model.CodeTaskNotFound) + } + return model.NewOkResult(task) +} + +func (i *Instance) GetTasks(filter *download.TaskFilter) *model.Result[[]*download.Task] { + return model.NewOkResult(i.downloader.GetTasksByFilter(filter)) +} + +func (i *Instance) GetTaskStats(taskId string) *model.Result[any] { + stats, err := i.downloader.GetTaskStats(taskId) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewOkResult(stats) +} + +func (i *Instance) GetConfig() *model.Result[*base.DownloaderStoreConfig] { + config, err := i.downloader.GetConfig() + if err != nil { + return model.NewErrorResult[*base.DownloaderStoreConfig](err.Error()) + } + return model.NewOkResult(config) +} + +func (i *Instance) PutConfig(cfg *base.DownloaderStoreConfig) *model.Result[any] { + err := i.downloader.PutConfig(cfg) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) InstallExtension(req *model.InstallExtension) *model.Result[string] { + var ( + installedExt *download.Extension + err error + ) + if req.DevMode { + installedExt, err = i.downloader.InstallExtensionByFolder(req.URL, true) + } else { + installedExt, err = i.downloader.InstallExtensionByGit(req.URL) + } + if err != nil { + return model.NewErrorResult[string](err.Error()) + } + + return model.NewOkResult(installedExt.Identity) +} + +func (i *Instance) GetExtension(identity string) *model.Result[*download.Extension] { + extension, err := i.downloader.GetExtension(identity) + if err != nil { + return model.NewErrorResult[*download.Extension](err.Error()) + } + return model.NewOkResult(extension) +} + +func (i *Instance) GetExtensions() *model.Result[[]*download.Extension] { + return model.NewOkResult(i.downloader.GetExtensions()) +} + +func (i *Instance) UpdateExtensionSettings(identity string, req *model.UpdateExtensionSettings) *model.Result[any] { + err := i.downloader.UpdateExtensionSettings(identity, req.Settings) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) SwitchExtension(identity string, req *model.SwitchExtension) *model.Result[any] { + err := i.downloader.SwitchExtension(identity, req.Status) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) DeleteExtension(identity string) *model.Result[any] { + err := i.downloader.DeleteExtension(identity) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) UpgradeCheckExtension(identity string) *model.Result[*model.UpgradeCheckExtensionResp] { + newVersion, err := i.downloader.UpgradeCheckExtension(identity) + if err != nil { + return model.NewErrorResult[*model.UpgradeCheckExtensionResp](err.Error()) + } + + return model.NewOkResult(&model.UpgradeCheckExtensionResp{ + NewVersion: newVersion, + }) +} + +func (i *Instance) UpgradeExtension(identity string) *model.Result[any] { + err := i.downloader.UpgradeExtension(identity) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +type Request struct { + Method string `json:"method"` + Params []string `json:"params"` +} + +// Invoke support dynamic call method +func Invoke(server *Instance, request *Request) (ret any) { + defer func() { + if err := recover(); err != nil { + ret = model.NewErrorResult[any](fmt.Sprintf("%v", err)) + } + }() + + method, args := request.Method, request.Params + dsType := reflect.ValueOf(server) + fn := dsType.MethodByName(method) + numIn := fn.Type().NumIn() + in := make([]reflect.Value, numIn) + for i := 0; i < numIn; i++ { + paramType := fn.Type().In(i) + var param reflect.Value + var paramPtr any + if paramType.Kind() == reflect.Ptr { + param = reflect.New(paramType.Elem()) + paramPtr = param.Interface() + } else { + param = reflect.New(paramType).Elem() + paramPtr = param.Addr().Interface() + } + if err := json.Unmarshal([]byte(args[i]), paramPtr); err != nil { + panic(err) + } + in[i] = param + } + retVals := fn.Call(in) + return retVals[0] +} diff --git a/pkg/rest/server_test.go b/pkg/api/api_test.go similarity index 98% rename from pkg/rest/server_test.go rename to pkg/api/api_test.go index 930d9817..0eaa6c72 100644 --- a/pkg/rest/server_test.go +++ b/pkg/api/api_test.go @@ -1,4 +1,4 @@ -package rest +package api import ( "bytes" @@ -7,9 +7,9 @@ import ( "errors" "fmt" "github.com/GopeedLab/gopeed/internal/test" + "github.com/GopeedLab/gopeed/pkg/api/model" "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/download" - "github.com/GopeedLab/gopeed/pkg/rest/model" "io" "net" "net/http" @@ -354,7 +354,10 @@ func TestGetAndPutConfig(t *testing.T) { cfg := httpRequestCheckOk[*base.DownloaderStoreConfig](http.MethodGet, "/api/v1/config", nil) cfg.DownloadDir = "./download" cfg.Extra = map[string]any{ - "serverConfig": &Config{ + "serverConfig": &struct { + Host string `json:"host"` + Port int `json:"port"` + }{ Host: "127.0.0.1", Port: 8080, }, @@ -449,10 +452,10 @@ func TestDeleteExtension(t *testing.T) { func TestUpdateCheckExtension(t *testing.T) { doTest(func() { identity := httpRequestCheckOk[string](http.MethodPost, "/api/v1/extensions", installExtensionReq) - resp := httpRequestCheckOk[*model.UpdateCheckExtensionResp](http.MethodGet, "/api/v1/extensions/"+identity+"/update", nil) + resp := httpRequestCheckOk[*model.UpgradeCheckExtensionResp](http.MethodGet, "/api/v1/extensions/"+identity+"/update", nil) // no new version if resp.NewVersion != "" { - t.Errorf("UpdateCheckExtension() got = %v, want %v", resp.NewVersion, "") + t.Errorf("UpgradeCheckExtension() got = %v, want %v", resp.NewVersion, "") } // force update httpRequestCheckOk[any](http.MethodPost, "/api/v1/extensions/"+identity+"/update", nil) diff --git a/pkg/api/http.go b/pkg/api/http.go new file mode 100644 index 00000000..ab283906 --- /dev/null +++ b/pkg/api/http.go @@ -0,0 +1,447 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "github.com/GopeedLab/gopeed/pkg/api/model" + "github.com/GopeedLab/gopeed/pkg/base" + "github.com/GopeedLab/gopeed/pkg/download" + "github.com/gorilla/handlers" + "github.com/gorilla/mux" + "github.com/pkg/errors" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" +) + +const instanceKey string = "apiInstance" + +func ListenHttp(httpCfg *base.DownloaderHttpConfig, ai *Instance) error { + var r = mux.NewRouter() + r.Methods(http.MethodGet).Path("/api/v1/info").HandlerFunc(Info) + r.Methods(http.MethodPost).Path("/api/v1/resolve").HandlerFunc(Resolve) + r.Methods(http.MethodPost).Path("/api/v1/tasks").HandlerFunc(CreateTask) + r.Methods(http.MethodPost).Path("/api/v1/tasks/batch").HandlerFunc(CreateTaskBatch) + r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/pause").HandlerFunc(PauseTask) + r.Methods(http.MethodPut).Path("/api/v1/tasks/pause").HandlerFunc(PauseTasks) + r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/continue").HandlerFunc(ContinueTask) + r.Methods(http.MethodPut).Path("/api/v1/tasks/continue").HandlerFunc(ContinueTasks) + r.Methods(http.MethodDelete).Path("/api/v1/tasks/{id}").HandlerFunc(DeleteTask) + r.Methods(http.MethodDelete).Path("/api/v1/tasks").HandlerFunc(DeleteTasks) + r.Methods(http.MethodGet).Path("/api/v1/tasks/{id}").HandlerFunc(GetTask) + r.Methods(http.MethodGet).Path("/api/v1/tasks").HandlerFunc(GetTasks) + r.Methods(http.MethodGet).Path("/api/v1/tasks/{id}/stats").HandlerFunc(GetTaskStats) + r.Methods(http.MethodGet).Path("/api/v1/config").HandlerFunc(GetConfig) + r.Methods(http.MethodPut).Path("/api/v1/config").HandlerFunc(PutConfig) + r.Methods(http.MethodPost).Path("/api/v1/extensions").HandlerFunc(InstallExtension) + r.Methods(http.MethodGet).Path("/api/v1/extensions").HandlerFunc(GetExtensions) + r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}").HandlerFunc(GetExtension) + r.Methods(http.MethodPut).Path("/api/v1/extensions/{identity}/settings").HandlerFunc(UpdateExtensionSettings) + r.Methods(http.MethodPut).Path("/api/v1/extensions/{identity}/switch").HandlerFunc(SwitchExtension) + r.Methods(http.MethodDelete).Path("/api/v1/extensions/{identity}").HandlerFunc(DeleteExtension) + r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}/upgrade").HandlerFunc(UpgradeCheckExtension) + r.Methods(http.MethodPost).Path("/api/v1/extensions/{identity}/upgrade").HandlerFunc(UpgradeExtension) + r.Path("/api/v1/proxy").HandlerFunc(DoProxy) + if ai.startCfg.WebEnable { + r.PathPrefix("/fs/tasks").Handler(http.FileServer(&taskFileSystem{ + downloader: ai.downloader, + })) + r.PathPrefix("/fs/extensions").Handler(http.FileServer(&extensionFileSystem{ + downloader: ai.downloader, + })) + r.PathPrefix("/").Handler(http.FileServer(http.FS(ai.startCfg.WebFS))) + } + + r.Use(func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), instanceKey, ai) + h.ServeHTTP(w, r.WithContext(ctx)) + }) + }) + + if httpCfg.ApiToken != "" || (ai.startCfg.WebEnable && ai.startCfg.WebBasicAuth != nil) { + r.Use(func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if httpCfg.ApiToken != "" && r.Header.Get("X-Api-Token") == httpCfg.ApiToken { + h.ServeHTTP(w, r) + return + } + if ai.startCfg.WebEnable && ai.startCfg.WebBasicAuth != nil { + if r.Header.Get("Authorization") == ai.startCfg.WebBasicAuth.Authorization() { + h.ServeHTTP(w, r) + return + } + w.Header().Set("WWW-Authenticate", "Basic realm=\"gopeed web\"") + } + WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult[any]("unauthorized", model.CodeUnauthorized)) + }) + }) + } + + // recover panic + r.Use(func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if v := recover(); v != nil { + err := errors.WithStack(fmt.Errorf("%v", v)) + ai.downloader.Logger.Error().Stack().Err(err).Msgf("http server panic: %s %s", r.Method, r.RequestURI) + WriteJson(w, model.NewErrorResult[any](err.Error(), model.CodeError)) + } + }() + h.ServeHTTP(w, r) + }) + }) + + srv := &http.Server{Handler: handlers.CORS( + handlers.AllowedHeaders([]string{"Content-Type", "X-Api-Token", "X-Target-Uri"}), + handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}), + handlers.AllowedOrigins([]string{"*"}), + )(r)} + + listener, err := net.Listen("tcp", net.JoinHostPort(httpCfg.Host, fmt.Sprintf("%d", httpCfg.Port))) + if err != nil { + return err + } + + ai.srv = srv + ai.listener = listener + return nil +} + +func getInstance(r *http.Request) *Instance { + return r.Context().Value(instanceKey).(*Instance) +} + +func Info(w http.ResponseWriter, r *http.Request) { + WriteJson(w, getInstance(r).Info()) +} + +func Resolve(w http.ResponseWriter, r *http.Request) { + var req base.Request + if ReadJson(r, w, &req) { + WriteJson(w, getInstance(r).Resolve(&req)) + } +} + +func CreateTask(w http.ResponseWriter, r *http.Request) { + var req model.CreateTask + if ReadJson(r, w, &req) { + WriteJson(w, getInstance(r).CreateTask(&req)) + } +} + +func CreateTaskBatch(w http.ResponseWriter, r *http.Request) { + var req model.CreateTaskBatch + if ReadJson(r, w, &req) { + WriteJson(w, getInstance(r).CreateTaskBatch(&req)) + } +} + +func PauseTask(w http.ResponseWriter, r *http.Request) { + var taskId string + if !parseTaskId(r, w, &taskId) { + return + } + WriteJson(w, getInstance(r).PauseTask(taskId)) +} + +func PauseTasks(w http.ResponseWriter, r *http.Request) { + var filter download.TaskFilter + if !parseFilter(r, w, &filter) { + return + } + WriteJson(w, getInstance(r).PauseTasks(&filter)) +} + +func ContinueTask(w http.ResponseWriter, r *http.Request) { + var taskId string + if !parseTaskId(r, w, &taskId) { + return + } + WriteJson(w, getInstance(r).ContinueTask(taskId)) +} + +func ContinueTasks(w http.ResponseWriter, r *http.Request) { + var filter download.TaskFilter + if !parseFilter(r, w, &filter) { + return + } + WriteJson(w, getInstance(r).ContinueTasks(&filter)) +} + +func DeleteTask(w http.ResponseWriter, r *http.Request) { + var taskId string + if !parseTaskId(r, w, &taskId) { + return + } + force := r.FormValue("force") + WriteJson(w, getInstance(r).DeleteTask(taskId, force == "true")) +} + +func DeleteTasks(w http.ResponseWriter, r *http.Request) { + var filter download.TaskFilter + if !parseFilter(r, w, &filter) { + return + } + force := r.FormValue("force") + WriteJson(w, getInstance(r).DeleteTasks(&filter, force == "true")) +} + +func GetTask(w http.ResponseWriter, r *http.Request) { + var taskId string + if !parseTaskId(r, w, &taskId) { + return + } + WriteJson(w, getInstance(r).GetTask(taskId)) +} + +func GetTasks(w http.ResponseWriter, r *http.Request) { + var filter download.TaskFilter + if !parseFilter(r, w, &filter) { + return + } + WriteJson(w, getInstance(r).GetTasks(&filter)) +} + +func GetTaskStats(w http.ResponseWriter, r *http.Request) { + var taskId string + if !parseTaskId(r, w, &taskId) { + return + } + WriteJson(w, getInstance(r).GetTaskStats(taskId)) +} + +func GetConfig(w http.ResponseWriter, r *http.Request) { + WriteJson(w, getInstance(r).GetConfig()) +} + +func PutConfig(w http.ResponseWriter, r *http.Request) { + var cfg base.DownloaderStoreConfig + if !ReadJson(r, w, &cfg) { + return + } + WriteJson(w, getInstance(r).PutConfig(&cfg)) +} + +func InstallExtension(w http.ResponseWriter, r *http.Request) { + var req model.InstallExtension + if !ReadJson(r, w, &req) { + return + } + WriteJson(w, getInstance(r).InstallExtension(&req)) +} + +func GetExtensions(w http.ResponseWriter, r *http.Request) { + WriteJson(w, getInstance(r).GetExtensions()) +} + +func GetExtension(w http.ResponseWriter, r *http.Request) { + var identity string + if !parseIdentity(r, w, &identity) { + return + } + WriteJson(w, getInstance(r).GetExtension(identity)) +} + +func UpdateExtensionSettings(w http.ResponseWriter, r *http.Request) { + var identity string + if !parseIdentity(r, w, &identity) { + return + } + var req model.UpdateExtensionSettings + if !ReadJson(r, w, &req) { + return + } + WriteJson(w, getInstance(r).UpdateExtensionSettings(identity, &req)) +} + +func SwitchExtension(w http.ResponseWriter, r *http.Request) { + var identity string + if !parseIdentity(r, w, &identity) { + return + } + var switchExtension model.SwitchExtension + if !ReadJson(r, w, &switchExtension) { + return + } + WriteJson(w, getInstance(r).SwitchExtension(identity, &switchExtension)) +} + +func DeleteExtension(w http.ResponseWriter, r *http.Request) { + var identity string + if !parseIdentity(r, w, &identity) { + return + } + WriteJson(w, getInstance(r).DeleteExtension(identity)) +} + +func UpgradeCheckExtension(w http.ResponseWriter, r *http.Request) { + var identity string + if !parseIdentity(r, w, &identity) { + return + } + WriteJson(w, getInstance(r).UpgradeCheckExtension(identity)) +} + +func UpgradeExtension(w http.ResponseWriter, r *http.Request) { + var identity string + if !parseIdentity(r, w, &identity) { + return + } + WriteJson(w, getInstance(r).UpgradeExtension(identity)) +} + +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 + 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 parsePathParam(r *http.Request, w http.ResponseWriter, name string, value *string) bool { + vars := mux.Vars(r) + *value = vars[name] + if *value == "" { + WriteJson(w, model.NewErrorResult[any]("param invalid: id", model.CodeInvalidParam)) + return false + } + return true +} + +func parseTaskId(r *http.Request, w http.ResponseWriter, taskId *string) bool { + return parsePathParam(r, w, "id", taskId) +} + +func parseIdentity(r *http.Request, w http.ResponseWriter, identity *string) bool { + return parsePathParam(r, w, "identity", identity) +} + +func parseFilter(r *http.Request, w http.ResponseWriter, filter *download.TaskFilter) bool { + if err := r.ParseForm(); err != nil { + WriteJson(w, model.NewErrorResult[any](err.Error())) + return false + } + + filter.IDs = r.Form["id"] + filter.Statuses = convertStatues(r.Form["status"]) + filter.NotStatuses = convertStatues(r.Form["notStatus"]) + return true +} + +func convertStatues(statues []string) []base.Status { + result := make([]base.Status, 0) + for _, status := range statues { + result = append(result, base.Status(status)) + } + return result +} + +func writeError(w http.ResponseWriter, msg string) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(msg)) +} + +func ReadJson(r *http.Request, w http.ResponseWriter, v any) bool { + if err := json.NewDecoder(r.Body).Decode(v); err != nil { + WriteJson(w, model.NewErrorResult[any](err.Error())) + return false + } + return true +} + +func WriteJson(w http.ResponseWriter, v any) { + WriteStatusJson(w, http.StatusOK, v) +} + +func WriteStatusJson(w http.ResponseWriter, statusCode int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(v) +} + +func resolvePath(urlPath string, prefix string) (identity string, path string, err error) { + // remove prefix + clearPath := strings.TrimPrefix(urlPath, prefix) + // match extension identity, eg: /fs/extensions/identity/xxx + reg := regexp.MustCompile(`^/([^/]+)/(.*)$`) + if !reg.MatchString(clearPath) { + err = os.ErrNotExist + return + } + matched := reg.FindStringSubmatch(clearPath) + if len(matched) != 3 { + err = os.ErrNotExist + return + } + return matched[1], matched[2], nil +} + +// handle task file resource +type taskFileSystem struct { + downloader *download.Downloader +} + +func (e *taskFileSystem) Open(name string) (http.File, error) { + // get extension identity + identity, path, err := resolvePath(name, "/fs/tasks") + if err != nil { + return nil, err + } + task := e.downloader.GetTask(identity) + if task == nil { + return nil, os.ErrNotExist + } + return os.Open(filepath.Join(task.Meta.RootDirPath(), path)) +} + +// handle extension file resource +type extensionFileSystem struct { + downloader *download.Downloader +} + +func (e *extensionFileSystem) Open(name string) (http.File, error) { + // get extension identity + identity, path, err := resolvePath(name, "/fs/extensions") + if err != nil { + return nil, err + } + extension, err := e.downloader.GetExtension(identity) + if err != nil { + return nil, os.ErrNotExist + } + extensionPath := e.downloader.ExtensionPath(extension) + return os.Open(filepath.Join(extensionPath, path)) +} diff --git a/pkg/rest/model/server.go b/pkg/api/model/api.go similarity index 80% rename from pkg/rest/model/server.go rename to pkg/api/model/api.go index 45934909..46e15152 100644 --- a/pkg/rest/model/server.go +++ b/pkg/api/model/api.go @@ -14,13 +14,10 @@ const ( ) type StartConfig struct { - Network string `json:"network"` - Address string `json:"address"` RefreshInterval int `json:"refreshInterval"` Storage Storage `json:"storage"` StorageDir string `json:"storageDir"` WhiteDownloadDirs []string `json:"whiteDownloadDirs"` - ApiToken string `json:"apiToken"` DownloadConfig *base.DownloaderStoreConfig `json:"downloadConfig"` ProductionMode bool @@ -31,12 +28,6 @@ type StartConfig struct { } func (cfg *StartConfig) Init() *StartConfig { - if cfg.Network == "" { - cfg.Network = "tcp" - } - if cfg.Address == "" { - cfg.Address = "127.0.0.1:0" - } if cfg.RefreshInterval == 0 { cfg.RefreshInterval = 350 } @@ -59,3 +50,9 @@ func (cfg *WebBasicAuth) Authorization() string { userId := cfg.Username + ":" + cfg.Password return "Basic " + base64.StdEncoding.EncodeToString([]byte(userId)) } + +type StartResult struct { + Host string `json:"host"` + // Port is the http server real listen port. + Port int `json:"port"` +} diff --git a/pkg/rest/model/extension.go b/pkg/api/model/extension.go similarity index 88% rename from pkg/rest/model/extension.go rename to pkg/api/model/extension.go index 2c67e567..46d68635 100644 --- a/pkg/rest/model/extension.go +++ b/pkg/api/model/extension.go @@ -13,6 +13,6 @@ type SwitchExtension struct { Status bool `json:"status"` } -type UpdateCheckExtensionResp struct { +type UpgradeCheckExtensionResp struct { NewVersion string `json:"newVersion"` } diff --git a/pkg/rest/model/result.go b/pkg/api/model/result.go similarity index 76% rename from pkg/rest/model/result.go rename to pkg/api/model/result.go index ae3ca22d..af4525be 100644 --- a/pkg/rest/model/result.go +++ b/pkg/api/model/result.go @@ -1,5 +1,7 @@ package model +import "encoding/json" + type RespCode int const ( @@ -20,6 +22,15 @@ type Result[T any] struct { Data T `json:"data"` } +func (r *Result[T]) Json() string { + buf, _ := json.Marshal(r) + return string(buf) +} + +func (r *Result[T]) HasError() bool { + return r.Code != CodeOk +} + func NewOkResult[T any](data T) *Result[T] { return &Result[T]{ Code: CodeOk, @@ -33,14 +44,14 @@ func NewNilResult() *Result[any] { } } -func NewErrorResult(msg string, code ...RespCode) *Result[any] { +func NewErrorResult[T any](msg string, code ...RespCode) *Result[T] { // if code is not provided, the default code is CodeError c := CodeError if len(code) > 0 { c = code[0] } - return &Result[any]{ + return &Result[T]{ Code: c, Msg: msg, } diff --git a/pkg/rest/model/task.go b/pkg/api/model/task.go similarity index 100% rename from pkg/rest/model/task.go rename to pkg/api/model/task.go diff --git a/pkg/base/model.go b/pkg/base/model.go index da5cefd8..ca935ecd 100644 --- a/pkg/base/model.go +++ b/pkg/base/model.go @@ -171,6 +171,7 @@ type DownloaderStoreConfig struct { MaxRunning int `json:"maxRunning"` // MaxRunning is the max running download count ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol Extra map[string]any `json:"extra"` + Http *DownloaderHttpConfig `json:"http"` Proxy *DownloaderProxyConfig `json:"proxy"` } @@ -181,6 +182,12 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig { if cfg.ProtocolConfig == nil { cfg.ProtocolConfig = make(map[string]any) } + if cfg.Extra == nil { + cfg.Extra = make(map[string]any) + } + if cfg.Http == nil { + cfg.Http = &DownloaderHttpConfig{} + } if cfg.Proxy == nil { cfg.Proxy = &DownloaderProxyConfig{} } @@ -203,12 +210,26 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl if cfg.Extra == nil { cfg.Extra = beforeCfg.Extra } + if cfg.Http == nil { + cfg.Http = beforeCfg.Http + } if cfg.Proxy == nil { cfg.Proxy = beforeCfg.Proxy } return cfg } +type DownloaderHttpConfig struct { + // Enable is the flag that enable http server + Enable bool `json:"Enable"` + // Host is the http server listen address. + Host string `json:"host"` + // Port is the http server listen port. + Port int `json:"port"` + // ApiToken is the auth token for http API + ApiToken string `json:"apiToken"` +} + type DownloaderProxyConfig struct { Enable bool `json:"enable"` // System is the flag that use system proxy diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index da1ac217..b2634f75 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -256,7 +256,7 @@ func (d *Downloader) setupFetcher(fm fetcher.FetcherManager, fetcher fetcher.Fet ctl.GetConfig = func(v any) { d.getProtocolConfig(fm.Name(), v) } - // Get proxy config, task request proxy config has higher priority, then use global proxy config + // get proxy config, task request proxy config has higher priority, then use global proxy config ctl.GetProxy = func(requestProxy *base.RequestProxy) func(*gohttp.Request) (*url.URL, error) { if requestProxy == nil { return d.cfg.Proxy.ToHandler() @@ -576,7 +576,7 @@ func (d *Downloader) deleteAll() (err error) { return } -func (d *Downloader) Stats(id string) (sr any, err error) { +func (d *Downloader) GetTaskStats(id string) (sr any, err error) { task := d.GetTask(id) if task == nil { return sr, ErrTaskNotFound diff --git a/pkg/download/model.go b/pkg/download/model.go index 085a20f0..6f57444b 100644 --- a/pkg/download/model.go +++ b/pkg/download/model.go @@ -75,7 +75,7 @@ func (t *Task) Name() string { return t.Meta.Res.Name } - // Get the name of the first file + // get the name of the first file return t.Meta.Res.Files[0].Name } diff --git a/pkg/rest/api.go b/pkg/rest/api.go deleted file mode 100644 index 9e030b34..00000000 --- a/pkg/rest/api.go +++ /dev/null @@ -1,389 +0,0 @@ -package rest - -import ( - "github.com/GopeedLab/gopeed/pkg/base" - "github.com/GopeedLab/gopeed/pkg/download" - "github.com/GopeedLab/gopeed/pkg/rest/model" - "github.com/gorilla/mux" - "io" - "net/http" - "net/url" - "runtime" -) - -func Info(w http.ResponseWriter, r *http.Request) { - info := map[string]any{ - "version": base.Version, - "runtime": runtime.Version(), - "os": runtime.GOOS, - "arch": runtime.GOARCH, - "inDocker": base.InDocker == "true", - } - WriteJson(w, model.NewOkResult(info)) -} - -func Resolve(w http.ResponseWriter, r *http.Request) { - var req base.Request - if ReadJson(r, w, &req) { - rr, err := Downloader.Resolve(&req) - if err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewOkResult(rr)) - } -} - -func CreateTask(w http.ResponseWriter, r *http.Request) { - var req model.CreateTask - if ReadJson(r, w, &req) { - var ( - taskId string - err error - ) - if req.Rid != "" { - taskId, err = Downloader.Create(req.Rid, req.Opt) - } else if req.Req != nil { - taskId, err = Downloader.CreateDirect(req.Req, req.Opt) - } else { - WriteJson(w, model.NewErrorResult("param invalid: rid or req", model.CodeInvalidParam)) - return - } - if err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewOkResult(taskId)) - } -} - -func CreateTaskBatch(w http.ResponseWriter, r *http.Request) { - var req model.CreateTaskBatch - if ReadJson(r, w, &req) { - if len(req.Reqs) == 0 { - WriteJson(w, model.NewErrorResult("param invalid: reqs", model.CodeInvalidParam)) - return - } - taskIds, err := Downloader.CreateDirectBatch(req.Reqs, req.Opt) - if err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewOkResult(taskIds)) - } -} - -func PauseTask(w http.ResponseWriter, r *http.Request) { - filter, errResult := parseIdFilter(r) - if errResult != nil { - WriteJson(w, errResult) - return - } - - if err := Downloader.Pause(filter); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewNilResult()) -} - -func PauseTasks(w http.ResponseWriter, r *http.Request) { - filter, errResult := parseFilter(r) - if errResult != nil { - WriteJson(w, errResult) - return - } - - if err := Downloader.Pause(filter); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewNilResult()) -} - -func ContinueTask(w http.ResponseWriter, r *http.Request) { - filter, errResult := parseIdFilter(r) - if errResult != nil { - WriteJson(w, errResult) - return - } - - if err := Downloader.Continue(filter); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewNilResult()) -} - -func ContinueTasks(w http.ResponseWriter, r *http.Request) { - filter, errResult := parseFilter(r) - if errResult != nil { - WriteJson(w, errResult) - return - } - - if err := Downloader.Continue(filter); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewNilResult()) -} - -func DeleteTask(w http.ResponseWriter, r *http.Request) { - filter, errResult := parseIdFilter(r) - if errResult != nil { - WriteJson(w, errResult) - return - } - force := r.FormValue("force") - - if err := Downloader.Delete(filter, force == "true"); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewNilResult()) -} - -func DeleteTasks(w http.ResponseWriter, r *http.Request) { - filter, errResult := parseFilter(r) - if errResult != nil { - WriteJson(w, errResult) - return - } - force := r.FormValue("force") - - if err := Downloader.Delete(filter, force == "true"); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewNilResult()) -} - -func GetTask(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - taskId := vars["id"] - if taskId == "" { - WriteJson(w, model.NewErrorResult("param invalid: id", model.CodeInvalidParam)) - return - } - task := Downloader.GetTask(taskId) - if task == nil { - WriteJson(w, model.NewErrorResult("task not found", model.CodeTaskNotFound)) - return - } - WriteJson(w, model.NewOkResult(task)) -} - -func GetTasks(w http.ResponseWriter, r *http.Request) { - filter, errResult := parseFilter(r) - if errResult != nil { - WriteJson(w, errResult) - return - } - - tasks := Downloader.GetTasksByFilter(filter) - WriteJson(w, model.NewOkResult(tasks)) -} - -func GetConfig(w http.ResponseWriter, r *http.Request) { - WriteJson(w, model.NewOkResult(getServerConfig())) -} - -func PutConfig(w http.ResponseWriter, r *http.Request) { - var cfg base.DownloaderStoreConfig - if ReadJson(r, w, &cfg) { - if err := Downloader.PutConfig(&cfg); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - } - WriteJson(w, model.NewNilResult()) -} - -func InstallExtension(w http.ResponseWriter, r *http.Request) { - var req model.InstallExtension - if ReadJson(r, w, &req) { - var ( - installedExt *download.Extension - err error - ) - if req.DevMode { - installedExt, err = Downloader.InstallExtensionByFolder(req.URL, true) - } else { - installedExt, err = Downloader.InstallExtensionByGit(req.URL) - } - if err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewOkResult(installedExt.Identity)) - } -} - -func GetExtensions(w http.ResponseWriter, r *http.Request) { - list := Downloader.GetExtensions() - WriteJson(w, model.NewOkResult(list)) -} - -func GetExtension(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - identity := vars["identity"] - ext, err := Downloader.GetExtension(identity) - if err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewOkResult(ext)) -} - -func UpdateExtensionSettings(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - identity := vars["identity"] - var req model.UpdateExtensionSettings - if ReadJson(r, w, &req) { - if err := Downloader.UpdateExtensionSettings(identity, req.Settings); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - } - WriteJson(w, model.NewNilResult()) -} - -func SwitchExtension(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - identity := vars["identity"] - var switchExtension model.SwitchExtension - if ReadJson(r, w, &switchExtension) { - if err := Downloader.SwitchExtension(identity, switchExtension.Status); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - } - WriteJson(w, model.NewNilResult()) -} - -func DeleteExtension(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - identity := vars["identity"] - if err := Downloader.DeleteExtension(identity); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewNilResult()) -} - -func UpdateCheckExtension(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - identity := vars["identity"] - newVersion, err := Downloader.UpgradeCheckExtension(identity) - if err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewOkResult(&model.UpdateCheckExtensionResp{ - NewVersion: newVersion, - })) -} - -func UpdateExtension(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - identity := vars["identity"] - if err := Downloader.UpgradeExtension(identity); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - 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 - 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"] - if taskId == "" { - WriteJson(w, model.NewErrorResult("param invalid: id", model.CodeInvalidParam)) - return - } - statsResult, err := Downloader.Stats(taskId) - if err != nil { - writeError(w, err.Error()) - return - } - WriteJson(w, model.NewOkResult(statsResult)) -} - -func parseIdFilter(r *http.Request) (*download.TaskFilter, any) { - vars := mux.Vars(r) - taskId := vars["id"] - if taskId == "" { - return nil, model.NewErrorResult("param invalid: id", model.CodeInvalidParam) - } - - filter := &download.TaskFilter{ - IDs: []string{taskId}, - } - return filter, nil -} - -func parseFilter(r *http.Request) (*download.TaskFilter, any) { - if err := r.ParseForm(); err != nil { - return nil, model.NewErrorResult(err.Error()) - } - - filter := &download.TaskFilter{ - IDs: r.Form["id"], - Statuses: convertStatues(r.Form["status"]), - NotStatuses: convertStatues(r.Form["notStatus"]), - } - return filter, nil -} - -func convertStatues(statues []string) []base.Status { - result := make([]base.Status, 0) - for _, status := range statues { - result = append(result, base.Status(status)) - } - return result -} - -func writeError(w http.ResponseWriter, msg string) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(msg)) -} - -func getServerConfig() *base.DownloaderStoreConfig { - cfg, _ := Downloader.GetConfig() - return cfg -} diff --git a/pkg/rest/config.go b/pkg/rest/config.go deleted file mode 100644 index fddff6b7..00000000 --- a/pkg/rest/config.go +++ /dev/null @@ -1,6 +0,0 @@ -package rest - -type Config struct { - Host string `json:"host"` - Port int `json:"port"` -} diff --git a/pkg/rest/server.go b/pkg/rest/server.go deleted file mode 100644 index 9c0836d4..00000000 --- a/pkg/rest/server.go +++ /dev/null @@ -1,241 +0,0 @@ -package rest - -import ( - "context" - "encoding/json" - "fmt" - "github.com/GopeedLab/gopeed/pkg/download" - "github.com/GopeedLab/gopeed/pkg/rest/model" - "github.com/GopeedLab/gopeed/pkg/util" - "github.com/gorilla/handlers" - "github.com/gorilla/mux" - "github.com/pkg/errors" - "net" - "net/http" - "os" - "path/filepath" - "regexp" - "strings" -) - -var ( - srv *http.Server - runningPort int - - Downloader *download.Downloader -) - -func Start(startCfg *model.StartConfig) (port int, err error) { - // avoid repeat start - if srv != nil { - return runningPort, nil - } - - var listener net.Listener - srv, listener, err = BuildServer(startCfg) - if err != nil { - return - } - - go func() { - if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { - panic(err) - } - }() - - if addr, ok := listener.Addr().(*net.TCPAddr); ok { - port = addr.Port - runningPort = port - } - return -} - -func Stop() { - defer func() { - srv = nil - }() - - if srv != nil { - if err := srv.Shutdown(context.TODO()); err != nil { - Downloader.Logger.Warn().Err(err).Msg("shutdown server failed") - } - } - if Downloader != nil { - if err := Downloader.Close(); err != nil { - Downloader.Logger.Warn().Err(err).Msg("close downloader failed") - } - } -} - -func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error) { - if startCfg == nil { - startCfg = &model.StartConfig{} - } - startCfg.Init() - - downloadCfg := &download.DownloaderConfig{ - ProductionMode: startCfg.ProductionMode, - RefreshInterval: startCfg.RefreshInterval, - } - if startCfg.Storage == model.StorageBolt { - downloadCfg.Storage = download.NewBoltStorage(startCfg.StorageDir) - } else { - downloadCfg.Storage = download.NewMemStorage() - } - downloadCfg.StorageDir = startCfg.StorageDir - downloadCfg.Init() - Downloader = download.NewDownloader(downloadCfg) - if err := Downloader.Setup(); err != nil { - return nil, nil, err - } - - if startCfg.Network == "unix" { - util.SafeRemove(startCfg.Address) - } - - listener, err := net.Listen(startCfg.Network, startCfg.Address) - if err != nil { - return nil, nil, err - } - - var r = mux.NewRouter() - r.Methods(http.MethodGet).Path("/api/v1/info").HandlerFunc(Info) - r.Methods(http.MethodPost).Path("/api/v1/resolve").HandlerFunc(Resolve) - r.Methods(http.MethodPost).Path("/api/v1/tasks").HandlerFunc(CreateTask) - r.Methods(http.MethodPost).Path("/api/v1/tasks/batch").HandlerFunc(CreateTaskBatch) - r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/pause").HandlerFunc(PauseTask) - r.Methods(http.MethodPut).Path("/api/v1/tasks/pause").HandlerFunc(PauseTasks) - r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/continue").HandlerFunc(ContinueTask) - r.Methods(http.MethodPut).Path("/api/v1/tasks/continue").HandlerFunc(ContinueTasks) - r.Methods(http.MethodDelete).Path("/api/v1/tasks/{id}").HandlerFunc(DeleteTask) - r.Methods(http.MethodDelete).Path("/api/v1/tasks").HandlerFunc(DeleteTasks) - r.Methods(http.MethodGet).Path("/api/v1/tasks/{id}").HandlerFunc(GetTask) - r.Methods(http.MethodGet).Path("/api/v1/tasks").HandlerFunc(GetTasks) - r.Methods(http.MethodGet).Path("/api/v1/tasks/{id}/stats").HandlerFunc(GetStats) - r.Methods(http.MethodGet).Path("/api/v1/config").HandlerFunc(GetConfig) - r.Methods(http.MethodPut).Path("/api/v1/config").HandlerFunc(PutConfig) - r.Methods(http.MethodPost).Path("/api/v1/extensions").HandlerFunc(InstallExtension) - r.Methods(http.MethodGet).Path("/api/v1/extensions").HandlerFunc(GetExtensions) - r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}").HandlerFunc(GetExtension) - r.Methods(http.MethodPut).Path("/api/v1/extensions/{identity}/settings").HandlerFunc(UpdateExtensionSettings) - r.Methods(http.MethodPut).Path("/api/v1/extensions/{identity}/switch").HandlerFunc(SwitchExtension) - r.Methods(http.MethodDelete).Path("/api/v1/extensions/{identity}").HandlerFunc(DeleteExtension) - r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateCheckExtension) - r.Methods(http.MethodPost).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateExtension) - r.Path("/api/v1/proxy").HandlerFunc(DoProxy) - if startCfg.WebEnable { - r.PathPrefix("/fs/tasks").Handler(http.FileServer(new(taskFileSystem))) - r.PathPrefix("/fs/extensions").Handler(http.FileServer(new(extensionFileSystem))) - r.PathPrefix("/").Handler(http.FileServer(http.FS(startCfg.WebFS))) - } - - if startCfg.ApiToken != "" || (startCfg.WebEnable && startCfg.WebBasicAuth != nil) { - r.Use(func(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if startCfg.ApiToken != "" && r.Header.Get("X-Api-Token") == startCfg.ApiToken { - h.ServeHTTP(w, r) - return - } - if startCfg.WebEnable && startCfg.WebBasicAuth != nil { - if r.Header.Get("Authorization") == startCfg.WebBasicAuth.Authorization() { - h.ServeHTTP(w, r) - return - } - w.Header().Set("WWW-Authenticate", "Basic realm=\"gopeed web\"") - } - WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized)) - }) - }) - } - - // recover panic - r.Use(func(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer func() { - if v := recover(); v != nil { - err := errors.WithStack(fmt.Errorf("%v", v)) - Downloader.Logger.Error().Stack().Err(err).Msgf("http server panic: %s %s", r.Method, r.RequestURI) - WriteJson(w, model.NewErrorResult(err.Error(), model.CodeError)) - } - }() - h.ServeHTTP(w, r) - }) - }) - - srv = &http.Server{Handler: handlers.CORS( - handlers.AllowedHeaders([]string{"Content-Type", "X-Api-Token", "X-Target-Uri"}), - handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}), - handlers.AllowedOrigins([]string{"*"}), - )(r)} - return srv, listener, nil -} - -func resolvePath(urlPath string, prefix string) (identity string, path string, err error) { - // remove prefix - clearPath := strings.TrimPrefix(urlPath, prefix) - // match extension identity, eg: /fs/extensions/identity/xxx - reg := regexp.MustCompile(`^/([^/]+)/(.*)$`) - if !reg.MatchString(clearPath) { - err = os.ErrNotExist - return - } - matched := reg.FindStringSubmatch(clearPath) - if len(matched) != 3 { - err = os.ErrNotExist - return - } - return matched[1], matched[2], nil -} - -// handle task file resource -type taskFileSystem struct { -} - -func (e *taskFileSystem) Open(name string) (http.File, error) { - // get extension identity - identity, path, err := resolvePath(name, "/fs/tasks") - if err != nil { - return nil, err - } - task := Downloader.GetTask(identity) - if task == nil { - return nil, os.ErrNotExist - } - return os.Open(filepath.Join(task.Meta.RootDirPath(), path)) -} - -// handle extension file resource -type extensionFileSystem struct { -} - -func (e *extensionFileSystem) Open(name string) (http.File, error) { - // get extension identity - identity, path, err := resolvePath(name, "/fs/extensions") - if err != nil { - return nil, err - } - extension, err := Downloader.GetExtension(identity) - if err != nil { - return nil, os.ErrNotExist - } - extensionPath := Downloader.ExtensionPath(extension) - return os.Open(filepath.Join(extensionPath, path)) -} - -func ReadJson(r *http.Request, w http.ResponseWriter, v any) bool { - if err := json.NewDecoder(r.Body).Decode(v); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return false - } - return true -} - -func WriteJson(w http.ResponseWriter, v any) { - WriteStatusJson(w, http.StatusOK, v) -} - -func WriteStatusJson(w http.ResponseWriter, statusCode int, v any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(statusCode) - json.NewEncoder(w).Encode(v) -}