mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 15:49:49 +00:00
568 lines
15 KiB
Go
568 lines
15 KiB
Go
package rest
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"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/mcpserver"
|
|
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
|
"github.com/GopeedLab/gopeed/pkg/util"
|
|
"github.com/gorilla/handlers"
|
|
"github.com/gorilla/mux"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
var (
|
|
runtimeMu sync.RWMutex
|
|
|
|
Downloader *download.Downloader
|
|
APIService *goapi.Service
|
|
)
|
|
|
|
const (
|
|
webAuthCookieName = "gopeed-session"
|
|
webAuthSessionTTL = 7 * 24 * time.Hour
|
|
)
|
|
|
|
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")
|
|
}
|
|
|
|
sessionID := base64.RawURLEncoding.EncodeToString(rawToken)
|
|
expiresAt := s.now().Add(webAuthSessionTTL)
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for existingToken, expiry := range s.sessions {
|
|
if !expiry.After(s.now()) {
|
|
delete(s.sessions, existingToken)
|
|
}
|
|
}
|
|
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.MCPEnable = apiConfig.MCPEnable
|
|
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,
|
|
MCPEnable: startCfg.MCPEnable,
|
|
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() {
|
|
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
|
|
}
|
|
|
|
if err := initializeCore(startCfg); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if startCfg.Network == "unix" {
|
|
util.SafeRemove(startCfg.Address)
|
|
}
|
|
|
|
listener, err := net.Listen(startCfg.Network, startCfg.Address)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if continueOnStartup {
|
|
if err := Downloader.ContinueOnStartup(); err != nil {
|
|
Downloader.Logger.Warn().Err(err).Msg("auto-start tasks failed")
|
|
}
|
|
}
|
|
|
|
var r = mux.NewRouter()
|
|
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)
|
|
if startCfg.MCPEnable {
|
|
r.Path("/mcp").Handler(mcpserver.NewHandler(Downloader))
|
|
} else {
|
|
r.Path("/mcp").Handler(http.NotFoundHandler())
|
|
}
|
|
// Reserve the endpoint namespace so the web UI catch-all cannot serve it.
|
|
r.PathPrefix("/mcp/").Handler(http.NotFoundHandler())
|
|
|
|
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 {
|
|
sessionID, expiresAt, err := webSessions.create()
|
|
if err != nil {
|
|
WriteJson(w, model.NewErrorResult(err.Error()))
|
|
return
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: webAuthCookieName,
|
|
Value: sessionID,
|
|
Path: "/",
|
|
MaxAge: int(webAuthSessionTTL.Seconds()),
|
|
Expires: expiresAt,
|
|
HttpOnly: true,
|
|
Secure: requestUsesHTTPS(r),
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
|
|
WriteJson(w, model.NewNilResult())
|
|
return
|
|
}
|
|
}
|
|
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
|
|
})
|
|
}
|
|
r.PathPrefix("/fs/tasks").Handler(http.FileServer(new(taskFileSystem)))
|
|
r.PathPrefix("/fs/extensions").Handler(http.FileServer(new(extensionFileSystem)))
|
|
r.PathPrefix("/").Handler(gzipMiddleware(http.FileServer(newEmbedCacheFileSystem(http.FS(startCfg.WebFS)))))
|
|
}
|
|
if enableApiToken || enableWebAuth {
|
|
writeUnauthorized := func(w http.ResponseWriter, r *http.Request) {
|
|
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
|
|
}
|
|
|
|
r.Use(func(h http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
protectedPath := strings.HasPrefix(r.URL.Path, "/api/") || isProtectedWebFilePath(r.URL.Path) ||
|
|
(startCfg.MCPEnable && isMCPPath(r.URL.Path))
|
|
if r.URL.Path == "/api/web/login" || !protectedPath {
|
|
h.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
if enableApiToken {
|
|
// If an API token header is set, validate it before other authentication methods.
|
|
if provided, valid := validateAPITokenHeaders(r, startCfg.ApiToken); provided {
|
|
if valid {
|
|
h.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
writeUnauthorized(w, r)
|
|
return
|
|
}
|
|
}
|
|
|
|
if enableWebAuth {
|
|
cookie, err := r.Cookie(webAuthCookieName)
|
|
if err != nil {
|
|
writeUnauthorized(w, r)
|
|
return
|
|
}
|
|
if !webSessions.valid(cookie.Value) {
|
|
writeUnauthorized(w, r)
|
|
return
|
|
}
|
|
|
|
h.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
writeUnauthorized(w, r)
|
|
})
|
|
})
|
|
}
|
|
|
|
// recover panic
|
|
r.Use(func(h http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if v := recover(); v != nil {
|
|
err := errors.WithStack(fmt.Errorf("%v", v))
|
|
Downloader.Logger.Error().Stack().Err(err).Msgf("http server panic: %s %s", r.Method, r.RequestURI)
|
|
WriteJson(w, model.NewErrorResult(err.Error(), model.CodeError))
|
|
}
|
|
}()
|
|
h.ServeHTTP(w, r)
|
|
})
|
|
})
|
|
|
|
server := &http.Server{Handler: handlers.CORS(
|
|
handlers.AllowedHeaders([]string{"Accept", "Content-Type", "Authorization", "X-Api-Token", "X-Target-Uri", "Mcp-Method", "Mcp-Name", "Mcp-Protocol-Version", "Mcp-Session-Id", "Last-Event-ID"}),
|
|
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}),
|
|
handlers.AllowedOrigins([]string{"*"}),
|
|
handlers.ExposedHeaders([]string{"Mcp-Session-Id"}),
|
|
handlers.AllowCredentials(),
|
|
)(r)}
|
|
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,
|
|
MCPEnable: config.MCPEnable,
|
|
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.MCPEnable == right.MCPEnable && left.Network == right.Network &&
|
|
left.Address == right.Address && left.Token == right.Token
|
|
}
|
|
|
|
func validateAPITokenHeaders(r *http.Request, expected string) (provided bool, valid bool) {
|
|
for _, token := range r.Header.Values("X-Api-Token") {
|
|
provided = true
|
|
if constantTimeTokenEqual(strings.TrimSpace(token), expected) {
|
|
valid = true
|
|
}
|
|
}
|
|
for _, authorization := range r.Header.Values("Authorization") {
|
|
parts := strings.Fields(authorization)
|
|
if len(parts) == 0 || !strings.EqualFold(parts[0], "Bearer") {
|
|
continue
|
|
}
|
|
provided = true
|
|
if len(parts) == 2 && constantTimeTokenEqual(parts[1], expected) {
|
|
valid = true
|
|
}
|
|
}
|
|
return provided, valid
|
|
}
|
|
|
|
func constantTimeTokenEqual(actual, expected string) bool {
|
|
return len(actual) == len(expected) && subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1
|
|
}
|
|
|
|
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 isMCPPath(urlPath string) bool {
|
|
return urlPath == "/mcp" || strings.HasPrefix(urlPath, "/mcp/")
|
|
}
|
|
|
|
func requestUsesHTTPS(r *http.Request) bool {
|
|
if r.TLS != nil {
|
|
return true
|
|
}
|
|
forwardedProto := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0])
|
|
return strings.EqualFold(forwardedProto, "https")
|
|
}
|
|
|
|
func resolvePath(urlPath string, prefix string) (identity string, path string, err error) {
|
|
// remove prefix
|
|
clearPath := strings.TrimPrefix(urlPath, prefix)
|
|
// match extension identity, eg: /fs/extensions/identity/xxx
|
|
reg := regexp.MustCompile(`^/([^/]+)/(.*)$`)
|
|
if !reg.MatchString(clearPath) {
|
|
err = os.ErrNotExist
|
|
return
|
|
}
|
|
matched := reg.FindStringSubmatch(clearPath)
|
|
if len(matched) != 3 {
|
|
err = os.ErrNotExist
|
|
return
|
|
}
|
|
return matched[1], matched[2], nil
|
|
}
|
|
|
|
// handle task file resource
|
|
type taskFileSystem struct {
|
|
}
|
|
|
|
func (e *taskFileSystem) Open(name string) (http.File, error) {
|
|
// get extension identity
|
|
identity, path, err := resolvePath(name, "/fs/tasks")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
task := Downloader.GetTask(identity)
|
|
if task == nil {
|
|
return nil, os.ErrNotExist
|
|
}
|
|
return os.Open(filepath.Join(task.Meta.RootDirPath(), path))
|
|
}
|
|
|
|
// handle extension file resource
|
|
type extensionFileSystem struct {
|
|
}
|
|
|
|
func (e *extensionFileSystem) Open(name string) (http.File, error) {
|
|
// get extension identity
|
|
identity, path, err := resolvePath(name, "/fs/extensions")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
extension, err := Downloader.GetExtension(identity)
|
|
if err != nil {
|
|
return nil, os.ErrNotExist
|
|
}
|
|
extensionPath := Downloader.ExtensionPath(extension)
|
|
return os.Open(filepath.Join(extensionPath, path))
|
|
}
|
|
|
|
type embedCacheFileSystem struct {
|
|
fs http.FileSystem
|
|
lastModTime time.Time
|
|
}
|
|
|
|
func newEmbedCacheFileSystem(fs http.FileSystem) *embedCacheFileSystem {
|
|
efs := &embedCacheFileSystem{
|
|
fs: fs,
|
|
lastModTime: time.Now(),
|
|
}
|
|
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return efs
|
|
}
|
|
|
|
fi, err := os.Stat(exe)
|
|
if err != nil {
|
|
return efs
|
|
}
|
|
|
|
efs.lastModTime = fi.ModTime()
|
|
return efs
|
|
}
|
|
|
|
func (e *embedCacheFileSystem) Open(name string) (http.File, error) {
|
|
file, err := e.fs.Open(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &embedFile{
|
|
File: file,
|
|
lastModTime: e.lastModTime,
|
|
}, nil
|
|
}
|
|
|
|
type embedFile struct {
|
|
http.File
|
|
lastModTime time.Time
|
|
}
|
|
|
|
type embedFileInfo struct {
|
|
fs.FileInfo
|
|
lastModTime time.Time
|
|
}
|
|
|
|
func (e *embedFileInfo) ModTime() time.Time {
|
|
return e.lastModTime
|
|
}
|
|
|
|
func (e *embedFile) Stat() (fs.FileInfo, error) {
|
|
fi, err := e.File.Stat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &embedFileInfo{
|
|
FileInfo: fi,
|
|
lastModTime: e.lastModTime,
|
|
}, nil
|
|
}
|
|
|
|
func ReadJson(r *http.Request, w http.ResponseWriter, v any) bool {
|
|
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
|
WriteJson(w, model.NewErrorResult(err.Error()))
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func WriteJson(w http.ResponseWriter, v any) {
|
|
WriteStatusJson(w, http.StatusOK, v)
|
|
}
|
|
|
|
func WriteStatusJson(w http.ResponseWriter, statusCode int, v any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(statusCode)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|