mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-08 09:30:27 +00:00
feat: move native communication to FFI (#1457)
This commit is contained in:
86 files changed
+4558
-886
No files matched your search
+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