mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 07:45:30 +00:00
439 lines
12 KiB
Go
439 lines
12 KiB
Go
package rest
|
|
|
|
import (
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/GopeedLab/gopeed/pkg/download"
|
|
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
|
"github.com/GopeedLab/gopeed/pkg/util"
|
|
"github.com/gorilla/handlers"
|
|
"github.com/gorilla/mux"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
var (
|
|
srv *http.Server
|
|
runningPort int
|
|
aesKey []byte
|
|
|
|
Downloader *download.Downloader
|
|
)
|
|
|
|
func Start(startCfg *model.StartConfig) (port int, err error) {
|
|
// avoid repeat start
|
|
if srv != nil {
|
|
return runningPort, nil
|
|
}
|
|
|
|
var listener net.Listener
|
|
srv, listener, err = BuildServer(startCfg)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
panic(err)
|
|
}
|
|
}()
|
|
|
|
if addr, ok := listener.Addr().(*net.TCPAddr); ok {
|
|
port = addr.Port
|
|
runningPort = port
|
|
}
|
|
return
|
|
}
|
|
|
|
func Stop() {
|
|
defer func() {
|
|
srv = nil
|
|
}()
|
|
|
|
if srv != nil {
|
|
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")
|
|
}
|
|
}
|
|
if Downloader != nil {
|
|
if err := Downloader.Close(); err != nil {
|
|
Downloader.Logger.Warn().Err(err).Msg("close downloader failed")
|
|
}
|
|
}
|
|
}
|
|
|
|
func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error) {
|
|
if startCfg == nil {
|
|
startCfg = &model.StartConfig{}
|
|
}
|
|
startCfg.Init()
|
|
|
|
downloadCfg := &download.DownloaderConfig{
|
|
ProductionMode: startCfg.ProductionMode,
|
|
RefreshInterval: startCfg.RefreshInterval,
|
|
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 nil, nil, err
|
|
}
|
|
|
|
if startCfg.Network == "unix" {
|
|
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
|
|
}
|
|
|
|
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").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)
|
|
|
|
enableApiToken := startCfg.ApiToken != ""
|
|
enableWebAuth := startCfg.WebEnable && startCfg.WebAuth != nil
|
|
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))
|
|
if err != nil {
|
|
WriteJson(w, model.NewErrorResult(err.Error()))
|
|
return
|
|
}
|
|
|
|
WriteJson(w, model.NewOkResult(token))
|
|
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) {
|
|
if enableApiToken {
|
|
apiTokenHeader := r.Header["X-Api-Token"]
|
|
// If api token header is set, only check api token ignore basic auth
|
|
if len(apiTokenHeader) > 0 {
|
|
if apiTokenHeader[0] == startCfg.ApiToken {
|
|
h.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
writeUnauthorized(w, r)
|
|
return
|
|
}
|
|
}
|
|
|
|
if enableWebAuth {
|
|
if !strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api/web/login" {
|
|
h.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
token := r.Header.Get("Authorization")
|
|
if token == "" {
|
|
writeUnauthorized(w, r)
|
|
return
|
|
}
|
|
|
|
token = strings.TrimPrefix(token, "Bearer ")
|
|
tokenData, err := aesDecrypt(aesKey, token)
|
|
if err != nil {
|
|
writeUnauthorized(w, r)
|
|
return
|
|
}
|
|
parts := strings.SplitN(string(tokenData), ":", 3)
|
|
username := parts[0]
|
|
password := parts[1]
|
|
timestamp, _ := strconv.Atoi(parts[2])
|
|
|
|
if username != startCfg.WebAuth.Username || password != startCfg.WebAuth.Password {
|
|
writeUnauthorized(w, r)
|
|
return
|
|
}
|
|
|
|
// Check if the token is expired (7 days)
|
|
if time.Now().Unix()-int64(timestamp) > 7*24*3600 {
|
|
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)
|
|
})
|
|
})
|
|
|
|
srv = &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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|