feat: move native communication to FFI (#1457)

This commit is contained in:
Levi authored and GitHub committed 2026-09-05 20:18:42 +08:00
1 parent 177c225f16
commit 39cd9034cd
86 files changed
+4558 -886

No files matched your search

+82
View File
@@ -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))
}()
}
+37
View File
@@ -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"}})
}
+94
View File
@@ -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
}
+457
View File
@@ -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}
}
+21
View File
@@ -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
View File
@@ -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 {
+214
View File
@@ -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)
}
+44
View File
@@ -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)
}
+229
View File
@@ -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)
}
}
+41
View File
@@ -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"`
}
+45
View File
@@ -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
View File
@@ -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
View File
@@ -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 {