mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-07 09:00:47 +00:00
feat: move native communication to FFI (#1457)
This commit is contained in:
86 files changed
+4558
-886
No files matched your search
@@ -0,0 +1,82 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
)
|
||||
|
||||
type TaskEventMask uint64
|
||||
|
||||
const (
|
||||
TaskEventDone TaskEventMask = 1 << iota
|
||||
TaskEventError
|
||||
)
|
||||
|
||||
type TaskEvent struct {
|
||||
Type string `json:"type"`
|
||||
TaskID string `json:"taskId"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type TaskEventListener func(payload string)
|
||||
|
||||
type taskEventSubscription struct {
|
||||
mu sync.RWMutex
|
||||
mask TaskEventMask
|
||||
listener TaskEventListener
|
||||
}
|
||||
|
||||
func (s *Service) SubscribeTaskEvents(mask TaskEventMask, listener TaskEventListener) {
|
||||
s.taskEvents.mu.Lock()
|
||||
defer s.taskEvents.mu.Unlock()
|
||||
s.taskEvents.mask = mask
|
||||
s.taskEvents.listener = listener
|
||||
}
|
||||
|
||||
func (s *Service) emitTaskEvent(event *download.Event) {
|
||||
if event == nil || event.Task == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var mask TaskEventMask
|
||||
switch event.Key {
|
||||
case download.EventKeyDone:
|
||||
mask = TaskEventDone
|
||||
case download.EventKeyError:
|
||||
mask = TaskEventError
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
s.taskEvents.mu.RLock()
|
||||
if s.taskEvents.mask&mask == 0 || s.taskEvents.listener == nil {
|
||||
s.taskEvents.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
listener := s.taskEvents.listener
|
||||
s.taskEvents.mu.RUnlock()
|
||||
|
||||
payload := TaskEvent{
|
||||
Type: "task." + string(event.Key),
|
||||
TaskID: event.Task.ID,
|
||||
}
|
||||
if event.Task.Meta != nil {
|
||||
payload.Name = event.Task.Name()
|
||||
}
|
||||
if event.Err != nil {
|
||||
payload.Error = event.Err.Error()
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// A platform callback must never be able to crash a download worker.
|
||||
func() {
|
||||
defer func() { _ = recover() }()
|
||||
listener(string(data))
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
)
|
||||
|
||||
func TestTaskEventSubscriptionFiltersMask(t *testing.T) {
|
||||
service := &Service{}
|
||||
var payloads []string
|
||||
service.SubscribeTaskEvents(TaskEventDone, func(payload string) {
|
||||
payloads = append(payloads, payload)
|
||||
})
|
||||
|
||||
task := &download.Task{ID: "task-1"}
|
||||
service.emitTaskEvent(&download.Event{Key: download.EventKeyError, Task: task})
|
||||
service.emitTaskEvent(&download.Event{Key: download.EventKeyDone, Task: task})
|
||||
|
||||
if len(payloads) != 1 {
|
||||
t.Fatalf("got %d callbacks, want 1", len(payloads))
|
||||
}
|
||||
var event TaskEvent
|
||||
if err := json.Unmarshal([]byte(payloads[0]), &event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event.Type != "task.done" || event.TaskID != task.ID {
|
||||
t.Fatalf("unexpected event: %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskEventListenerPanicIsContained(t *testing.T) {
|
||||
service := &Service{}
|
||||
service.SubscribeTaskEvents(TaskEventDone, func(string) { panic("callback failed") })
|
||||
service.emitTaskEvent(&download.Event{Key: download.EventKeyDone, Task: &download.Task{ID: "task-1"}})
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
pathpkg "path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type HandlerFunc func(*Context) *Response
|
||||
|
||||
type RouteSpec struct {
|
||||
Method string
|
||||
Pattern string
|
||||
Handler HandlerFunc
|
||||
parts []routePart
|
||||
}
|
||||
|
||||
type routePart struct {
|
||||
literal string
|
||||
param string
|
||||
}
|
||||
|
||||
func newRoute(method, pattern string, handler HandlerFunc) RouteSpec {
|
||||
pattern = cleanPath(pattern)
|
||||
return RouteSpec{
|
||||
Method: strings.ToUpper(method),
|
||||
Pattern: pattern,
|
||||
Handler: handler,
|
||||
parts: parseRouteParts(pattern),
|
||||
}
|
||||
}
|
||||
|
||||
func validateRoutes(routes []RouteSpec) error {
|
||||
patterns := make(map[string]struct{}, len(routes))
|
||||
for _, route := range routes {
|
||||
if route.Method == "" || route.Pattern == "" || route.Handler == nil {
|
||||
return fmt.Errorf("invalid API route: %+v", route)
|
||||
}
|
||||
key := route.Method + " " + route.Pattern
|
||||
if _, exists := patterns[key]; exists {
|
||||
return fmt.Errorf("duplicate API route: %s", key)
|
||||
}
|
||||
patterns[key] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRouteParts(pattern string) []routePart {
|
||||
pattern = strings.Trim(pattern, "/")
|
||||
if pattern == "" {
|
||||
return nil
|
||||
}
|
||||
segments := strings.Split(pattern, "/")
|
||||
parts := make([]routePart, 0, len(segments))
|
||||
for _, segment := range segments {
|
||||
if strings.HasPrefix(segment, "{") && strings.HasSuffix(segment, "}") {
|
||||
name := strings.TrimSuffix(strings.TrimPrefix(segment, "{"), "}")
|
||||
parts = append(parts, routePart{param: name})
|
||||
continue
|
||||
}
|
||||
parts = append(parts, routePart{literal: segment})
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func (r RouteSpec) Match(rawPath string) (map[string]string, bool) {
|
||||
path := strings.Trim(cleanPath(rawPath), "/")
|
||||
if path == "" && len(r.parts) == 0 {
|
||||
return map[string]string{}, true
|
||||
}
|
||||
segments := strings.Split(path, "/")
|
||||
if len(segments) != len(r.parts) {
|
||||
return nil, false
|
||||
}
|
||||
params := make(map[string]string)
|
||||
for index, part := range r.parts {
|
||||
if part.param != "" {
|
||||
params[part.param] = segments[index]
|
||||
continue
|
||||
}
|
||||
if part.literal != segments[index] {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return params, true
|
||||
}
|
||||
|
||||
func cleanPath(rawPath string) string {
|
||||
clean := pathpkg.Clean("/" + strings.TrimSpace(rawPath))
|
||||
if clean == "." {
|
||||
return "/"
|
||||
}
|
||||
return clean
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
restmodel "github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
|
||||
type Context struct {
|
||||
Method string
|
||||
Path string
|
||||
Query url.Values
|
||||
Body []byte
|
||||
Headers http.Header
|
||||
PathParams map[string]string
|
||||
}
|
||||
|
||||
func (c *Context) PathParam(name string) string {
|
||||
if c == nil || c.PathParams == nil {
|
||||
return ""
|
||||
}
|
||||
return c.PathParams[name]
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
StatusCode int
|
||||
Body any
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Downloader *download.Downloader
|
||||
routes []RouteSpec
|
||||
taskEvents taskEventSubscription
|
||||
}
|
||||
|
||||
func NewService(downloader *download.Downloader) (*Service, error) {
|
||||
if downloader == nil {
|
||||
return nil, fmt.Errorf("downloader is nil")
|
||||
}
|
||||
service := &Service{Downloader: downloader}
|
||||
service.routes = []RouteSpec{
|
||||
newRoute(http.MethodGet, "/api/v1/info", service.info),
|
||||
newRoute(http.MethodPost, "/api/v1/resolve", service.resolve),
|
||||
newRoute(http.MethodPost, "/api/v1/tasks", service.createTask),
|
||||
newRoute(http.MethodPost, "/api/v1/tasks/batch", service.createTaskBatch),
|
||||
newRoute(http.MethodPatch, "/api/v1/tasks/{id}", service.patchTask),
|
||||
newRoute(http.MethodPut, "/api/v1/tasks/{id}/pause", service.pauseTask),
|
||||
newRoute(http.MethodPut, "/api/v1/tasks/pause", service.pauseTasks),
|
||||
newRoute(http.MethodPut, "/api/v1/tasks/{id}/continue", service.continueTask),
|
||||
newRoute(http.MethodPut, "/api/v1/tasks/continue", service.continueTasks),
|
||||
newRoute(http.MethodDelete, "/api/v1/tasks/{id}", service.deleteTask),
|
||||
newRoute(http.MethodDelete, "/api/v1/tasks", service.deleteTasks),
|
||||
newRoute(http.MethodGet, "/api/v1/tasks/{id}", service.getTask),
|
||||
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.MethodGet, "/api/v1/config", service.getConfig),
|
||||
newRoute(http.MethodPut, "/api/v1/config", service.putConfig),
|
||||
newRoute(http.MethodPost, "/api/v1/extensions", service.installExtension),
|
||||
newRoute(http.MethodGet, "/api/v1/extensions", service.getExtensions),
|
||||
newRoute(http.MethodGet, "/api/v1/extensions/{identity}", service.getExtension),
|
||||
newRoute(http.MethodPut, "/api/v1/extensions/{identity}/settings", service.updateExtensionSettings),
|
||||
newRoute(http.MethodPut, "/api/v1/extensions/{identity}/switch", service.switchExtension),
|
||||
newRoute(http.MethodDelete, "/api/v1/extensions/{identity}", service.deleteExtension),
|
||||
newRoute(http.MethodGet, "/api/v1/extensions/{identity}/update", service.updateCheckExtension),
|
||||
newRoute(http.MethodPost, "/api/v1/extensions/{identity}/update", service.updateExtension),
|
||||
newRoute(http.MethodPost, "/api/v1/webhook/test", service.testWebhook),
|
||||
}
|
||||
if err := validateRoutes(service.routes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
downloader.Listener(service.emitTaskEvent)
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (s *Service) RouteSpecs() []RouteSpec {
|
||||
return append([]RouteSpec(nil), s.routes...)
|
||||
}
|
||||
|
||||
func (s *Service) Dispatch(method, rawPath string, query url.Values, body []byte, headers http.Header) (response *Response) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
response = newErrorResponse(fmt.Sprintf("%v", recovered))
|
||||
}
|
||||
}()
|
||||
|
||||
method = strings.ToUpper(strings.TrimSpace(method))
|
||||
path := cleanPath(rawPath)
|
||||
for _, route := range s.routes {
|
||||
if route.Method != method {
|
||||
continue
|
||||
}
|
||||
pathParams, matched := route.Match(path)
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
return route.Handler(&Context{
|
||||
Method: method,
|
||||
Path: path,
|
||||
Query: query,
|
||||
Body: body,
|
||||
Headers: headers,
|
||||
PathParams: pathParams,
|
||||
})
|
||||
}
|
||||
return newStatusResponse(http.StatusNotFound, restmodel.NewErrorResult("not found"))
|
||||
}
|
||||
|
||||
func (s *Service) JSON(method, rawPath string, query url.Values, body []byte, headers http.Header) (int, []byte) {
|
||||
response := s.Dispatch(method, rawPath, query, body, headers)
|
||||
data, err := json.Marshal(response.Body)
|
||||
if err != nil {
|
||||
response = newErrorResponse(err.Error())
|
||||
data, _ = json.Marshal(response.Body)
|
||||
}
|
||||
return response.StatusCode, data
|
||||
}
|
||||
|
||||
func (s *Service) info(_ *Context) *Response {
|
||||
return newOKResponse(map[string]any{
|
||||
"version": base.Version,
|
||||
"runtime": runtime.Version(),
|
||||
"os": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"inDocker": base.InDocker == "true",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) resolve(ctx *Context) *Response {
|
||||
var request restmodel.ResolveTask
|
||||
if response := decodeRequest(ctx.Body, &request); response != nil {
|
||||
return response
|
||||
}
|
||||
result, err := s.Downloader.Resolve(request.Req, request.Opts)
|
||||
if err != nil {
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return newOKResponse(result)
|
||||
}
|
||||
|
||||
func (s *Service) createTask(ctx *Context) *Response {
|
||||
var request restmodel.CreateTask
|
||||
if response := decodeRequest(ctx.Body, &request); response != nil {
|
||||
return response
|
||||
}
|
||||
var (
|
||||
taskID string
|
||||
err error
|
||||
)
|
||||
if request.Rid != "" {
|
||||
taskID, err = s.Downloader.Create(request.Rid)
|
||||
} else if request.Req != nil {
|
||||
taskID, err = s.Downloader.CreateDirect(request.Req, request.Opts)
|
||||
} else {
|
||||
return newErrorResponse("param invalid: rid or req", restmodel.CodeInvalidParam)
|
||||
}
|
||||
if err != nil {
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return newOKResponse(taskID)
|
||||
}
|
||||
|
||||
func (s *Service) createTaskBatch(ctx *Context) *Response {
|
||||
var request base.CreateTaskBatch
|
||||
if response := decodeRequest(ctx.Body, &request); response != nil {
|
||||
return response
|
||||
}
|
||||
if len(request.Reqs) == 0 {
|
||||
return newErrorResponse("param invalid: reqs", restmodel.CodeInvalidParam)
|
||||
}
|
||||
taskIDs, err := s.Downloader.CreateDirectBatch(&request)
|
||||
if err != nil {
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return newOKResponse(taskIDs)
|
||||
}
|
||||
|
||||
func (s *Service) patchTask(ctx *Context) *Response {
|
||||
taskID := ctx.PathParam("id")
|
||||
if taskID == "" {
|
||||
return invalidIDResponse()
|
||||
}
|
||||
var request restmodel.ResolveTask
|
||||
if response := decodeRequest(ctx.Body, &request); response != nil {
|
||||
return response
|
||||
}
|
||||
if err := s.Downloader.Patch(taskID, request.Req, request.Opts); err != nil {
|
||||
if err == download.ErrTaskNotFound {
|
||||
return taskNotFoundResponse()
|
||||
}
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return newNilResponse()
|
||||
}
|
||||
|
||||
func (s *Service) pauseTask(ctx *Context) *Response {
|
||||
return s.applyTaskAction(ctx, s.Downloader.Pause)
|
||||
}
|
||||
|
||||
func (s *Service) pauseTasks(ctx *Context) *Response {
|
||||
return applyFilterAction(taskFilterFromQuery(ctx.Query), s.Downloader.Pause)
|
||||
}
|
||||
|
||||
func (s *Service) continueTask(ctx *Context) *Response {
|
||||
return s.applyTaskAction(ctx, s.Downloader.Continue)
|
||||
}
|
||||
|
||||
func (s *Service) continueTasks(ctx *Context) *Response {
|
||||
return applyFilterAction(taskFilterFromQuery(ctx.Query), s.Downloader.Continue)
|
||||
}
|
||||
|
||||
func (s *Service) applyTaskAction(ctx *Context, action func(*download.TaskFilter) error) *Response {
|
||||
taskID := ctx.PathParam("id")
|
||||
if taskID == "" {
|
||||
return invalidIDResponse()
|
||||
}
|
||||
return applyFilterAction(&download.TaskFilter{IDs: []string{taskID}}, action)
|
||||
}
|
||||
|
||||
func applyFilterAction(filter *download.TaskFilter, action func(*download.TaskFilter) error) *Response {
|
||||
if err := action(filter); err != nil {
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return newNilResponse()
|
||||
}
|
||||
|
||||
func (s *Service) deleteTask(ctx *Context) *Response {
|
||||
taskID := ctx.PathParam("id")
|
||||
if taskID == "" {
|
||||
return invalidIDResponse()
|
||||
}
|
||||
return deleteTasks(s.Downloader, &download.TaskFilter{IDs: []string{taskID}}, ctx.Query.Get("force") == "true")
|
||||
}
|
||||
|
||||
func (s *Service) deleteTasks(ctx *Context) *Response {
|
||||
return deleteTasks(s.Downloader, taskFilterFromQuery(ctx.Query), ctx.Query.Get("force") == "true")
|
||||
}
|
||||
|
||||
func deleteTasks(downloader *download.Downloader, filter *download.TaskFilter, force bool) *Response {
|
||||
if err := downloader.Delete(filter, force); err != nil {
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return newNilResponse()
|
||||
}
|
||||
|
||||
func (s *Service) getTask(ctx *Context) *Response {
|
||||
taskID := ctx.PathParam("id")
|
||||
if taskID == "" {
|
||||
return invalidIDResponse()
|
||||
}
|
||||
task := s.Downloader.GetTask(taskID)
|
||||
if task == nil {
|
||||
return taskNotFoundResponse()
|
||||
}
|
||||
return newOKResponse(task)
|
||||
}
|
||||
|
||||
func (s *Service) getTaskStatus(ctx *Context) *Response {
|
||||
taskID := ctx.PathParam("id")
|
||||
if taskID == "" {
|
||||
return invalidIDResponse()
|
||||
}
|
||||
status, err := s.Downloader.RuntimeStatus(taskID)
|
||||
if err != nil {
|
||||
if err == download.ErrTaskNotFound {
|
||||
return taskNotFoundResponse()
|
||||
}
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return newOKResponse(status)
|
||||
}
|
||||
|
||||
func (s *Service) getTasks(ctx *Context) *Response {
|
||||
return newOKResponse(s.Downloader.GetTasksByFilter(taskFilterFromQuery(ctx.Query)))
|
||||
}
|
||||
|
||||
func (s *Service) getStats(ctx *Context) *Response {
|
||||
taskID := ctx.PathParam("id")
|
||||
if taskID == "" {
|
||||
return invalidIDResponse()
|
||||
}
|
||||
stats, err := s.Downloader.Stats(taskID)
|
||||
if err != nil {
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return newOKResponse(stats)
|
||||
}
|
||||
|
||||
func (s *Service) getConfig(_ *Context) *Response {
|
||||
config, err := s.Downloader.GetConfig()
|
||||
if err != nil {
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return newOKResponse(config)
|
||||
}
|
||||
|
||||
func (s *Service) putConfig(ctx *Context) *Response {
|
||||
var config base.DownloaderStoreConfig
|
||||
if response := decodeRequest(ctx.Body, &config); response != nil {
|
||||
return response
|
||||
}
|
||||
if err := s.Downloader.PutConfig(&config); err != nil {
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return newNilResponse()
|
||||
}
|
||||
|
||||
func (s *Service) installExtension(ctx *Context) *Response {
|
||||
var request restmodel.InstallExtension
|
||||
if response := decodeRequest(ctx.Body, &request); response != nil {
|
||||
return response
|
||||
}
|
||||
var (
|
||||
extension *download.Extension
|
||||
err error
|
||||
)
|
||||
if request.DevMode {
|
||||
extension, err = s.Downloader.InstallExtensionByFolder(request.URL, true)
|
||||
} else {
|
||||
extension, err = s.Downloader.InstallExtensionByGit(request.URL)
|
||||
}
|
||||
if err != nil {
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return newOKResponse(extension.Identity)
|
||||
}
|
||||
|
||||
func (s *Service) getExtensions(_ *Context) *Response {
|
||||
return newOKResponse(s.Downloader.GetExtensions())
|
||||
}
|
||||
|
||||
func (s *Service) getExtension(ctx *Context) *Response {
|
||||
identity := ctx.PathParam("identity")
|
||||
if identity == "" {
|
||||
return newErrorResponse("param invalid: identity", restmodel.CodeInvalidParam)
|
||||
}
|
||||
extension, err := s.Downloader.GetExtension(identity)
|
||||
if err != nil {
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return newOKResponse(extension)
|
||||
}
|
||||
|
||||
func (s *Service) updateExtensionSettings(ctx *Context) *Response {
|
||||
identity := ctx.PathParam("identity")
|
||||
var request restmodel.UpdateExtensionSettings
|
||||
if response := decodeRequest(ctx.Body, &request); response != nil {
|
||||
return response
|
||||
}
|
||||
if err := s.Downloader.UpdateExtensionSettings(identity, request.Settings); err != nil {
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return newNilResponse()
|
||||
}
|
||||
|
||||
func (s *Service) switchExtension(ctx *Context) *Response {
|
||||
identity := ctx.PathParam("identity")
|
||||
var request restmodel.SwitchExtension
|
||||
if response := decodeRequest(ctx.Body, &request); response != nil {
|
||||
return response
|
||||
}
|
||||
if err := s.Downloader.SwitchExtension(identity, request.Status); err != nil {
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return newNilResponse()
|
||||
}
|
||||
|
||||
func (s *Service) deleteExtension(ctx *Context) *Response {
|
||||
if err := s.Downloader.DeleteExtension(ctx.PathParam("identity")); err != nil {
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return newNilResponse()
|
||||
}
|
||||
|
||||
func (s *Service) updateCheckExtension(ctx *Context) *Response {
|
||||
version, err := s.Downloader.UpgradeCheckExtension(ctx.PathParam("identity"))
|
||||
if err != nil {
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return newOKResponse(&restmodel.UpdateCheckExtensionResp{NewVersion: version})
|
||||
}
|
||||
|
||||
func (s *Service) updateExtension(ctx *Context) *Response {
|
||||
if err := s.Downloader.UpgradeExtension(ctx.PathParam("identity")); err != nil {
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return newNilResponse()
|
||||
}
|
||||
|
||||
func (s *Service) testWebhook(ctx *Context) *Response {
|
||||
var request restmodel.TestWebhookReq
|
||||
if response := decodeRequest(ctx.Body, &request); response != nil {
|
||||
return response
|
||||
}
|
||||
if err := s.Downloader.TestWebhookUrl(request.URL); err != nil {
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return newNilResponse()
|
||||
}
|
||||
|
||||
func decodeRequest(body []byte, target any) *Response {
|
||||
if err := json.NewDecoder(bytes.NewReader(body)).Decode(target); err != nil {
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func taskFilterFromQuery(query url.Values) *download.TaskFilter {
|
||||
if query == nil {
|
||||
query = url.Values{}
|
||||
}
|
||||
return &download.TaskFilter{
|
||||
IDs: query["id"],
|
||||
Statuses: convertStatuses(query["status"]),
|
||||
NotStatuses: convertStatuses(query["notStatus"]),
|
||||
}
|
||||
}
|
||||
|
||||
func convertStatuses(values []string) []base.Status {
|
||||
statuses := make([]base.Status, 0, len(values))
|
||||
for _, value := range values {
|
||||
statuses = append(statuses, base.Status(value))
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
|
||||
func invalidIDResponse() *Response {
|
||||
return newErrorResponse("param invalid: id", restmodel.CodeInvalidParam)
|
||||
}
|
||||
|
||||
func taskNotFoundResponse() *Response {
|
||||
return newErrorResponse("task not found", restmodel.CodeTaskNotFound)
|
||||
}
|
||||
|
||||
func newOKResponse(data any) *Response {
|
||||
return &Response{StatusCode: http.StatusOK, Body: restmodel.NewOkResult(data)}
|
||||
}
|
||||
|
||||
func newNilResponse() *Response {
|
||||
return &Response{StatusCode: http.StatusOK, Body: restmodel.NewNilResult()}
|
||||
}
|
||||
|
||||
func newErrorResponse(message string, code ...restmodel.RespCode) *Response {
|
||||
return &Response{StatusCode: http.StatusOK, Body: restmodel.NewErrorResult(message, code...)}
|
||||
}
|
||||
|
||||
func newStatusResponse(statusCode int, body any) *Response {
|
||||
return &Response{StatusCode: statusCode, Body: body}
|
||||
}
|
||||
@@ -208,6 +208,7 @@ type DownloaderStoreConfig struct {
|
||||
Script *ScriptConfig `json:"script"` // Script is the script execution configuration
|
||||
AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration
|
||||
Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction configuration
|
||||
API *APIServerConfig `json:"api"` // API is the optional REST server configuration
|
||||
AutoStartTasks bool `json:"autoStartTasks"` // AutoStartTasks continues all unfinished tasks when the backend starts
|
||||
AutoDeleteMissingFileTasks bool `json:"autoDeleteMissingFileTasks"` // AutoDeleteMissingFileTasks enables automatic deletion of tasks with missing files
|
||||
}
|
||||
@@ -274,6 +275,26 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
|
||||
if cfg.Archive == nil {
|
||||
cfg.Archive = beforeCfg.Archive
|
||||
}
|
||||
if cfg.API == nil {
|
||||
cfg.API = beforeCfg.API
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
type APIServerConfig struct {
|
||||
Enable bool `json:"enable"`
|
||||
Network string `json:"network"`
|
||||
Address string `json:"address"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func (cfg *APIServerConfig) Init() *APIServerConfig {
|
||||
if cfg.Network == "" {
|
||||
cfg.Network = "tcp"
|
||||
}
|
||||
if cfg.Address == "" {
|
||||
cfg.Address = "127.0.0.1:9999"
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -351,7 +351,8 @@ func DoProxy(w http.ResponseWriter, r *http.Request) {
|
||||
r.RequestURI = ""
|
||||
r.URL = targetUrl
|
||||
r.Host = targetUrl.Host
|
||||
r.Header.Del("Authorization")
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type apiServerManager struct {
|
||||
server *http.Server
|
||||
runningPort int
|
||||
activeConfig *base.APIServerConfig
|
||||
lastError string
|
||||
}
|
||||
|
||||
var apiServer = &apiServerManager{}
|
||||
|
||||
func GetAPIServerState() (*model.APIServerState, error) {
|
||||
runtimeMu.Lock()
|
||||
defer runtimeMu.Unlock()
|
||||
desired, err := desiredAPIServerConfigLocked()
|
||||
return apiServer.stateLocked(desired), err
|
||||
}
|
||||
|
||||
func StartAPIServer() (*model.APIServerState, error) {
|
||||
runtimeMu.Lock()
|
||||
defer runtimeMu.Unlock()
|
||||
desired, err := desiredAPIServerConfigLocked()
|
||||
if err != nil {
|
||||
return apiServer.stateLocked(nil), err
|
||||
}
|
||||
if !desired.Enable {
|
||||
err = errors.New("API server is disabled in persisted config")
|
||||
apiServer.lastError = err.Error()
|
||||
return apiServer.stateLocked(desired), err
|
||||
}
|
||||
_, err = apiServer.startLocked(desired)
|
||||
return apiServer.stateLocked(desired), err
|
||||
}
|
||||
|
||||
func StopAPIServer() (*model.APIServerState, error) {
|
||||
runtimeMu.Lock()
|
||||
defer runtimeMu.Unlock()
|
||||
desired, configErr := desiredAPIServerConfigLocked()
|
||||
err := apiServer.stopLocked()
|
||||
if err == nil {
|
||||
err = configErr
|
||||
}
|
||||
return apiServer.stateLocked(desired), err
|
||||
}
|
||||
|
||||
func RestartAPIServer() (*model.APIServerState, error) {
|
||||
runtimeMu.Lock()
|
||||
defer runtimeMu.Unlock()
|
||||
desired, err := desiredAPIServerConfigLocked()
|
||||
if err != nil {
|
||||
return apiServer.stateLocked(nil), err
|
||||
}
|
||||
if !desired.Enable {
|
||||
err = errors.New("API server is disabled in persisted config")
|
||||
apiServer.lastError = err.Error()
|
||||
return apiServer.stateLocked(desired), err
|
||||
}
|
||||
err = apiServer.restartLocked(desired)
|
||||
return apiServer.stateLocked(desired), err
|
||||
}
|
||||
|
||||
func desiredAPIServerConfigLocked() (*base.APIServerConfig, error) {
|
||||
if Downloader == nil {
|
||||
return nil, errors.New("service not started")
|
||||
}
|
||||
config, err := Downloader.GetConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if config.API == nil {
|
||||
return (&base.APIServerConfig{}).Init(), nil
|
||||
}
|
||||
return cloneAPIServerConfig(config.API).Init(), nil
|
||||
}
|
||||
|
||||
func (m *apiServerManager) startLocked(config *base.APIServerConfig) (int, error) {
|
||||
if config == nil {
|
||||
return 0, errors.New("API server config is nil")
|
||||
}
|
||||
if m.server != nil {
|
||||
if sameAPIServerConfig(m.activeConfig, config) {
|
||||
m.lastError = ""
|
||||
return m.runningPort, nil
|
||||
}
|
||||
err := errors.New("API server is already running with different config")
|
||||
m.lastError = err.Error()
|
||||
return m.runningPort, err
|
||||
}
|
||||
|
||||
return m.startWithConfigLocked(startConfigForAPIServer(config), config)
|
||||
}
|
||||
|
||||
func (m *apiServerManager) startWithConfigLocked(
|
||||
startConfig *model.StartConfig,
|
||||
activeConfig *base.APIServerConfig,
|
||||
) (int, error) {
|
||||
server, listener, err := buildServer(startConfig, false)
|
||||
if err != nil {
|
||||
m.lastError = err.Error()
|
||||
return 0, err
|
||||
}
|
||||
m.server = server
|
||||
m.runningPort = 0
|
||||
m.activeConfig = cloneAPIServerConfig(activeConfig)
|
||||
m.activeConfig.Enable = true
|
||||
m.lastError = ""
|
||||
if address, ok := listener.Addr().(*net.TCPAddr); ok {
|
||||
m.runningPort = address.Port
|
||||
}
|
||||
go m.serve(server, listener)
|
||||
return m.runningPort, nil
|
||||
}
|
||||
|
||||
func (m *apiServerManager) serve(server *http.Server, listener net.Listener) {
|
||||
err := server.Serve(listener)
|
||||
if err == nil || errors.Is(err, http.ErrServerClosed) {
|
||||
return
|
||||
}
|
||||
runtimeMu.Lock()
|
||||
if m.server == server {
|
||||
m.server = nil
|
||||
m.runningPort = 0
|
||||
m.activeConfig = nil
|
||||
m.lastError = err.Error()
|
||||
}
|
||||
downloader := Downloader
|
||||
runtimeMu.Unlock()
|
||||
if downloader != nil {
|
||||
downloader.Logger.Error().Err(err).Msg("API server stopped unexpectedly")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *apiServerManager) stopLocked() error {
|
||||
server := m.server
|
||||
config := cloneAPIServerConfig(m.activeConfig)
|
||||
if server == nil {
|
||||
m.runningPort = 0
|
||||
m.activeConfig = nil
|
||||
m.lastError = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
err := server.Shutdown(shutdownCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if closeErr := server.Close(); closeErr != nil {
|
||||
err = errors.Wrapf(err, "force close API server failed: %v", closeErr)
|
||||
}
|
||||
}
|
||||
m.server = nil
|
||||
m.runningPort = 0
|
||||
m.activeConfig = nil
|
||||
if config != nil && config.Network == "unix" {
|
||||
util.SafeRemove(config.Address)
|
||||
}
|
||||
if err != nil {
|
||||
m.lastError = err.Error()
|
||||
return errors.Wrap(err, "shutdown API server failed")
|
||||
}
|
||||
m.lastError = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *apiServerManager) restartLocked(desired *base.APIServerConfig) error {
|
||||
if m.server == nil {
|
||||
_, err := m.startLocked(desired)
|
||||
return err
|
||||
}
|
||||
if sameAPIServerConfig(m.activeConfig, desired) {
|
||||
m.lastError = ""
|
||||
return nil
|
||||
}
|
||||
if err := m.stopLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := m.startLocked(desired)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *apiServerManager) stateLocked(desired *base.APIServerConfig) *model.APIServerState {
|
||||
state := &model.APIServerState{Running: m.server != nil, RunningPort: m.runningPort, LastError: m.lastError}
|
||||
if desired != nil {
|
||||
state.Enabled = desired.Enable
|
||||
}
|
||||
if m.activeConfig != nil {
|
||||
state.Network = m.activeConfig.Network
|
||||
state.Address = m.activeConfig.Address
|
||||
}
|
||||
state.PendingApply = !apiServerConfigApplied(desired, m.activeConfig)
|
||||
return state
|
||||
}
|
||||
|
||||
func apiServerConfigApplied(desired, active *base.APIServerConfig) bool {
|
||||
if desired == nil {
|
||||
return false
|
||||
}
|
||||
if !desired.Enable {
|
||||
return active == nil
|
||||
}
|
||||
return active != nil && sameAPIServerConfig(desired, active)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
|
||||
goapi "github.com/GopeedLab/gopeed/pkg/api"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
|
||||
const (
|
||||
TaskEventDone = uint64(goapi.TaskEventDone)
|
||||
TaskEventError = uint64(goapi.TaskEventError)
|
||||
)
|
||||
|
||||
// Invoke dispatches a Core API request without using an HTTP listener. It is
|
||||
// the shared entry point for Desktop FFI and gomobile bindings.
|
||||
func Invoke(method, path, rawQuery, body string) string {
|
||||
service := APIService
|
||||
if service == nil {
|
||||
return marshalInvokeResult(model.NewErrorResult("service not started"))
|
||||
}
|
||||
query, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
return marshalInvokeResult(model.NewErrorResult(err.Error()))
|
||||
}
|
||||
_, data := service.JSON(method, path, query, []byte(body), nil)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func SubscribeTaskEvents(mask uint64, listener func(payload string)) {
|
||||
if APIService == nil {
|
||||
return
|
||||
}
|
||||
APIService.SubscribeTaskEvents(goapi.TaskEventMask(mask), listener)
|
||||
}
|
||||
|
||||
func marshalInvokeResult(value any) string {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return `{"code":1000,"msg":"marshal response failed"}`
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
|
||||
func TestNativeInvokeWithoutRESTListener(t *testing.T) {
|
||||
cfg := &model.StartConfig{
|
||||
NativeMode: true,
|
||||
Storage: model.StorageMem,
|
||||
}
|
||||
port, err := Start(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer Stop()
|
||||
if port != 0 {
|
||||
t.Fatalf("native runtime opened REST port %d by default", port)
|
||||
}
|
||||
|
||||
var result model.Result[json.RawMessage]
|
||||
if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Code != model.CodeOk {
|
||||
t.Fatalf("invoke failed: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIServerLifecycleUsesPersistedConfigWithoutRestartingCore(t *testing.T) {
|
||||
port, err := Start(&model.StartConfig{NativeMode: true, Storage: model.StorageMem})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer Stop()
|
||||
if port != 0 {
|
||||
t.Fatalf("native runtime opened REST port %d by default", port)
|
||||
}
|
||||
|
||||
putAPIServerConfig(t, &base.APIServerConfig{
|
||||
Enable: true,
|
||||
Network: "tcp",
|
||||
Address: "127.0.0.1:0",
|
||||
Token: "secret",
|
||||
})
|
||||
state, err := StartAPIServer()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port = state.RunningPort
|
||||
if port == 0 {
|
||||
t.Fatal("enabling the REST API did not open a TCP listener")
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/api/v1/info", port), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Header.Set("X-Api-Token", "secret")
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("REST API returned status %d", response.StatusCode)
|
||||
}
|
||||
|
||||
putAPIServerConfig(t, &base.APIServerConfig{Enable: false})
|
||||
if state, err := StopAPIServer(); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if state.Running || state.RunningPort != 0 || state.PendingApply {
|
||||
t.Fatalf("unexpected stopped API server state: %#v", state)
|
||||
}
|
||||
|
||||
var result model.Result[json.RawMessage]
|
||||
if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Code != model.CodeOk {
|
||||
t.Fatalf("in-process API stopped with REST listener: %#v", result)
|
||||
}
|
||||
|
||||
if _, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/api/v1/info", port)); err == nil {
|
||||
t.Fatal("REST listener still accepted connections after it was disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartAPIServerFailsFastAndLeavesListenerStopped(t *testing.T) {
|
||||
if _, err := Start(&model.StartConfig{NativeMode: true, Storage: model.StorageMem}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer Stop()
|
||||
|
||||
putAPIServerConfig(t, &base.APIServerConfig{
|
||||
Enable: true,
|
||||
Network: "tcp",
|
||||
Address: "127.0.0.1:0",
|
||||
Token: "secret",
|
||||
})
|
||||
startedState, err := StartAPIServer()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previousPort := startedState.RunningPort
|
||||
putAPIServerConfig(t, &base.APIServerConfig{
|
||||
Enable: true,
|
||||
Network: "unsupported-network",
|
||||
Address: "invalid",
|
||||
})
|
||||
state, err := RestartAPIServer()
|
||||
if err == nil {
|
||||
t.Fatal("invalid listener configuration unexpectedly succeeded")
|
||||
}
|
||||
if state.Running || state.RunningPort != 0 || !state.PendingApply || state.LastError == "" {
|
||||
t.Fatalf("failed restart did not leave REST listener stopped: %#v", state)
|
||||
}
|
||||
|
||||
if _, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/api/v1/info", previousPort)); err == nil {
|
||||
t.Fatal("previous REST listener still accepted connections after failed restart")
|
||||
}
|
||||
|
||||
var result model.Result[json.RawMessage]
|
||||
if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Code != model.CodeOk {
|
||||
t.Fatalf("in-process API unavailable after failed REST restart: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeRESTSettingPersistsInGoStorage(t *testing.T) {
|
||||
storageDir := filepath.Join(t.TempDir(), "storage")
|
||||
start := func() int {
|
||||
port, err := Start(&model.StartConfig{
|
||||
NativeMode: true,
|
||||
Storage: model.StorageBolt,
|
||||
StorageDir: storageDir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
if port := start(); port != 0 {
|
||||
t.Fatalf("first native start opened REST port %d", port)
|
||||
}
|
||||
config, err := Downloader.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config.API = newAPIServerConfigForTest()
|
||||
if err := Downloader.PutConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Stop()
|
||||
|
||||
if port := start(); port == 0 {
|
||||
Stop()
|
||||
t.Fatal("persisted REST setting did not open a listener")
|
||||
}
|
||||
Stop()
|
||||
}
|
||||
|
||||
func TestNativeAPIServerAutoStartFailureKeepsCoreAvailable(t *testing.T) {
|
||||
storageDir := filepath.Join(t.TempDir(), "storage")
|
||||
startConfig := &model.StartConfig{
|
||||
NativeMode: true,
|
||||
Storage: model.StorageBolt,
|
||||
StorageDir: storageDir,
|
||||
}
|
||||
if _, err := Start(startConfig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
putAPIServerConfig(t, &base.APIServerConfig{
|
||||
Enable: true,
|
||||
Network: "unsupported-network",
|
||||
Address: "invalid",
|
||||
})
|
||||
Stop()
|
||||
|
||||
port, err := Start(startConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("optional REST listener failure stopped native core startup: %v", err)
|
||||
}
|
||||
defer Stop()
|
||||
if port != 0 {
|
||||
t.Fatalf("failed REST listener reported running port %d", port)
|
||||
}
|
||||
state, err := GetAPIServerState()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !state.Enabled || state.Running || !state.PendingApply || state.LastError == "" {
|
||||
t.Fatalf("unexpected state after optional REST listener failure: %#v", state)
|
||||
}
|
||||
|
||||
var result model.Result[json.RawMessage]
|
||||
if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Code != model.CodeOk {
|
||||
t.Fatalf("in-process API unavailable after REST listener failure: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func newAPIServerConfigForTest() *base.APIServerConfig {
|
||||
return &base.APIServerConfig{Enable: true, Network: "tcp", Address: "127.0.0.1:0"}
|
||||
}
|
||||
|
||||
func putAPIServerConfig(t *testing.T, apiConfig *base.APIServerConfig) {
|
||||
t.Helper()
|
||||
config, err := Downloader.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config.API = apiConfig
|
||||
if err := Downloader.PutConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
"io/fs"
|
||||
@@ -16,6 +18,7 @@ const (
|
||||
type StartConfig struct {
|
||||
Network string `json:"network"`
|
||||
Address string `json:"address"`
|
||||
ApiEnable *bool `json:"apiEnable,omitempty"`
|
||||
RefreshInterval int `json:"refreshInterval"`
|
||||
Storage Storage `json:"storage"`
|
||||
StorageDir string `json:"storageDir"`
|
||||
@@ -25,6 +28,7 @@ type StartConfig struct {
|
||||
WebViewRPCConfig *enginewebview.RPCConfig `json:"webViewRpcConfig,omitempty"`
|
||||
|
||||
ProductionMode bool
|
||||
NativeMode bool
|
||||
WebViewProvider enginewebview.Provider `json:"-"`
|
||||
|
||||
WebEnable bool
|
||||
@@ -32,6 +36,26 @@ type StartConfig struct {
|
||||
WebAuth *WebAuth
|
||||
}
|
||||
|
||||
func (cfg *StartConfig) APIEnabled() bool {
|
||||
if cfg == nil || cfg.WebEnable {
|
||||
return true
|
||||
}
|
||||
// Preserve the behavior of existing CLI and library callers. Native
|
||||
// Flutter explicitly sends false when it wants an in-process-only runtime.
|
||||
return cfg.ApiEnable == nil || *cfg.ApiEnable
|
||||
}
|
||||
|
||||
func (cfg *StartConfig) Validate() error {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
if cfg.WebEnable && cfg.ApiToken != "" &&
|
||||
(cfg.WebAuth == nil || cfg.WebAuth.Username == "" || cfg.WebAuth.Password == "") {
|
||||
return errors.New("web authentication username and password are required when API token is enabled for the web server")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *StartConfig) Init() *StartConfig {
|
||||
if cfg.Network == "" {
|
||||
cfg.Network = "tcp"
|
||||
@@ -55,3 +79,20 @@ type WebAuth struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// APIServerState describes the listener that is actually running. The
|
||||
// persisted APIServerConfig remains the desired state.
|
||||
type APIServerState struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Running bool `json:"running"`
|
||||
Network string `json:"network"`
|
||||
Address string `json:"address"`
|
||||
RunningPort int `json:"runningPort"`
|
||||
PendingApply bool `json:"pendingApply"`
|
||||
LastError string `json:"lastError"`
|
||||
}
|
||||
|
||||
type APIServerOperationResult struct {
|
||||
State *APIServerState `json:"state"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStartConfigValidateWebAPIToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *StartConfig
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "web API token without web auth",
|
||||
config: &StartConfig{WebEnable: true, ApiToken: "token"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "web API token with web auth",
|
||||
config: &StartConfig{
|
||||
WebEnable: true,
|
||||
ApiToken: "token",
|
||||
WebAuth: &WebAuth{
|
||||
Username: "admin",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "web without authentication",
|
||||
config: &StartConfig{WebEnable: true},
|
||||
},
|
||||
{
|
||||
name: "native REST API token",
|
||||
config: &StartConfig{NativeMode: true, ApiToken: "token"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+223
-173
@@ -1,9 +1,6 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -15,10 +12,12 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
goapi "github.com/GopeedLab/gopeed/pkg/api"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
@@ -28,83 +27,154 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
srv *http.Server
|
||||
runningPort int
|
||||
aesKey []byte
|
||||
runtimeMu sync.Mutex
|
||||
|
||||
Downloader *download.Downloader
|
||||
APIService *goapi.Service
|
||||
)
|
||||
|
||||
const (
|
||||
webAuthCookieName = "gopeed_web_auth"
|
||||
webAuthTokenTTL = 7 * 24 * time.Hour
|
||||
webAuthCookieName = "gopeed-session"
|
||||
webAuthSessionTTL = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
func Start(startCfg *model.StartConfig) (port int, err error) {
|
||||
// avoid repeat start
|
||||
if srv != nil {
|
||||
return runningPort, nil
|
||||
type webSessionStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]time.Time
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func newWebSessionStore() *webSessionStore {
|
||||
return &webSessionStore{
|
||||
sessions: make(map[string]time.Time),
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *webSessionStore) create() (string, time.Time, error) {
|
||||
rawToken := make([]byte, 32)
|
||||
if _, err := rand.Read(rawToken); err != nil {
|
||||
return "", time.Time{}, errors.Wrap(err, "generate web session failed")
|
||||
}
|
||||
|
||||
var listener net.Listener
|
||||
srv, listener, err = BuildServer(startCfg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
sessionID := base64.RawURLEncoding.EncodeToString(rawToken)
|
||||
expiresAt := s.now().Add(webAuthSessionTTL)
|
||||
|
||||
go func() {
|
||||
if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
panic(err)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for existingToken, expiry := range s.sessions {
|
||||
if !expiry.After(s.now()) {
|
||||
delete(s.sessions, existingToken)
|
||||
}
|
||||
}()
|
||||
|
||||
if addr, ok := listener.Addr().(*net.TCPAddr); ok {
|
||||
port = addr.Port
|
||||
runningPort = port
|
||||
}
|
||||
return
|
||||
s.sessions[sessionID] = expiresAt
|
||||
return sessionID, expiresAt, nil
|
||||
}
|
||||
|
||||
func (s *webSessionStore) valid(sessionID string) bool {
|
||||
if sessionID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
expiresAt, ok := s.sessions[sessionID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if !expiresAt.After(s.now()) {
|
||||
delete(s.sessions, sessionID)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func Start(startCfg *model.StartConfig) (port int, err error) {
|
||||
runtimeMu.Lock()
|
||||
defer runtimeMu.Unlock()
|
||||
|
||||
// avoid repeat start
|
||||
if APIService != nil {
|
||||
return apiServer.runningPort, nil
|
||||
}
|
||||
if startCfg == nil {
|
||||
startCfg = &model.StartConfig{}
|
||||
}
|
||||
startCfg.Init()
|
||||
if err = startCfg.Validate(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err = initializeCore(startCfg); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if startCfg.NativeMode {
|
||||
storedConfig, configErr := Downloader.GetConfig()
|
||||
if configErr != nil {
|
||||
return 0, configErr
|
||||
}
|
||||
apiConfig := storedConfig.API
|
||||
if apiConfig == nil {
|
||||
apiConfig = (&base.APIServerConfig{}).Init()
|
||||
} else {
|
||||
apiConfig.Init()
|
||||
}
|
||||
startCfg.ApiEnable = &apiConfig.Enable
|
||||
startCfg.Network = apiConfig.Network
|
||||
startCfg.Address = apiConfig.Address
|
||||
startCfg.ApiToken = apiConfig.Token
|
||||
}
|
||||
if err := Downloader.ContinueOnStartup(); err != nil {
|
||||
Downloader.Logger.Warn().Err(err).Msg("auto-start tasks failed")
|
||||
}
|
||||
if !startCfg.APIEnabled() {
|
||||
return 0, nil
|
||||
}
|
||||
config := &base.APIServerConfig{
|
||||
Enable: true,
|
||||
Network: startCfg.Network,
|
||||
Address: startCfg.Address,
|
||||
Token: startCfg.ApiToken,
|
||||
}
|
||||
port, startErr := apiServer.startWithConfigLocked(startCfg, config)
|
||||
if startErr != nil && startCfg.NativeMode {
|
||||
Downloader.Logger.Error().Err(startErr).Msg("optional API server failed to start")
|
||||
return 0, nil
|
||||
}
|
||||
return port, startErr
|
||||
}
|
||||
|
||||
func Stop() {
|
||||
defer func() {
|
||||
srv = nil
|
||||
}()
|
||||
|
||||
if srv != nil {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
Downloader.Logger.Warn().Err(err).Msg("shutdown server failed")
|
||||
}
|
||||
runtimeMu.Lock()
|
||||
defer runtimeMu.Unlock()
|
||||
if err := apiServer.stopLocked(); err != nil && Downloader != nil {
|
||||
Downloader.Logger.Warn().Err(err).Msg("shutdown server failed")
|
||||
}
|
||||
if APIService != nil {
|
||||
APIService.SubscribeTaskEvents(0, nil)
|
||||
}
|
||||
if Downloader != nil {
|
||||
if err := Downloader.Close(); err != nil {
|
||||
Downloader.Logger.Warn().Err(err).Msg("close downloader failed")
|
||||
}
|
||||
}
|
||||
APIService = nil
|
||||
apiServer = &apiServerManager{}
|
||||
}
|
||||
|
||||
func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error) {
|
||||
return buildServer(startCfg, true)
|
||||
}
|
||||
|
||||
func buildServer(startCfg *model.StartConfig, continueOnStartup bool) (*http.Server, net.Listener, error) {
|
||||
if startCfg == nil {
|
||||
startCfg = &model.StartConfig{}
|
||||
}
|
||||
startCfg.Init()
|
||||
if err := startCfg.Validate(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
downloadCfg := &download.DownloaderConfig{
|
||||
ProductionMode: startCfg.ProductionMode,
|
||||
RefreshInterval: startCfg.RefreshInterval,
|
||||
WhiteDownloadDirs: startCfg.WhiteDownloadDirs,
|
||||
WebViewProvider: startCfg.WebViewProvider,
|
||||
}
|
||||
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 {
|
||||
if err := initializeCore(startCfg); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -112,77 +182,66 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
||||
util.SafeRemove(startCfg.Address)
|
||||
}
|
||||
|
||||
if startCfg.WebEnable {
|
||||
aesKey = make([]byte, 32)
|
||||
if _, err := rand.Read(aesKey); err != nil {
|
||||
return nil, nil, errors.Wrap(err, "generate aes key failed")
|
||||
}
|
||||
}
|
||||
|
||||
listener, err := net.Listen(startCfg.Network, startCfg.Address)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := Downloader.ContinueOnStartup(); err != nil {
|
||||
Downloader.Logger.Warn().Err(err).Msg("auto-start tasks failed")
|
||||
if continueOnStartup {
|
||||
if err := Downloader.ContinueOnStartup(); err != nil {
|
||||
Downloader.Logger.Warn().Err(err).Msg("auto-start tasks failed")
|
||||
}
|
||||
}
|
||||
|
||||
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.MethodPatch).Path("/api/v1/tasks/{id}").HandlerFunc(PatchTask)
|
||||
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/{id}/status").HandlerFunc(GetTaskStatus)
|
||||
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.Methods(http.MethodPost).Path("/api/v1/webhook/test").HandlerFunc(TestWebhook)
|
||||
r.Path("/api/v1/proxy").HandlerFunc(DoProxy)
|
||||
for _, route := range APIService.RouteSpecs() {
|
||||
route := route
|
||||
r.Methods(route.Method).Path(route.Pattern).HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
||||
return
|
||||
}
|
||||
response := route.Handler(&goapi.Context{
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
Query: r.URL.Query(),
|
||||
Body: body,
|
||||
Headers: r.Header,
|
||||
PathParams: mux.Vars(r),
|
||||
})
|
||||
WriteStatusJson(w, response.StatusCode, response.Body)
|
||||
})
|
||||
}
|
||||
r.Path("/api/web/proxy").HandlerFunc(DoProxy)
|
||||
|
||||
enableApiToken := startCfg.ApiToken != ""
|
||||
enableWebAuth := startCfg.WebEnable && startCfg.WebAuth != nil
|
||||
var webSessions *webSessionStore
|
||||
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 {
|
||||
// Generate a login token, Username:Password:Timestamp
|
||||
timestamp := time.Now().Unix()
|
||||
tokenData := fmt.Sprintf("%s:%s:%d", loginReq.Username, loginReq.Password, timestamp)
|
||||
token, err := aesEncrypt(aesKey, []byte(tokenData))
|
||||
sessionID, expiresAt, err := webSessions.create()
|
||||
if err != nil {
|
||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: webAuthCookieName,
|
||||
Value: token,
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
Expires: time.Now().Add(webAuthTokenTTL),
|
||||
MaxAge: int(webAuthTokenTTL.Seconds()),
|
||||
MaxAge: int(webAuthSessionTTL.Seconds()),
|
||||
Expires: expiresAt,
|
||||
HttpOnly: true,
|
||||
Secure: requestUsesHTTPS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
WriteJson(w, model.NewNilResult())
|
||||
return
|
||||
}
|
||||
@@ -201,19 +260,15 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
||||
|
||||
r.Use(func(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if enableWebAuth && isProtectedWebFilePath(r.URL.Path) {
|
||||
cookie, err := r.Cookie(webAuthCookieName)
|
||||
if err != nil || !validWebAuthToken(cookie.Value, startCfg.WebAuth) {
|
||||
writeUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
protectedPath := strings.HasPrefix(r.URL.Path, "/api/") || isProtectedWebFilePath(r.URL.Path)
|
||||
if r.URL.Path == "/api/web/login" || !protectedPath {
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if enableApiToken {
|
||||
apiTokenHeader := r.Header["X-Api-Token"]
|
||||
// If api token header is set, only check api token ignore basic auth
|
||||
// If an API token header is set, validate it before other authentication methods.
|
||||
if len(apiTokenHeader) > 0 {
|
||||
if apiTokenHeader[0] == startCfg.ApiToken {
|
||||
h.ServeHTTP(w, r)
|
||||
@@ -226,13 +281,12 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
||||
}
|
||||
|
||||
if enableWebAuth {
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api/web/login" {
|
||||
h.ServeHTTP(w, r)
|
||||
cookie, err := r.Cookie(webAuthCookieName)
|
||||
if err != nil {
|
||||
writeUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie(webAuthCookieName)
|
||||
if err != nil || !validWebAuthToken(cookie.Value, startCfg.WebAuth) {
|
||||
if !webSessions.valid(cookie.Value) {
|
||||
writeUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
@@ -259,38 +313,78 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
||||
})
|
||||
})
|
||||
|
||||
srv = &http.Server{Handler: handlers.CORS(
|
||||
server := &http.Server{Handler: handlers.CORS(
|
||||
handlers.AllowedHeaders([]string{"Content-Type", "Authorization", "X-Api-Token", "X-Target-Uri"}),
|
||||
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}),
|
||||
handlers.AllowedOrigins([]string{"*"}),
|
||||
handlers.AllowCredentials(),
|
||||
)(r)}
|
||||
return srv, listener, nil
|
||||
return server, listener, nil
|
||||
}
|
||||
|
||||
func startConfigForAPIServer(config *base.APIServerConfig) *model.StartConfig {
|
||||
enabled := config.Enable
|
||||
return &model.StartConfig{
|
||||
Network: config.Network,
|
||||
Address: config.Address,
|
||||
ApiEnable: &enabled,
|
||||
ApiToken: config.Token,
|
||||
NativeMode: true,
|
||||
Storage: model.StorageMem,
|
||||
WebEnable: false,
|
||||
ProductionMode: true,
|
||||
}
|
||||
}
|
||||
|
||||
func cloneAPIServerConfig(config *base.APIServerConfig) *base.APIServerConfig {
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *config
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func sameAPIServerConfig(left, right *base.APIServerConfig) bool {
|
||||
return left != nil && right != nil &&
|
||||
left.Enable == right.Enable && left.Network == right.Network &&
|
||||
left.Address == right.Address && left.Token == right.Token
|
||||
}
|
||||
|
||||
func initializeCore(startCfg *model.StartConfig) error {
|
||||
if APIService != nil {
|
||||
return nil
|
||||
}
|
||||
downloadCfg := &download.DownloaderConfig{
|
||||
ProductionMode: startCfg.ProductionMode,
|
||||
RefreshInterval: startCfg.RefreshInterval,
|
||||
WhiteDownloadDirs: startCfg.WhiteDownloadDirs,
|
||||
WebViewProvider: startCfg.WebViewProvider,
|
||||
}
|
||||
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 err
|
||||
}
|
||||
service, err := goapi.NewService(Downloader)
|
||||
if err != nil {
|
||||
_ = Downloader.Close()
|
||||
Downloader = nil
|
||||
return err
|
||||
}
|
||||
APIService = service
|
||||
return nil
|
||||
}
|
||||
|
||||
func isProtectedWebFilePath(urlPath string) bool {
|
||||
return urlPath == "/fs" || strings.HasPrefix(urlPath, "/fs/")
|
||||
}
|
||||
|
||||
func validWebAuthToken(token string, auth *model.WebAuth) bool {
|
||||
if token == "" || auth == nil {
|
||||
return false
|
||||
}
|
||||
tokenData, err := aesDecrypt(aesKey, token)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
parts := strings.SplitN(string(tokenData), ":", 3)
|
||||
if len(parts) != 3 || parts[0] != auth.Username || parts[1] != auth.Password {
|
||||
return false
|
||||
}
|
||||
timestamp, err := strconv.ParseInt(parts[2], 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().Unix()-timestamp <= int64(webAuthTokenTTL.Seconds())
|
||||
}
|
||||
|
||||
func requestUsesHTTPS(r *http.Request) bool {
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
@@ -430,47 +524,3 @@ func WriteStatusJson(w http.ResponseWriter, statusCode int, v any) {
|
||||
w.WriteHeader(statusCode)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func aesEncrypt(key, data []byte) (string, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cipherText := gcm.Seal(nonce, nonce, data, nil)
|
||||
return base64.StdEncoding.EncodeToString(cipherText), nil
|
||||
}
|
||||
|
||||
func aesDecrypt(key []byte, encryptedData string) ([]byte, error) {
|
||||
cipherText, err := base64.StdEncoding.DecodeString(encryptedData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(cipherText) < gcm.NonceSize() {
|
||||
return nil, errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, cipherText := cipherText[:gcm.NonceSize()], cipherText[gcm.NonceSize():]
|
||||
return gcm.Open(nil, nonce, cipherText, nil)
|
||||
}
|
||||
+95
-96
@@ -643,7 +643,7 @@ func TestWebFsEnhance(t *testing.T) {
|
||||
|
||||
func TestDoProxy(t *testing.T) {
|
||||
doTest(func() {
|
||||
code, respBody := doHttpRequest0(http.MethodGet, "/api/v1/proxy", map[string]string{
|
||||
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 {
|
||||
@@ -657,7 +657,7 @@ func TestDoProxy(t *testing.T) {
|
||||
})
|
||||
|
||||
doTest(func() {
|
||||
code, _ := doHttpRequest0(http.MethodGet, "/api/v1/proxy", map[string]string{
|
||||
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 {
|
||||
@@ -802,6 +802,28 @@ func TestApiToken(t *testing.T) {
|
||||
|
||||
}
|
||||
|
||||
func TestBuildServerRejectsWebAPITokenWithoutWebAuth(t *testing.T) {
|
||||
cfg := (&model.StartConfig{
|
||||
Storage: model.StorageMem,
|
||||
WebEnable: true,
|
||||
ApiToken: "123456",
|
||||
}).Init()
|
||||
|
||||
server, listener, err := BuildServer(cfg)
|
||||
if err == nil {
|
||||
if listener != nil {
|
||||
listener.Close()
|
||||
}
|
||||
if server != nil {
|
||||
server.Close()
|
||||
}
|
||||
t.Fatal("BuildServer() should reject an API token without web authentication")
|
||||
}
|
||||
if server != nil || listener != nil {
|
||||
t.Fatal("invalid web authentication config must fail before opening a listener")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorization(t *testing.T) {
|
||||
var cfg = &model.StartConfig{}
|
||||
cfg.Init()
|
||||
@@ -827,118 +849,74 @@ func TestAuthorization(t *testing.T) {
|
||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
status, loginHeaders, loginBody := doHttpRequest1(http.MethodPost, "/api/web/login", nil, cfg.WebAuth)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("login status = %d, want %d", status, http.StatusOK)
|
||||
}
|
||||
var loginResult model.Result[any]
|
||||
if err := json.Unmarshal(loginBody, &loginResult); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loginResult.Data != nil {
|
||||
t.Fatalf("login response exposed session data: %#v", loginResult.Data)
|
||||
}
|
||||
loginResponse := &http.Response{Header: http.Header{"Set-Cookie": []string{loginHeaders["Set-Cookie"]}}}
|
||||
cookies := loginResponse.Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("login cookies = %d, want 1", len(cookies))
|
||||
}
|
||||
authCookie := cookies[0]
|
||||
if authCookie.Name != webAuthCookieName || authCookie.Value == "" || authCookie.Path != "/" ||
|
||||
!authCookie.HttpOnly || authCookie.SameSite != http.SameSiteLaxMode || authCookie.MaxAge <= 0 {
|
||||
t.Fatalf("unexpected login cookie: %#v", authCookie)
|
||||
}
|
||||
bearerHeaders := map[string]string{
|
||||
"Authorization": "Bearer " + authCookie.Value,
|
||||
}
|
||||
authCookieHeader := map[string]string{
|
||||
"Cookie": fmt.Sprintf("%s=%s", authCookie.Name, authCookie.Value),
|
||||
}
|
||||
|
||||
for _, filePath := range []string{
|
||||
"/fs/extensions/not_exist/icon.png",
|
||||
"/fs/tasks/not_exist/file.bin",
|
||||
} {
|
||||
status, _ = doHttpRequest0(http.MethodGet, filePath, nil, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("unauthenticated %s status = %d, want %d", filePath, status, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
status, _ = doHttpRequest0(http.MethodGet, filePath, bearerHeaders, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("Bearer-only %s status = %d, want %d", filePath, status, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
status, _ = doHttpRequest0(http.MethodGet, filePath, authCookieHeader, nil)
|
||||
if status != http.StatusNotFound {
|
||||
t.Errorf("cookie-authenticated %s status = %d, want %d", filePath, status, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/fs/future-resource", nil, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("unauthenticated future /fs route status = %d, want %d", status, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/fs/extensions/not_exist/icon.png", map[string]string{
|
||||
"Cookie": webAuthCookieName + "=invalid",
|
||||
}, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("invalid-cookie file status = %d, want %d", status, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
||||
}
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", bearerHeaders, nil)
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
||||
"Authorization": "Bearer invalid-session",
|
||||
}, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("Bearer-authenticated API status = %v, want %v", status, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
||||
"Authorization": "xxx",
|
||||
"Cookie": webAuthCookieName + "=invalid-session",
|
||||
}, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
status, loginHeaders, loginBody := doHttpRequest1(http.MethodPost, "/api/web/login", nil, cfg.WebAuth)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("cookie login got = %v, want %v", status, http.StatusOK)
|
||||
}
|
||||
setCookie := loginHeaders["Set-Cookie"]
|
||||
if !strings.Contains(setCookie, webAuthCookieName+"=") ||
|
||||
!strings.Contains(setCookie, "HttpOnly") ||
|
||||
!strings.Contains(setCookie, "SameSite=Lax") {
|
||||
t.Fatalf("unexpected auth cookie: %q", setCookie)
|
||||
}
|
||||
cookieHeader := strings.SplitN(setCookie, ";", 2)[0]
|
||||
sessionID := strings.TrimPrefix(cookieHeader, webAuthCookieName+"=")
|
||||
if sessionID == "" || strings.Contains(string(loginBody), sessionID) {
|
||||
t.Fatalf("session id must only be returned in the HttpOnly cookie")
|
||||
}
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
||||
"Authorization": "xxx",
|
||||
"Cookie": cookieHeader,
|
||||
}, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("cookie authorization got = %v, want %v", status, http.StatusOK)
|
||||
}
|
||||
|
||||
buildToken := func(username, password string, ts int64) string {
|
||||
token, _ := aesEncrypt(aesKey, []byte(fmt.Sprintf("%s:%s:%d", username, password, ts)))
|
||||
return token
|
||||
}
|
||||
|
||||
fakeToken := buildToken("fake", "fake", time.Now().Unix())
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
||||
"Cookie": fmt.Sprintf("%s=%s", webAuthCookieName, fakeToken),
|
||||
}, nil)
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/fs/extensions/not_exist/icon.png", nil, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
expireToken := buildToken(cfg.WebAuth.Username, cfg.WebAuth.Password, time.Now().Add(-time.Hour*8*24).Unix())
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
||||
"Cookie": fmt.Sprintf("%s=%s", webAuthCookieName, expireToken),
|
||||
}, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
||||
t.Errorf("unauthenticated fs request got = %v, want %v", status, http.StatusUnauthorized)
|
||||
}
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/fs/extensions/not_exist/icon.png", map[string]string{
|
||||
"Cookie": fmt.Sprintf("%s=%s", webAuthCookieName, expireToken),
|
||||
"Cookie": cookieHeader,
|
||||
}, nil)
|
||||
if status != http.StatusNotFound {
|
||||
t.Errorf("authenticated fs request got = %v, want %v", status, http.StatusNotFound)
|
||||
}
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/fs/tasks/not_exist/file.png", nil, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("unauthenticated task fs request got = %v, want %v", status, http.StatusUnauthorized)
|
||||
}
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/fs/tasks/not_exist/file.png", map[string]string{
|
||||
"Cookie": cookieHeader,
|
||||
}, nil)
|
||||
if status != http.StatusNotFound {
|
||||
t.Errorf("authenticated task fs request got = %v, want %v", status, http.StatusNotFound)
|
||||
}
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/fs/future-resource", nil, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("unauthenticated future /fs route got = %v, want %v", status, http.StatusUnauthorized)
|
||||
}
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/fs/future-resource", map[string]string{
|
||||
"Authorization": "Bearer " + sessionID,
|
||||
}, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("expired-cookie file status = %d, want %d", status, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", authCookieHeader, nil)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK)
|
||||
t.Errorf("Bearer-only future /fs route got = %v, want %v", status, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
||||
@@ -947,17 +925,16 @@ func TestAuthorization(t *testing.T) {
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK)
|
||||
}
|
||||
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
||||
"Cookie": fmt.Sprintf("%s=%s", authCookie.Name, authCookie.Value),
|
||||
"Cookie": cookieHeader,
|
||||
"X-Api-Token": cfg.ApiToken,
|
||||
}, nil)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK)
|
||||
t.Errorf("cookie and API token authorization got = %v, want %v", status, http.StatusOK)
|
||||
}
|
||||
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
||||
"Cookie": fmt.Sprintf("%s=%s", authCookie.Name, authCookie.Value),
|
||||
"Cookie": cookieHeader,
|
||||
"X-Api-Token": "",
|
||||
}, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
@@ -965,6 +942,28 @@ func TestAuthorization(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSessionStoreExpiry(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 30, 12, 0, 0, 0, time.UTC)
|
||||
store := newWebSessionStore()
|
||||
store.now = func() time.Time { return now }
|
||||
|
||||
sessionID, expiresAt, err := store.create()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sessionID == "" || expiresAt != now.Add(webAuthSessionTTL) {
|
||||
t.Fatalf("unexpected session: id=%q expiresAt=%v", sessionID, expiresAt)
|
||||
}
|
||||
if !store.valid(sessionID) {
|
||||
t.Fatal("new session should be valid")
|
||||
}
|
||||
|
||||
now = expiresAt
|
||||
if store.valid(sessionID) {
|
||||
t.Fatal("expired session should be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServerPropagatesWebViewProvider(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user