From 7c1147ce4ece4123bc67feeef96158a822efee96 Mon Sep 17 00:00:00 2001 From: Haowei Li Date: Thu, 5 Mar 2026 03:06:28 -0600 Subject: [PATCH 01/10] feat: support fs get refresh and frontend compatibility --- internal/fs/fs.go | 8 ++++-- internal/fs/get.go | 9 +++++-- internal/op/fs.go | 10 +++++++- server/handles/auth.go | 25 ++++++++++++++---- server/handles/fsread.go | 3 ++- server/handles/setting.go | 5 ++++ server/router.go | 5 ++++ server/static/static.go | 53 ++++++++++++++++++++++++++++++++++++++- 8 files changed, 106 insertions(+), 12 deletions(-) diff --git a/internal/fs/fs.go b/internal/fs/fs.go index 65e5a2c264a..8cbc1872324 100644 --- a/internal/fs/fs.go +++ b/internal/fs/fs.go @@ -30,11 +30,15 @@ func List(ctx context.Context, path string, args *ListArgs) ([]model.Obj, error) } type GetArgs struct { - NoLog bool + Refresh bool + NoLog bool } func Get(ctx context.Context, path string, args *GetArgs) (model.Obj, error) { - res, err := get(ctx, path) + if args == nil { + args = &GetArgs{} + } + res, err := get(ctx, path, args) if err != nil { if !args.NoLog { log.Warnf("failed get %s: %s", path, err) diff --git a/internal/fs/get.go b/internal/fs/get.go index 17c202b7412..b06d73aca7d 100644 --- a/internal/fs/get.go +++ b/internal/fs/get.go @@ -11,7 +11,10 @@ import ( "github.com/pkg/errors" ) -func get(ctx context.Context, path string) (model.Obj, error) { +func get(ctx context.Context, path string, args *GetArgs) (model.Obj, error) { + if args == nil { + args = &GetArgs{} + } path = utils.FixAndCleanPath(path) // maybe a virtual file if path != "/" { @@ -35,5 +38,7 @@ func get(ctx context.Context, path string) (model.Obj, error) { } return nil, errors.WithMessage(err, "failed get storage") } - return op.Get(ctx, storage, actualPath) + return op.GetWithArgs(ctx, storage, actualPath, op.GetArgs{ + Refresh: args.Refresh, + }) } diff --git a/internal/op/fs.go b/internal/op/fs.go index e49c941a62f..85129ee28a0 100644 --- a/internal/op/fs.go +++ b/internal/op/fs.go @@ -21,6 +21,10 @@ import ( var listCache = cache.NewMemCache(cache.WithShards[[]model.Obj](64)) var listG singleflight.Group[[]model.Obj] +type GetArgs struct { + Refresh bool +} + func updateCacheObj(storage driver.Driver, path string, oldObj model.Obj, newObj model.Obj) { key := Key(storage, path) objs, ok := listCache.Get(key) @@ -161,6 +165,10 @@ func List(ctx context.Context, storage driver.Driver, path string, args model.Li // Get object from list of files func Get(ctx context.Context, storage driver.Driver, path string) (model.Obj, error) { + return GetWithArgs(ctx, storage, path, GetArgs{}) +} + +func GetWithArgs(ctx context.Context, storage driver.Driver, path string, args GetArgs) (model.Obj, error) { path = utils.FixAndCleanPath(path) log.Debugf("op.Get %s", path) @@ -214,7 +222,7 @@ func Get(ctx context.Context, storage driver.Driver, path string) (model.Obj, er // not root folder dir, name := stdpath.Split(path) - files, err := List(ctx, storage, dir, model.ListArgs{}) + files, err := List(ctx, storage, dir, model.ListArgs{Refresh: args.Refresh}) if err != nil { return nil, errors.WithMessage(err, "failed get parent list") } diff --git a/server/handles/auth.go b/server/handles/auth.go index e1f512c4dc1..89b745b93a3 100644 --- a/server/handles/auth.go +++ b/server/handles/auth.go @@ -88,8 +88,16 @@ func loginHash(c *gin.Context, req *LoginReq) { } type UserResp struct { - model.User - Otp bool `json:"otp"` + ID uint `json:"id"` + Username string `json:"username"` + Password string `json:"password"` + BasePath string `json:"base_path"` + Role []int `json:"role"` + RoleID int `json:"role_id"` + Disabled bool `json:"disabled"` + Permission int32 `json:"permission"` + SsoID string `json:"sso_id"` + Otp bool `json:"otp"` } // CurrentUser get current user by token @@ -97,10 +105,17 @@ type UserResp struct { func CurrentUser(c *gin.Context) { user := c.MustGet("user").(*model.User) userResp := UserResp{ - User: *user, + ID: user.ID, + Username: user.Username, + Password: "", + BasePath: user.BasePath, + Role: []int{user.Role}, + RoleID: user.Role, + Disabled: user.Disabled, + Permission: user.Permission, + SsoID: user.SsoID, } - userResp.Password = "" - if userResp.OtpSecret != "" { + if user.OtpSecret != "" { userResp.Otp = true } common.SuccessResp(c, userResp) diff --git a/server/handles/fsread.go b/server/handles/fsread.go index 7c580f635e4..e5baf3141ff 100644 --- a/server/handles/fsread.go +++ b/server/handles/fsread.go @@ -228,6 +228,7 @@ func toObjsResp(objs []model.Obj, parent string, encrypt bool) []ObjResp { type FsGetReq struct { Path string `json:"path" form:"path"` Password string `json:"password" form:"password"` + Refresh bool `json:"refresh" form:"refresh"` } type FsGetResp struct { @@ -263,7 +264,7 @@ func FsGet(c *gin.Context) { common.ErrorStrResp(c, "password is incorrect or you have no permission", 403) return } - obj, err := fs.Get(c, reqPath, &fs.GetArgs{}) + obj, err := fs.Get(c, reqPath, &fs.GetArgs{Refresh: req.Refresh}) if err != nil { common.ErrorResp(c, err, 500) return diff --git a/server/handles/setting.go b/server/handles/setting.go index f778b1803c5..c38db5846a3 100644 --- a/server/handles/setting.go +++ b/server/handles/setting.go @@ -103,3 +103,8 @@ func DeleteSetting(c *gin.Context) { func PublicSettings(c *gin.Context) { common.SuccessResp(c, op.GetPublicSettingsMap()) } + +func ArchiveExtensions(c *gin.Context) { + // Keep this endpoint for frontend compatibility. Empty means use frontend defaults. + common.SuccessResp(c, []string{}) +} diff --git a/server/router.go b/server/router.go index fffa840e537..3aa8e69670b 100644 --- a/server/router.go +++ b/server/router.go @@ -74,6 +74,11 @@ func Init(e *gin.Engine) { public := api.Group("/public") public.Any("/settings", handles.PublicSettings) public.Any("/offline_download_tools", handles.OfflineDownloadTools) + public.Any("/archive_extensions", handles.ArchiveExtensions) + // compatibility for older frontend builds that request /public/* directly + g.Any("/public/settings", handles.PublicSettings) + g.Any("/public/offline_download_tools", handles.OfflineDownloadTools) + g.Any("/public/archive_extensions", handles.ArchiveExtensions) _fs(auth.Group("/fs")) _task(auth.Group("/task", middlewares.AuthNotGuest)) diff --git a/server/static/static.go b/server/static/static.go index ec16014c22b..2208b76b2be 100644 --- a/server/static/static.go +++ b/server/static/static.go @@ -9,7 +9,10 @@ import ( "os" "strings" + "github.com/alist-org/alist/v3/cmd/flags" "github.com/alist-org/alist/v3/internal/conf" + "github.com/alist-org/alist/v3/internal/offline_download/tool" + "github.com/alist-org/alist/v3/internal/op" "github.com/alist-org/alist/v3/internal/setting" "github.com/alist-org/alist/v3/pkg/utils" "github.com/alist-org/alist/v3/public" @@ -47,9 +50,17 @@ func initIndex() { } conf.RawIndexHtml = string(index) siteConfig := getSiteConfig() + // Frontend appends "/api" to window.ALIST.api internally. + // So we should inject base path here instead of ".../api", otherwise + // requests become "/api/api/*". + apiPath := strings.TrimSuffix(siteConfig.BasePath, "/") + if apiPath == "" { + apiPath = "/" + } replaceMap := map[string]string{ "cdn: undefined": fmt.Sprintf("cdn: '%s'", siteConfig.Cdn), "base_path: undefined": fmt.Sprintf("base_path: '%s'", siteConfig.BasePath), + "api: undefined": fmt.Sprintf("api: '%s'", apiPath), } for k, v := range replaceMap { conf.RawIndexHtml = strings.Replace(conf.RawIndexHtml, k, v, 1) @@ -89,7 +100,12 @@ func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) { r.Use(func(c *gin.Context) { for i := range folders { if strings.HasPrefix(c.Request.RequestURI, fmt.Sprintf("/%s/", folders[i])) { - c.Header("Cache-Control", "public, max-age=15552000") + // In dev mode, avoid stale cached bundles causing frontend state mismatch. + if flags.Dev { + c.Header("Cache-Control", "max-age=0, no-cache, no-store, must-revalidate") + } else { + c.Header("Cache-Control", "public, max-age=15552000") + } } } }) @@ -102,6 +118,41 @@ func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) { } noRoute(func(c *gin.Context) { + // Compatibility fallback: + // some frontend bundles may request these public APIs under unexpected prefixes + // (for example "/@manage/public/settings"). We handle suffix matches here + // to avoid returning index.html to XHR requests. + reqPath := strings.TrimSuffix(c.Request.URL.Path, "/") + if strings.HasSuffix(reqPath, "/public/settings") { + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "message": "success", + "data": op.GetPublicSettingsMap(), + }) + return + } + if strings.HasSuffix(reqPath, "/public/archive_extensions") { + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "message": "success", + "data": []string{}, + }) + return + } + if strings.HasSuffix(reqPath, "/public/offline_download_tools") { + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "message": "success", + "data": tool.Tools.Names(), + }) + return + } + + // Never cache HTML entry documents. They should always reference + // the latest hashed assets after backend/frontend updates. + c.Header("Cache-Control", "max-age=0, no-cache, no-store, must-revalidate") + c.Header("Pragma", "no-cache") + c.Header("Expires", "0") c.Header("Content-Type", "text/html") c.Status(200) if strings.HasPrefix(c.Request.URL.Path, "/@manage") { From f1e1eaa578e6254741e0cc5230264c9051677f09 Mon Sep 17 00:00:00 2001 From: Haowei Li Date: Thu, 5 Mar 2026 09:15:05 -0600 Subject: [PATCH 02/10] docs: add fork notice and single-file link acceleration note --- README.md | 8 ++++++++ README_cn.md | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/README.md b/README.md index bed2eadf160..f543b1f930a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,14 @@ English | [中文](./README_cn.md)| [日本語](./README_ja.md) | [Contributing](./CONTRIBUTING.md) | [CODE_OF_CONDUCT](./CODE_OF_CONDUCT.md) +## Fork Notice + +This repository is a fork based on AList `v3.40`. + +### Custom updates + +- Accelerated single-file cloud drive direct-link retrieval. + ## Features - [x] Multiple storages diff --git a/README_cn.md b/README_cn.md index 7e45d60f757..2da8059e793 100644 --- a/README_cn.md +++ b/README_cn.md @@ -41,6 +41,14 @@ [English](./README.md) | 中文 | [日本語](./README_ja.md) | [Contributing](./CONTRIBUTING.md) | [CODE_OF_CONDUCT](./CODE_OF_CONDUCT.md) +## Fork 说明 + +本仓库是基于 AList `v3.40` 的 Fork 版本。 + +### 自定义更新 + +- 加速单文件网盘直链获取。 + ## 功能 - [x] 多种存储 From 54b17f6e475fc5c5a93774da78319ca3b3586104 Mon Sep 17 00:00:00 2001 From: Haowei Li Date: Thu, 5 Mar 2026 09:16:35 -0600 Subject: [PATCH 03/10] fix(auth): add permissions in /api/me for root storage rendering --- server/handles/auth.go | 41 +++++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/server/handles/auth.go b/server/handles/auth.go index 89b745b93a3..3f6870fc710 100644 --- a/server/handles/auth.go +++ b/server/handles/auth.go @@ -88,22 +88,37 @@ func loginHash(c *gin.Context, req *LoginReq) { } type UserResp struct { - ID uint `json:"id"` - Username string `json:"username"` - Password string `json:"password"` - BasePath string `json:"base_path"` - Role []int `json:"role"` - RoleID int `json:"role_id"` - Disabled bool `json:"disabled"` + ID uint `json:"id"` + Username string `json:"username"` + Password string `json:"password"` + BasePath string `json:"base_path"` + Role []int `json:"role"` + RoleID int `json:"role_id"` + Disabled bool `json:"disabled"` + Permission int32 `json:"permission"` + Permissions []UserPathPermission `json:"permissions"` + SsoID string `json:"sso_id"` + Otp bool `json:"otp"` +} + +type UserPathPermission struct { + Path string `json:"path"` Permission int32 `json:"permission"` - SsoID string `json:"sso_id"` - Otp bool `json:"otp"` } // CurrentUser get current user by token // if token is empty, return guest user func CurrentUser(c *gin.Context) { user := c.MustGet("user").(*model.User) + permPath := user.BasePath + if permPath == "" || user.IsAdmin() { + permPath = "/" + } + permValue := user.Permission + // Keep frontend permission checks simple for admin. + if user.IsAdmin() { + permValue = (1 << 10) - 1 + } userResp := UserResp{ ID: user.ID, Username: user.Username, @@ -113,7 +128,13 @@ func CurrentUser(c *gin.Context) { RoleID: user.Role, Disabled: user.Disabled, Permission: user.Permission, - SsoID: user.SsoID, + Permissions: []UserPathPermission{ + { + Path: permPath, + Permission: permValue, + }, + }, + SsoID: user.SsoID, } if user.OtpSecret != "" { userResp.Otp = true From 6a89e5c265c690ef6de65efe6389899179c19bf1 Mon Sep 17 00:00:00 2001 From: Haowei Li Date: Fri, 6 Mar 2026 13:48:09 -0600 Subject: [PATCH 04/10] optimize thunder get path and slim fs/get response --- drivers/thunder/driver.go | 198 ++++++++++++++++++++++++- drivers/thunder_browser/driver.go | 233 +++++++++++++++++++++++++++++- server/handles/fsread.go | 4 - 3 files changed, 429 insertions(+), 6 deletions(-) diff --git a/drivers/thunder/driver.go b/drivers/thunder/driver.go index 9ba5dd825f7..b655a2f8a0e 100644 --- a/drivers/thunder/driver.go +++ b/drivers/thunder/driver.go @@ -4,13 +4,17 @@ import ( "context" "fmt" "net/http" + stdpath "path" "strings" + "sync" + "time" "github.com/alist-org/alist/v3/drivers/base" "github.com/alist-org/alist/v3/internal/driver" "github.com/alist-org/alist/v3/internal/errs" "github.com/alist-org/alist/v3/internal/model" "github.com/alist-org/alist/v3/internal/op" + "github.com/alist-org/alist/v3/pkg/singleflight" "github.com/alist-org/alist/v3/pkg/utils" hash_extend "github.com/alist-org/alist/v3/pkg/utils/hash" "github.com/aws/aws-sdk-go/aws" @@ -18,6 +22,7 @@ import ( "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/s3/s3manager" "github.com/go-resty/resty/v2" + log "github.com/sirupsen/logrus" ) type Thunder struct { @@ -234,10 +239,102 @@ type XunLeiCommon struct { *TokenResp // 登录信息 refreshTokenFunc func() error + pathIDCache sync.Map // cleaned full path -> file id + getObjCache sync.Map // cleaned full path -> cachedObj + getObjG singleflight.Group[model.Obj] } +type cachedObj struct { + obj model.Obj + expiresAt int64 +} + +const getObjCacheTTL = 15 * time.Second + func (xc *XunLeiCommon) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { - return xc.getFiles(ctx, dir.GetID()) + files, err := xc.getFiles(ctx, dir.GetID()) + if err != nil { + return nil, err + } + reqPath := utils.FixAndCleanPath(args.ReqPath) + if reqPath != "" { + xc.cachePathID(reqPath, dir.GetID()) + for _, obj := range files { + xc.cachePathID(stdpath.Join(reqPath, obj.GetName()), obj.GetID()) + } + } + return files, nil +} + +func (xc *XunLeiCommon) Get(ctx context.Context, path string) (model.Obj, error) { + cleanPath := utils.FixAndCleanPath(path) + if cleanPath == "/" { + return &model.Object{ + Name: "root", + Size: 0, + Modified: time.Time{}, + IsFolder: true, + }, nil + } + + if cached, ok := xc.loadCachedObj(cleanPath); ok { + log.Debugf("[thunder.get] obj-cache-hit path=%s", cleanPath) + return cached, nil + } + + obj, err, shared := xc.getObjG.Do(cleanPath, func() (model.Obj, error) { + if cached, ok := xc.loadCachedObj(cleanPath); ok { + return cached, nil + } + resolved, resolveErr := xc.getNoSingleflight(ctx, cleanPath) + if resolveErr == nil { + xc.storeCachedObj(cleanPath, resolved) + } + return resolved, resolveErr + }) + if shared { + log.Debugf("[thunder.get] singleflight-shared path=%s", cleanPath) + } + return obj, err +} + +func (xc *XunLeiCommon) getNoSingleflight(ctx context.Context, cleanPath string) (model.Obj, error) { + + // Fast path: if we already know file id for this path, fetch by id directly. + if fileID, ok := xc.loadPathID(cleanPath); ok && fileID != "" { + log.Debugf("[thunder.get] cache-hit path=%s id=%s", cleanPath, fileID) + if obj, err := xc.getByID(ctx, fileID); err == nil { + log.Debugf("[thunder.get] by-id success path=%s", cleanPath) + xc.storeCachedObj(cleanPath, obj) + return obj, nil + } + log.Debugf("[thunder.get] by-id failed, fallback list path=%s", cleanPath) + } + + parentPath, base := stdpath.Split(cleanPath) + parentPath = utils.FixAndCleanPath(parentPath) + parentID, err := xc.resolveDirIDByPath(ctx, parentPath) + if err != nil { + return nil, err + } + + children, err := xc.getFiles(ctx, parentID) + if err != nil { + return nil, err + } + log.Debugf("[thunder.get] fallback-list parent=%s", parentPath) + for _, child := range children { + if child.GetName() != base { + continue + } + xc.cachePathID(cleanPath, child.GetID()) + if child.IsDir() { + xc.cachePathID(stdpath.Join(parentPath, child.GetName()), child.GetID()) + } + xc.storeCachedObj(cleanPath, child) + return child, nil + } + return nil, errs.ObjectNotFound } func (xc *XunLeiCommon) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { @@ -423,6 +520,105 @@ func (xc *XunLeiCommon) getFiles(ctx context.Context, folderId string) ([]model. return files, nil } +func (xc *XunLeiCommon) getByID(ctx context.Context, fileID string) (model.Obj, error) { + var f Files + _, err := xc.Request(FILE_API_URL+"/{fileID}", http.MethodGet, func(r *resty.Request) { + r.SetContext(ctx) + r.SetPathParam("fileID", fileID) + }, &f) + if err != nil { + return nil, err + } + return &f, nil +} + +func (xc *XunLeiCommon) cachePathID(path, fileID string) { + p := utils.FixAndCleanPath(path) + if p == "" || fileID == "" { + return + } + xc.pathIDCache.Store(p, fileID) +} + +func (xc *XunLeiCommon) loadPathID(path string) (string, bool) { + v, ok := xc.pathIDCache.Load(utils.FixAndCleanPath(path)) + if !ok { + return "", false + } + id, ok := v.(string) + return id, ok && id != "" +} + +func (xc *XunLeiCommon) loadCachedObj(path string) (model.Obj, bool) { + v, ok := xc.getObjCache.Load(utils.FixAndCleanPath(path)) + if !ok { + return nil, false + } + entry, ok := v.(cachedObj) + if !ok || entry.obj == nil { + return nil, false + } + if time.Now().UnixNano() > entry.expiresAt { + xc.getObjCache.Delete(utils.FixAndCleanPath(path)) + return nil, false + } + return entry.obj, true +} + +func (xc *XunLeiCommon) storeCachedObj(path string, obj model.Obj) { + if obj == nil { + return + } + xc.getObjCache.Store(utils.FixAndCleanPath(path), cachedObj{ + obj: obj, + expiresAt: time.Now().Add(getObjCacheTTL).UnixNano(), + }) +} + +func (xc *XunLeiCommon) resolveDirIDByPath(ctx context.Context, dirPath string) (string, error) { + cleanPath := utils.FixAndCleanPath(dirPath) + if cleanPath == "/" { + return "", nil + } + if id, ok := xc.loadPathID(cleanPath); ok { + return id, nil + } + + parts := strings.Split(strings.TrimPrefix(cleanPath, "/"), "/") + curPath := "/" + curID := "" + for _, part := range parts { + if part == "" { + continue + } + nextPath := stdpath.Join(curPath, part) + if cachedID, ok := xc.loadPathID(nextPath); ok { + curPath = nextPath + curID = cachedID + continue + } + items, err := xc.getFiles(ctx, curID) + if err != nil { + return "", err + } + found := false + for _, item := range items { + if item.GetName() != part || !item.IsDir() { + continue + } + curID = item.GetID() + curPath = nextPath + xc.cachePathID(curPath, curID) + found = true + break + } + if !found { + return "", errs.ObjectNotFound + } + } + return curID, nil +} + // 设置刷新Token的方法 func (xc *XunLeiCommon) SetRefreshTokenFunc(fn func() error) { xc.refreshTokenFunc = fn diff --git a/drivers/thunder_browser/driver.go b/drivers/thunder_browser/driver.go index 96dd7e8ecce..4dde136e9ab 100644 --- a/drivers/thunder_browser/driver.go +++ b/drivers/thunder_browser/driver.go @@ -6,8 +6,10 @@ import ( "fmt" "github.com/alist-org/alist/v3/drivers/base" "github.com/alist-org/alist/v3/internal/driver" + "github.com/alist-org/alist/v3/internal/errs" "github.com/alist-org/alist/v3/internal/model" "github.com/alist-org/alist/v3/internal/op" + "github.com/alist-org/alist/v3/pkg/singleflight" "github.com/alist-org/alist/v3/pkg/utils" hash_extend "github.com/alist-org/alist/v3/pkg/utils/hash" "github.com/aws/aws-sdk-go/aws" @@ -15,9 +17,13 @@ import ( "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/s3/s3manager" "github.com/go-resty/resty/v2" + log "github.com/sirupsen/logrus" "io" "net/http" + stdpath "path" "strings" + "sync" + "time" ) type ThunderBrowser struct { @@ -306,10 +312,121 @@ type XunLeiBrowserCommon struct { *TokenResp // 登录信息 refreshTokenFunc func() error + pathRefCache sync.Map // cleaned full path -> pathRef + getObjCache sync.Map // cleaned full path -> cachedObj + getObjG singleflight.Group[model.Obj] } +type pathRef struct { + ID string + Space string + IsDir bool +} + +type cachedObj struct { + obj model.Obj + expiresAt int64 +} + +const getObjCacheTTL = 15 * time.Second + func (xc *XunLeiBrowserCommon) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { - return xc.getFiles(ctx, dir, args.ReqPath) + files, err := xc.getFiles(ctx, dir, args.ReqPath) + if err != nil { + return nil, err + } + reqPath := utils.FixAndCleanPath(args.ReqPath) + if reqPath != "" { + parentSpace := ThunderBrowserDriveSpace + if f, ok := dir.(*Files); ok { + parentSpace = f.GetSpace() + } + xc.cachePathRef(reqPath, pathRef{ID: dir.GetID(), Space: parentSpace, IsDir: true}) + for _, obj := range files { + ref := pathRef{ID: obj.GetID(), IsDir: obj.IsDir()} + if f, ok := obj.(*Files); ok { + ref.Space = f.GetSpace() + } + xc.cachePathRef(stdpath.Join(reqPath, obj.GetName()), ref) + } + } + return files, nil +} + +func (xc *XunLeiBrowserCommon) Get(ctx context.Context, path string) (model.Obj, error) { + cleanPath := utils.FixAndCleanPath(path) + if cleanPath == "/" { + return &model.Object{ + Name: "root", + Size: 0, + Modified: time.Time{}, + IsFolder: true, + }, nil + } + + if cached, ok := xc.loadCachedObj(cleanPath); ok { + log.Debugf("[thunder_browser.get] obj-cache-hit path=%s", cleanPath) + return cached, nil + } + + obj, err, shared := xc.getObjG.Do(cleanPath, func() (model.Obj, error) { + if cached, ok := xc.loadCachedObj(cleanPath); ok { + return cached, nil + } + resolved, resolveErr := xc.getNoSingleflight(ctx, cleanPath) + if resolveErr == nil { + xc.storeCachedObj(cleanPath, resolved) + } + return resolved, resolveErr + }) + if shared { + log.Debugf("[thunder_browser.get] singleflight-shared path=%s", cleanPath) + } + return obj, err +} + +func (xc *XunLeiBrowserCommon) getNoSingleflight(ctx context.Context, cleanPath string) (model.Obj, error) { + + if ref, ok := xc.loadPathRef(cleanPath); ok && ref.ID != "" { + log.Debugf("[thunder_browser.get] cache-hit path=%s id=%s space=%s", cleanPath, ref.ID, ref.Space) + if obj, err := xc.getByID(ctx, ref.ID, ref.Space); err == nil { + log.Debugf("[thunder_browser.get] by-id success path=%s", cleanPath) + xc.storeCachedObj(cleanPath, obj) + return obj, nil + } + log.Debugf("[thunder_browser.get] by-id failed, fallback list path=%s", cleanPath) + } + + parentPath, base := stdpath.Split(cleanPath) + parentPath = utils.FixAndCleanPath(parentPath) + parentRef, err := xc.resolveDirRefByPath(ctx, parentPath) + if err != nil { + return nil, err + } + + parentDir := &Files{ + ID: parentRef.ID, + Space: parentRef.Space, + Kind: FOLDER, + } + children, err := xc.getFiles(ctx, parentDir, parentPath) + if err != nil { + return nil, err + } + log.Debugf("[thunder_browser.get] fallback-list parent=%s", parentPath) + for _, child := range children { + if child.GetName() != base { + continue + } + ref := pathRef{ID: child.GetID(), IsDir: child.IsDir()} + if f, ok := child.(*Files); ok { + ref.Space = f.GetSpace() + } + xc.cachePathRef(cleanPath, ref) + xc.storeCachedObj(cleanPath, child) + return child, nil + } + return nil, errs.ObjectNotFound } func (xc *XunLeiBrowserCommon) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { @@ -562,6 +679,120 @@ func (xc *XunLeiBrowserCommon) getFiles(ctx context.Context, dir model.Obj, path return files, nil } +func (xc *XunLeiBrowserCommon) getByID(ctx context.Context, fileID, space string) (model.Obj, error) { + var f Files + params := map[string]string{ + "_magic": "2021", + "space": space, + "thumbnail_size": "SIZE_LARGE", + "with": "url", + } + _, err := xc.Request(FILE_API_URL+"/{fileID}", http.MethodGet, func(r *resty.Request) { + r.SetContext(ctx) + r.SetPathParam("fileID", fileID) + r.SetQueryParams(params) + }, &f) + if err != nil { + return nil, err + } + return &f, nil +} + +func (xc *XunLeiBrowserCommon) cachePathRef(path string, ref pathRef) { + p := utils.FixAndCleanPath(path) + if p == "" { + return + } + xc.pathRefCache.Store(p, ref) +} + +func (xc *XunLeiBrowserCommon) loadPathRef(path string) (pathRef, bool) { + v, ok := xc.pathRefCache.Load(utils.FixAndCleanPath(path)) + if !ok { + return pathRef{}, false + } + ref, ok := v.(pathRef) + return ref, ok +} + +func (xc *XunLeiBrowserCommon) loadCachedObj(path string) (model.Obj, bool) { + v, ok := xc.getObjCache.Load(utils.FixAndCleanPath(path)) + if !ok { + return nil, false + } + entry, ok := v.(cachedObj) + if !ok || entry.obj == nil { + return nil, false + } + if time.Now().UnixNano() > entry.expiresAt { + xc.getObjCache.Delete(utils.FixAndCleanPath(path)) + return nil, false + } + return entry.obj, true +} + +func (xc *XunLeiBrowserCommon) storeCachedObj(path string, obj model.Obj) { + if obj == nil { + return + } + xc.getObjCache.Store(utils.FixAndCleanPath(path), cachedObj{ + obj: obj, + expiresAt: time.Now().Add(getObjCacheTTL).UnixNano(), + }) +} + +func (xc *XunLeiBrowserCommon) resolveDirRefByPath(ctx context.Context, dirPath string) (pathRef, error) { + cleanPath := utils.FixAndCleanPath(dirPath) + if cleanPath == "/" { + return pathRef{ID: "", Space: ThunderBrowserDriveSpace, IsDir: true}, nil + } + if ref, ok := xc.loadPathRef(cleanPath); ok && ref.IsDir { + return ref, nil + } + + parts := strings.Split(strings.TrimPrefix(cleanPath, "/"), "/") + curPath := "/" + curRef := pathRef{ID: "", Space: ThunderBrowserDriveSpace, IsDir: true} + for _, part := range parts { + if part == "" { + continue + } + nextPath := stdpath.Join(curPath, part) + if cached, ok := xc.loadPathRef(nextPath); ok && cached.IsDir { + curPath = nextPath + curRef = cached + continue + } + dirObj := &Files{ID: curRef.ID, Space: curRef.Space, Kind: FOLDER} + items, err := xc.getFiles(ctx, dirObj, curPath) + if err != nil { + return pathRef{}, err + } + found := false + for _, item := range items { + if item.GetName() != part || !item.IsDir() { + continue + } + nextRef := pathRef{ + ID: item.GetID(), + IsDir: true, + } + if f, ok := item.(*Files); ok { + nextRef.Space = f.GetSpace() + } + xc.cachePathRef(nextPath, nextRef) + curPath = nextPath + curRef = nextRef + found = true + break + } + if !found { + return pathRef{}, errs.ObjectNotFound + } + } + return curRef, nil +} + // SetRefreshTokenFunc 设置刷新Token的方法 func (xc *XunLeiBrowserCommon) SetRefreshTokenFunc(fn func() error) { xc.refreshTokenFunc = fn diff --git a/server/handles/fsread.go b/server/handles/fsread.go index e5baf3141ff..4f6cf878bca 100644 --- a/server/handles/fsread.go +++ b/server/handles/fsread.go @@ -318,10 +318,6 @@ func FsGet(c *gin.Context) { } var related []model.Obj parentPath := stdpath.Dir(reqPath) - sameLevelFiles, err := fs.List(c, parentPath, &fs.ListArgs{}) - if err == nil { - related = filterRelated(sameLevelFiles, obj) - } parentMeta, _ := op.GetNearestMeta(parentPath) thumb, _ := model.GetThumb(obj) common.SuccessResp(c, FsGetResp{ From 2dafe81b14d7a5224eb7ec4e839c1651d2daa9f2 Mon Sep 17 00:00:00 2001 From: Haowei Li Date: Mon, 9 Mar 2026 13:36:47 -0500 Subject: [PATCH 05/10] feat: add thunder offline download tools --- .gitignore | 4 +- drivers/115/driver.go | 199 +++++++++++++++++- drivers/thunder/driver.go | 52 +++++ drivers/thunder/types.go | 17 ++ drivers/thunder/util.go | 1 + drivers/thunder_browser/driver.go | 73 +++++++ drivers/thunder_browser/types.go | 17 ++ drivers/thunder_browser/util.go | 1 + drivers/thunderx/driver.go | 67 ++++++ drivers/thunderx/types.go | 17 ++ drivers/thunderx/util.go | 1 + internal/offline_download/all.go | 3 + internal/offline_download/thunder/thunder.go | 118 +++++++++++ internal/offline_download/thunder/util.go | 40 ++++ .../thunder_browser/thunder_browser.go | 130 ++++++++++++ .../offline_download/thunder_browser/util.go | 66 ++++++ .../offline_download/thunderx/thunderx.go | 117 ++++++++++ internal/offline_download/thunderx/utils.go | 40 ++++ internal/offline_download/tool/add.go | 4 + internal/offline_download/tool/download.go | 16 +- server/handles/fsthunder_batch.go | 130 ++++++++++++ server/router.go | 1 + 22 files changed, 1104 insertions(+), 10 deletions(-) create mode 100644 internal/offline_download/thunder/thunder.go create mode 100644 internal/offline_download/thunder/util.go create mode 100644 internal/offline_download/thunder_browser/thunder_browser.go create mode 100644 internal/offline_download/thunder_browser/util.go create mode 100644 internal/offline_download/thunderx/thunderx.go create mode 100644 internal/offline_download/thunderx/utils.go create mode 100644 server/handles/fsthunder_batch.go diff --git a/.gitignore b/.gitignore index 1d71f0d608c..68cc899d075 100644 --- a/.gitignore +++ b/.gitignore @@ -24,11 +24,13 @@ output/ *.json /build /data/ +/data-local/ /tmp/ /log/ /lang/ /daemon/ +/alist /public/dist/* /!public/dist/README.md -.VSCodeCounter \ No newline at end of file +.VSCodeCounter diff --git a/drivers/115/driver.go b/drivers/115/driver.go index 4f584cd7b51..26ef54fb006 100644 --- a/drivers/115/driver.go +++ b/drivers/115/driver.go @@ -2,13 +2,17 @@ package _115 import ( "context" + stdpath "path" "strings" "sync" + "time" driver115 "github.com/SheltonZhu/115driver/pkg/driver" "github.com/alist-org/alist/v3/internal/driver" + ierrs "github.com/alist-org/alist/v3/internal/errs" "github.com/alist-org/alist/v3/internal/model" "github.com/alist-org/alist/v3/pkg/http_range" + "github.com/alist-org/alist/v3/pkg/singleflight" "github.com/alist-org/alist/v3/pkg/utils" "github.com/pkg/errors" "golang.org/x/time/rate" @@ -17,11 +21,21 @@ import ( type Pan115 struct { model.Storage Addition - client *driver115.Pan115Client - limiter *rate.Limiter - appVerOnce sync.Once + client *driver115.Pan115Client + limiter *rate.Limiter + appVerOnce sync.Once + pathIDCache sync.Map // cleaned full path -> file id + getObjCache sync.Map // cleaned full path -> cached115Obj + getObjG singleflight.Group[model.Obj] } +type cached115Obj struct { + obj model.Obj + expiresAt int64 +} + +const getObjCacheTTL115 = 15 * time.Second + func (d *Pan115) Config() driver.Config { return config } @@ -57,9 +71,186 @@ func (d *Pan115) List(ctx context.Context, dir model.Obj, args model.ListArgs) ( if err != nil && !errors.Is(err, driver115.ErrNotExist) { return nil, err } - return utils.SliceConvert(files, func(src FileObj) (model.Obj, error) { + objs, convErr := utils.SliceConvert(files, func(src FileObj) (model.Obj, error) { return &src, nil }) + if convErr != nil { + return nil, convErr + } + reqPath := utils.FixAndCleanPath(args.ReqPath) + if reqPath != "" { + d.cachePathID(reqPath, dir.GetID()) + for _, obj := range objs { + d.cachePathID(stdpath.Join(reqPath, obj.GetName()), obj.GetID()) + } + } + return objs, nil +} + +func (d *Pan115) Get(ctx context.Context, path string) (model.Obj, error) { + cleanPath := utils.FixAndCleanPath(path) + if cleanPath == "/" { + rootID := d.RootFolderID + if rootID == "" { + rootID = "0" + } + return &FileObj{File: driver115.File{ + IsDirectory: true, + FileID: rootID, + ParentID: "", + Name: "root", + CreateTime: time.Time{}, + UpdateTime: time.Time{}, + }}, nil + } + + if cached, ok := d.loadCachedObj(cleanPath); ok { + return cached, nil + } + + obj, err, _ := d.getObjG.Do(cleanPath, func() (model.Obj, error) { + if cached, ok := d.loadCachedObj(cleanPath); ok { + return cached, nil + } + resolved, resolveErr := d.getNoSingleflight(ctx, cleanPath) + if resolveErr == nil { + d.storeCachedObj(cleanPath, resolved) + } + return resolved, resolveErr + }) + return obj, err +} + +func (d *Pan115) getNoSingleflight(ctx context.Context, cleanPath string) (model.Obj, error) { + if fileID, ok := d.loadPathID(cleanPath); ok && fileID != "" { + if err := d.WaitLimit(ctx); err != nil { + return nil, err + } + if f, err := d.getNewFile(fileID); err == nil && f != nil { + d.storeCachedObj(cleanPath, f) + return f, nil + } + } + + parentPath, base := stdpath.Split(cleanPath) + parentPath = utils.FixAndCleanPath(parentPath) + parentID, err := d.resolveDirIDByPath(ctx, parentPath) + if err != nil { + return nil, err + } + + if err := d.WaitLimit(ctx); err != nil { + return nil, err + } + children, err := d.getFiles(parentID) + if err != nil { + return nil, err + } + for i := range children { + child := &children[i] + if child.GetName() != base { + continue + } + d.cachePathID(cleanPath, child.GetID()) + d.storeCachedObj(cleanPath, child) + return child, nil + } + return nil, ierrs.ObjectNotFound +} + +func (d *Pan115) cachePathID(path, fileID string) { + p := utils.FixAndCleanPath(path) + if p == "" || fileID == "" { + return + } + d.pathIDCache.Store(p, fileID) +} + +func (d *Pan115) loadPathID(path string) (string, bool) { + v, ok := d.pathIDCache.Load(utils.FixAndCleanPath(path)) + if !ok { + return "", false + } + id, ok := v.(string) + return id, ok && id != "" +} + +func (d *Pan115) loadCachedObj(path string) (model.Obj, bool) { + v, ok := d.getObjCache.Load(utils.FixAndCleanPath(path)) + if !ok { + return nil, false + } + entry, ok := v.(cached115Obj) + if !ok || entry.obj == nil { + return nil, false + } + if time.Now().UnixNano() > entry.expiresAt { + d.getObjCache.Delete(utils.FixAndCleanPath(path)) + return nil, false + } + return entry.obj, true +} + +func (d *Pan115) storeCachedObj(path string, obj model.Obj) { + if obj == nil { + return + } + d.getObjCache.Store(utils.FixAndCleanPath(path), cached115Obj{ + obj: obj, + expiresAt: time.Now().Add(getObjCacheTTL115).UnixNano(), + }) +} + +func (d *Pan115) resolveDirIDByPath(ctx context.Context, dirPath string) (string, error) { + cleanPath := utils.FixAndCleanPath(dirPath) + rootID := d.RootFolderID + if rootID == "" { + rootID = "0" + } + if cleanPath == "/" { + return rootID, nil + } + if id, ok := d.loadPathID(cleanPath); ok { + return id, nil + } + + parts := strings.Split(strings.TrimPrefix(cleanPath, "/"), "/") + curPath := "/" + curID := rootID + for _, part := range parts { + if part == "" { + continue + } + nextPath := stdpath.Join(curPath, part) + if cachedID, ok := d.loadPathID(nextPath); ok { + curPath = nextPath + curID = cachedID + continue + } + if err := d.WaitLimit(ctx); err != nil { + return "", err + } + items, err := d.getFiles(curID) + if err != nil { + return "", err + } + found := false + for i := range items { + item := &items[i] + if item.GetName() != part || !item.IsDir() { + continue + } + curID = item.GetID() + curPath = nextPath + d.cachePathID(curPath, curID) + found = true + break + } + if !found { + return "", ierrs.ObjectNotFound + } + } + return curID, nil } func (d *Pan115) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { diff --git a/drivers/thunder/driver.go b/drivers/thunder/driver.go index b655a2f8a0e..a356f7463c0 100644 --- a/drivers/thunder/driver.go +++ b/drivers/thunder/driver.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" stdpath "path" + "strconv" "strings" "sync" "time" @@ -485,6 +486,57 @@ func (xc *XunLeiCommon) Put(ctx context.Context, dstDir model.Obj, stream model. return nil } +func (xc *XunLeiCommon) OfflineDownload(ctx context.Context, fileURL string, parentDir model.Obj, fileName string) (*OfflineTask, error) { + var resp OfflineDownloadResp + _, err := xc.Request(FILE_API_URL, http.MethodPost, func(r *resty.Request) { + r.SetContext(ctx) + r.SetBody(&base.Json{ + "kind": FILE, + "name": fileName, + "parent_id": parentDir.GetID(), + "upload_type": UPLOAD_TYPE_URL, + "space": "", + "url": base.Json{ + "url": fileURL, + }, + }) + }, &resp) + if err != nil { + return nil, err + } + return &resp.Task, nil +} + +func (xc *XunLeiCommon) OfflineList(ctx context.Context, nextPageToken string) ([]OfflineTask, error) { + var resp OfflineListResp + _, err := xc.Request(TASK_API_URL, http.MethodGet, func(req *resty.Request) { + req.SetContext(ctx).SetQueryParams(map[string]string{ + "type": "offline", + "limit": "10000", + "page_token": nextPageToken, + "space": "", + }) + }, &resp) + if err != nil { + return nil, fmt.Errorf("failed to get offline list: %w", err) + } + return resp.Tasks, nil +} + +func (xc *XunLeiCommon) DeleteOfflineTasks(ctx context.Context, taskIDs []string, deleteFiles bool) error { + _, err := xc.Request(TASK_API_URL, http.MethodDelete, func(req *resty.Request) { + req.SetContext(ctx).SetQueryParams(map[string]string{ + "task_ids": strings.Join(taskIDs, ","), + "delete_files": strconv.FormatBool(deleteFiles), + "space": "", + }) + }, nil) + if err != nil { + return fmt.Errorf("failed to delete tasks %v: %w", taskIDs, err) + } + return nil +} + func (xc *XunLeiCommon) getFiles(ctx context.Context, folderId string) ([]model.Obj, error) { files := make([]model.Obj, 0) var pageToken string diff --git a/drivers/thunder/types.go b/drivers/thunder/types.go index 7c223673448..604377a77a7 100644 --- a/drivers/thunder/types.go +++ b/drivers/thunder/types.go @@ -204,3 +204,20 @@ type UploadTaskResponse struct { File Files `json:"file"` } + +type OfflineDownloadResp struct { + Task OfflineTask `json:"task"` +} + +type OfflineListResp struct { + Tasks []OfflineTask `json:"tasks"` +} + +type OfflineTask struct { + ID string `json:"id"` + Name string `json:"name"` + Message string `json:"message"` + Phase string `json:"phase"` + Progress int64 `json:"progress"` + FileSize string `json:"file_size"` +} diff --git a/drivers/thunder/util.go b/drivers/thunder/util.go index 3ec8db58ffe..f509e6b2fbc 100644 --- a/drivers/thunder/util.go +++ b/drivers/thunder/util.go @@ -17,6 +17,7 @@ import ( const ( API_URL = "https://api-pan.xunlei.com/drive/v1" FILE_API_URL = API_URL + "/files" + TASK_API_URL = API_URL + "/tasks" XLUSER_API_URL = "https://xluser-ssl.xunlei.com/v1" ) diff --git a/drivers/thunder_browser/driver.go b/drivers/thunder_browser/driver.go index 4dde136e9ab..97709bc6504 100644 --- a/drivers/thunder_browser/driver.go +++ b/drivers/thunder_browser/driver.go @@ -21,6 +21,7 @@ import ( "io" "net/http" stdpath "path" + "strconv" "strings" "sync" "time" @@ -500,6 +501,23 @@ func (xc *XunLeiBrowserCommon) Move(ctx context.Context, srcObj, dstDir model.Ob return err } +func (xc *XunLeiBrowserCommon) BatchMoveByIDs(ctx context.Context, ids []string, srcSpace, dstParentID, dstSpace string) error { + params := map[string]string{ + "_from": srcSpace, + } + body := base.Json{ + "to": base.Json{"parent_id": dstParentID, "space": dstSpace}, + "space": srcSpace, + "ids": ids, + } + _, err := xc.Request(FILE_API_URL+":batchMove", http.MethodPost, func(r *resty.Request) { + r.SetContext(ctx) + r.SetBody(&body) + r.SetQueryParams(params) + }, nil) + return err +} + func (xc *XunLeiBrowserCommon) Rename(ctx context.Context, srcObj model.Obj, newName string) error { params := map[string]string{ @@ -632,6 +650,61 @@ func (xc *XunLeiBrowserCommon) Put(ctx context.Context, dstDir model.Obj, stream return nil } +func (xc *XunLeiBrowserCommon) OfflineDownload(ctx context.Context, fileURL string, parentDir model.Obj, fileName string) (*OfflineTask, error) { + var resp OfflineDownloadResp + body := base.Json{ + "kind": FILE, + "name": fileName, + "parent_id": parentDir.GetID(), + "upload_type": UPLOAD_TYPE_URL, + "url": base.Json{ + "url": fileURL, + }, + } + if files, ok := parentDir.(*Files); ok { + body["space"] = files.GetSpace() + } else { + body["space"] = ThunderDriveSpace + } + _, err := xc.Request(FILE_API_URL, http.MethodPost, func(r *resty.Request) { + r.SetContext(ctx) + r.SetBody(&body) + }, &resp) + if err != nil { + return nil, err + } + return &resp.Task, nil +} + +func (xc *XunLeiBrowserCommon) OfflineList(ctx context.Context, nextPageToken string) ([]OfflineTask, error) { + var resp OfflineListResp + _, err := xc.Request(TASK_API_URL, http.MethodGet, func(req *resty.Request) { + req.SetContext(ctx).SetQueryParams(map[string]string{ + "type": "offline", + "limit": "10000", + "page_token": nextPageToken, + "space": "default/*", + }) + }, &resp) + if err != nil { + return nil, fmt.Errorf("failed to get offline list: %w", err) + } + return resp.Tasks, nil +} + +func (xc *XunLeiBrowserCommon) DeleteOfflineTasks(ctx context.Context, taskIDs []string) error { + _, err := xc.Request(TASK_API_URL, http.MethodDelete, func(req *resty.Request) { + req.SetContext(ctx).SetQueryParams(map[string]string{ + "task_ids": strings.Join(taskIDs, ","), + "_t": strconv.FormatInt(time.Now().UnixMilli(), 10), + }) + }, nil) + if err != nil { + return fmt.Errorf("failed to delete tasks %v: %w", taskIDs, err) + } + return nil +} + func (xc *XunLeiBrowserCommon) getFiles(ctx context.Context, dir model.Obj, path string) ([]model.Obj, error) { files := make([]model.Obj, 0) var pageToken string diff --git a/drivers/thunder_browser/types.go b/drivers/thunder_browser/types.go index b3e21d2bc08..15feba02944 100644 --- a/drivers/thunder_browser/types.go +++ b/drivers/thunder_browser/types.go @@ -234,3 +234,20 @@ type UploadTaskResponse struct { File Files `json:"file"` } + +type OfflineDownloadResp struct { + Task OfflineTask `json:"task"` +} + +type OfflineListResp struct { + Tasks []OfflineTask `json:"tasks"` +} + +type OfflineTask struct { + ID string `json:"id"` + Name string `json:"name"` + Message string `json:"message"` + Phase string `json:"phase"` + Progress int64 `json:"progress"` + FileSize string `json:"file_size"` +} diff --git a/drivers/thunder_browser/util.go b/drivers/thunder_browser/util.go index befd1a904c8..5911d6e24c7 100644 --- a/drivers/thunder_browser/util.go +++ b/drivers/thunder_browser/util.go @@ -19,6 +19,7 @@ import ( const ( API_URL = "https://x-api-pan.xunlei.com/drive/v1" FILE_API_URL = API_URL + "/files" + TASK_API_URL = API_URL + "/tasks" XLUSER_API_URL = "https://xluser-ssl.xunlei.com/v1" ) diff --git a/drivers/thunderx/driver.go b/drivers/thunderx/driver.go index b9ee668c2f9..19d2840e09d 100644 --- a/drivers/thunderx/driver.go +++ b/drivers/thunderx/driver.go @@ -2,6 +2,7 @@ package thunderx import ( "context" + "encoding/json" "fmt" "github.com/alist-org/alist/v3/drivers/base" "github.com/alist-org/alist/v3/internal/driver" @@ -16,6 +17,7 @@ import ( "github.com/aws/aws-sdk-go/service/s3/s3manager" "github.com/go-resty/resty/v2" "net/http" + "strconv" "strings" ) @@ -420,6 +422,71 @@ func (xc *XunLeiXCommon) Put(ctx context.Context, dstDir model.Obj, stream model return nil } +func (xc *XunLeiXCommon) OfflineDownload(ctx context.Context, fileURL string, parentDir model.Obj, fileName string) (*OfflineTask, error) { + var resp OfflineDownloadResp + _, err := xc.Request(FILE_API_URL, http.MethodPost, func(req *resty.Request) { + req.SetContext(ctx).SetBody(base.Json{ + "kind": FILE, + "name": fileName, + "upload_type": UPLOAD_TYPE_URL, + "url": base.Json{ + "url": fileURL, + }, + "params": base.Json{}, + "parent_id": parentDir.GetID(), + }) + }, &resp) + if err != nil { + return nil, err + } + return &resp.Task, nil +} + +func (xc *XunLeiXCommon) OfflineList(ctx context.Context, nextPageToken string, phase []string) ([]OfflineTask, error) { + if len(phase) == 0 { + phase = []string{"PHASE_TYPE_RUNNING", "PHASE_TYPE_ERROR", "PHASE_TYPE_COMPLETE", "PHASE_TYPE_PENDING"} + } + params := map[string]string{ + "type": "offline", + "thumbnail_size": "SIZE_SMALL", + "limit": "10000", + "page_token": nextPageToken, + "with": "reference_resource", + } + filters := base.Json{ + "phase": map[string]string{ + "in": strings.Join(phase, ","), + }, + } + filtersJSON, err := json.Marshal(filters) + if err != nil { + return nil, fmt.Errorf("failed to marshal filters: %w", err) + } + params["filters"] = string(filtersJSON) + + var resp OfflineListResp + _, err = xc.Request(TASKS_API_URL, http.MethodGet, func(req *resty.Request) { + req.SetContext(ctx).SetQueryParams(params) + }, &resp) + if err != nil { + return nil, fmt.Errorf("failed to get offline list: %w", err) + } + return resp.Tasks, nil +} + +func (xc *XunLeiXCommon) DeleteOfflineTasks(ctx context.Context, taskIDs []string, deleteFiles bool) error { + _, err := xc.Request(TASKS_API_URL, http.MethodDelete, func(req *resty.Request) { + req.SetContext(ctx).SetQueryParams(map[string]string{ + "task_ids": strings.Join(taskIDs, ","), + "delete_files": strconv.FormatBool(deleteFiles), + }) + }, nil) + if err != nil { + return fmt.Errorf("failed to delete tasks %v: %w", taskIDs, err) + } + return nil +} + func (xc *XunLeiXCommon) getFiles(ctx context.Context, folderId string) ([]model.Obj, error) { files := make([]model.Obj, 0) var pageToken string diff --git a/drivers/thunderx/types.go b/drivers/thunderx/types.go index 77cfa0f2415..dc5866235d8 100644 --- a/drivers/thunderx/types.go +++ b/drivers/thunderx/types.go @@ -204,3 +204,20 @@ type UploadTaskResponse struct { File Files `json:"file"` } + +type OfflineDownloadResp struct { + Task OfflineTask `json:"task"` +} + +type OfflineListResp struct { + Tasks []OfflineTask `json:"tasks"` +} + +type OfflineTask struct { + ID string `json:"id"` + Name string `json:"name"` + Message string `json:"message"` + Phase string `json:"phase"` + Progress int64 `json:"progress"` + FileSize string `json:"file_size"` +} diff --git a/drivers/thunderx/util.go b/drivers/thunderx/util.go index 661da87e0b0..0d4dcbcd7a1 100644 --- a/drivers/thunderx/util.go +++ b/drivers/thunderx/util.go @@ -19,6 +19,7 @@ import ( const ( API_URL = "https://api-pan.xunleix.com/drive/v1" FILE_API_URL = API_URL + "/files" + TASKS_API_URL = API_URL + "/tasks" XLUSER_API_URL = "https://xluser-ssl.xunleix.com/v1" ) diff --git a/internal/offline_download/all.go b/internal/offline_download/all.go index 6682155dec8..fa8ad446f69 100644 --- a/internal/offline_download/all.go +++ b/internal/offline_download/all.go @@ -6,5 +6,8 @@ import ( _ "github.com/alist-org/alist/v3/internal/offline_download/http" _ "github.com/alist-org/alist/v3/internal/offline_download/pikpak" _ "github.com/alist-org/alist/v3/internal/offline_download/qbit" + _ "github.com/alist-org/alist/v3/internal/offline_download/thunder" + _ "github.com/alist-org/alist/v3/internal/offline_download/thunder_browser" + _ "github.com/alist-org/alist/v3/internal/offline_download/thunderx" _ "github.com/alist-org/alist/v3/internal/offline_download/transmission" ) diff --git a/internal/offline_download/thunder/thunder.go b/internal/offline_download/thunder/thunder.go new file mode 100644 index 00000000000..1bdccaa6d04 --- /dev/null +++ b/internal/offline_download/thunder/thunder.go @@ -0,0 +1,118 @@ +package thunder + +import ( + "context" + "fmt" + + "github.com/alist-org/alist/v3/drivers/thunder" + "github.com/alist-org/alist/v3/internal/errs" + "github.com/alist-org/alist/v3/internal/model" + "github.com/alist-org/alist/v3/internal/offline_download/tool" + "github.com/alist-org/alist/v3/internal/op" +) + +type Thunder struct { + refreshTaskCache bool +} + +func (t *Thunder) Name() string { + return "Thunder" +} + +func (t *Thunder) Items() []model.SettingItem { + return nil +} + +func (t *Thunder) Run(task *tool.DownloadTask) error { + return errs.NotSupport +} + +func (t *Thunder) Init() (string, error) { + t.refreshTaskCache = false + return "ok", nil +} + +func (t *Thunder) IsReady() bool { + return true +} + +func (t *Thunder) AddURL(args *tool.AddUrlArgs) (string, error) { + t.refreshTaskCache = true + storage, actualPath, err := op.GetStorageAndActualPath(args.TempDir) + if err != nil { + return "", err + } + thunderDriver, ok := storage.(*thunder.Thunder) + if !ok { + return "", fmt.Errorf("unsupported storage driver for offline download, only Thunder is supported") + } + + ctx := context.Background() + if err := op.MakeDir(ctx, storage, actualPath); err != nil { + if _, getErr := op.GetUnwrap(ctx, storage, actualPath); getErr != nil { + return "", err + } + } + parentDir, err := op.GetUnwrap(ctx, storage, actualPath) + if err != nil { + return "", err + } + + task, err := thunderDriver.OfflineDownload(ctx, args.Url, parentDir, "") + if err != nil { + return "", fmt.Errorf("failed to add offline download task: %w", err) + } + if task == nil { + return "", fmt.Errorf("failed to add offline download task: task is nil") + } + return task.ID, nil +} + +func (t *Thunder) Remove(task *tool.DownloadTask) error { + storage, _, err := op.GetStorageAndActualPath(task.TempDir) + if err != nil { + return err + } + thunderDriver, ok := storage.(*thunder.Thunder) + if !ok { + return fmt.Errorf("unsupported storage driver for offline download, only Thunder is supported") + } + return thunderDriver.DeleteOfflineTasks(context.Background(), []string{task.GID}, false) +} + +func (t *Thunder) Status(task *tool.DownloadTask) (*tool.Status, error) { + storage, _, err := op.GetStorageAndActualPath(task.TempDir) + if err != nil { + return nil, err + } + thunderDriver, ok := storage.(*thunder.Thunder) + if !ok { + return nil, fmt.Errorf("unsupported storage driver for offline download, only Thunder is supported") + } + tasks, err := t.getTasks(thunderDriver) + if err != nil { + return nil, err + } + s := &tool.Status{ + Progress: 0, + Completed: false, + Status: "the task has been deleted", + } + for _, taskInfo := range tasks { + if taskInfo.ID == task.GID { + s.Progress = float64(taskInfo.Progress) + s.Status = taskInfo.Message + s.Completed = taskInfo.Phase == "PHASE_TYPE_COMPLETE" + if taskInfo.Phase == "PHASE_TYPE_ERROR" { + s.Err = fmt.Errorf(taskInfo.Message) + } + return s, nil + } + } + s.Err = fmt.Errorf("the task has been deleted") + return s, nil +} + +func init() { + tool.Tools.Add(&Thunder{}) +} diff --git a/internal/offline_download/thunder/util.go b/internal/offline_download/thunder/util.go new file mode 100644 index 00000000000..fbd24a97a5b --- /dev/null +++ b/internal/offline_download/thunder/util.go @@ -0,0 +1,40 @@ +package thunder + +import ( + "context" + "time" + + "github.com/Xhofe/go-cache" + "github.com/alist-org/alist/v3/drivers/thunder" + "github.com/alist-org/alist/v3/internal/op" + "github.com/alist-org/alist/v3/pkg/singleflight" +) + +var taskCache = cache.NewMemCache(cache.WithShards[[]thunder.OfflineTask](16)) +var taskG singleflight.Group[[]thunder.OfflineTask] + +func (t *Thunder) getTasks(thunderDriver *thunder.Thunder) ([]thunder.OfflineTask, error) { + key := op.Key(thunderDriver, "/drive/v1/tasks") + if !t.refreshTaskCache { + if tasks, ok := taskCache.Get(key); ok { + return tasks, nil + } + } + t.refreshTaskCache = false + tasks, err, _ := taskG.Do(key, func() ([]thunder.OfflineTask, error) { + tasks, err := thunderDriver.OfflineList(context.Background(), "") + if err != nil { + return nil, err + } + if len(tasks) > 0 { + taskCache.Set(key, tasks, cache.WithEx[[]thunder.OfflineTask](10*time.Second)) + } else { + taskCache.Del(key) + } + return tasks, nil + }) + if err != nil { + return nil, err + } + return tasks, nil +} diff --git a/internal/offline_download/thunder_browser/thunder_browser.go b/internal/offline_download/thunder_browser/thunder_browser.go new file mode 100644 index 00000000000..e7eaffae694 --- /dev/null +++ b/internal/offline_download/thunder_browser/thunder_browser.go @@ -0,0 +1,130 @@ +package thunder_browser + +import ( + "context" + "fmt" + + "github.com/alist-org/alist/v3/drivers/thunder_browser" + "github.com/alist-org/alist/v3/internal/errs" + "github.com/alist-org/alist/v3/internal/model" + "github.com/alist-org/alist/v3/internal/offline_download/tool" + "github.com/alist-org/alist/v3/internal/op" +) + +type ThunderBrowser struct { + refreshTaskCache bool +} + +func (t *ThunderBrowser) Name() string { + return "ThunderBrowser" +} + +func (t *ThunderBrowser) Items() []model.SettingItem { + return nil +} + +func (t *ThunderBrowser) Run(task *tool.DownloadTask) error { + return errs.NotSupport +} + +func (t *ThunderBrowser) Init() (string, error) { + t.refreshTaskCache = false + return "ok", nil +} + +func (t *ThunderBrowser) IsReady() bool { + return true +} + +func (t *ThunderBrowser) AddURL(args *tool.AddUrlArgs) (string, error) { + t.refreshTaskCache = true + storage, actualPath, err := op.GetStorageAndActualPath(args.TempDir) + if err != nil { + return "", err + } + ctx := context.Background() + if err := op.MakeDir(ctx, storage, actualPath); err != nil { + if _, getErr := op.GetUnwrap(ctx, storage, actualPath); getErr != nil { + return "", err + } + } + parentDir, err := op.GetUnwrap(ctx, storage, actualPath) + if err != nil { + return "", err + } + + var task *thunder_browser.OfflineTask + switch driver := storage.(type) { + case *thunder_browser.ThunderBrowser: + task, err = driver.OfflineDownload(ctx, args.Url, parentDir, "") + case *thunder_browser.ThunderBrowserExpert: + task, err = driver.OfflineDownload(ctx, args.Url, parentDir, "") + default: + return "", fmt.Errorf("unsupported storage driver for offline download, only ThunderBrowser is supported") + } + if err != nil { + return "", fmt.Errorf("failed to add offline download task: %w", err) + } + if task == nil { + return "", fmt.Errorf("failed to add offline download task: task is nil") + } + return task.ID, nil +} + +func (t *ThunderBrowser) Remove(task *tool.DownloadTask) error { + storage, _, err := op.GetStorageAndActualPath(task.TempDir) + if err != nil { + return err + } + switch driver := storage.(type) { + case *thunder_browser.ThunderBrowser: + return driver.DeleteOfflineTasks(context.Background(), []string{task.GID}) + case *thunder_browser.ThunderBrowserExpert: + return driver.DeleteOfflineTasks(context.Background(), []string{task.GID}) + default: + return fmt.Errorf("unsupported storage driver for offline download, only ThunderBrowser is supported") + } +} + +func (t *ThunderBrowser) Status(task *tool.DownloadTask) (*tool.Status, error) { + storage, _, err := op.GetStorageAndActualPath(task.TempDir) + if err != nil { + return nil, err + } + + var tasks []thunder_browser.OfflineTask + switch driver := storage.(type) { + case *thunder_browser.ThunderBrowser: + tasks, err = t.getTasks(driver) + case *thunder_browser.ThunderBrowserExpert: + tasks, err = t.getTasksExpert(driver) + default: + return nil, fmt.Errorf("unsupported storage driver for offline download, only ThunderBrowser is supported") + } + if err != nil { + return nil, err + } + + s := &tool.Status{ + Progress: 0, + Completed: false, + Status: "the task has been deleted", + } + for _, taskInfo := range tasks { + if taskInfo.ID == task.GID { + s.Progress = float64(taskInfo.Progress) + s.Status = taskInfo.Message + s.Completed = taskInfo.Phase == "PHASE_TYPE_COMPLETE" + if taskInfo.Phase == "PHASE_TYPE_ERROR" { + s.Err = fmt.Errorf(taskInfo.Message) + } + return s, nil + } + } + s.Err = fmt.Errorf("the task has been deleted") + return s, nil +} + +func init() { + tool.Tools.Add(&ThunderBrowser{}) +} diff --git a/internal/offline_download/thunder_browser/util.go b/internal/offline_download/thunder_browser/util.go new file mode 100644 index 00000000000..8e5bea9781e --- /dev/null +++ b/internal/offline_download/thunder_browser/util.go @@ -0,0 +1,66 @@ +package thunder_browser + +import ( + "context" + "time" + + "github.com/Xhofe/go-cache" + "github.com/alist-org/alist/v3/drivers/thunder_browser" + "github.com/alist-org/alist/v3/internal/op" + "github.com/alist-org/alist/v3/pkg/singleflight" +) + +var taskCache = cache.NewMemCache(cache.WithShards[[]thunder_browser.OfflineTask](16)) +var taskG singleflight.Group[[]thunder_browser.OfflineTask] + +func (t *ThunderBrowser) getTasks(thunderDriver *thunder_browser.ThunderBrowser) ([]thunder_browser.OfflineTask, error) { + key := op.Key(thunderDriver, "/drive/v1/tasks") + if !t.refreshTaskCache { + if tasks, ok := taskCache.Get(key); ok { + return tasks, nil + } + } + t.refreshTaskCache = false + tasks, err, _ := taskG.Do(key, func() ([]thunder_browser.OfflineTask, error) { + tasks, err := thunderDriver.OfflineList(context.Background(), "") + if err != nil { + return nil, err + } + if len(tasks) > 0 { + taskCache.Set(key, tasks, cache.WithEx[[]thunder_browser.OfflineTask](10*time.Second)) + } else { + taskCache.Del(key) + } + return tasks, nil + }) + if err != nil { + return nil, err + } + return tasks, nil +} + +func (t *ThunderBrowser) getTasksExpert(thunderDriver *thunder_browser.ThunderBrowserExpert) ([]thunder_browser.OfflineTask, error) { + key := op.Key(thunderDriver, "/drive/v1/tasks") + if !t.refreshTaskCache { + if tasks, ok := taskCache.Get(key); ok { + return tasks, nil + } + } + t.refreshTaskCache = false + tasks, err, _ := taskG.Do(key, func() ([]thunder_browser.OfflineTask, error) { + tasks, err := thunderDriver.OfflineList(context.Background(), "") + if err != nil { + return nil, err + } + if len(tasks) > 0 { + taskCache.Set(key, tasks, cache.WithEx[[]thunder_browser.OfflineTask](10*time.Second)) + } else { + taskCache.Del(key) + } + return tasks, nil + }) + if err != nil { + return nil, err + } + return tasks, nil +} diff --git a/internal/offline_download/thunderx/thunderx.go b/internal/offline_download/thunderx/thunderx.go new file mode 100644 index 00000000000..61bbcdc07a4 --- /dev/null +++ b/internal/offline_download/thunderx/thunderx.go @@ -0,0 +1,117 @@ +package thunderx + +import ( + "context" + "fmt" + + "github.com/alist-org/alist/v3/drivers/thunderx" + "github.com/alist-org/alist/v3/internal/errs" + "github.com/alist-org/alist/v3/internal/model" + "github.com/alist-org/alist/v3/internal/offline_download/tool" + "github.com/alist-org/alist/v3/internal/op" +) + +type ThunderX struct { + refreshTaskCache bool +} + +func (t *ThunderX) Name() string { + return "ThunderX" +} + +func (t *ThunderX) Items() []model.SettingItem { + return nil +} + +func (t *ThunderX) Init() (string, error) { + t.refreshTaskCache = false + return "ok", nil +} + +func (t *ThunderX) IsReady() bool { + return true +} + +func (t *ThunderX) AddURL(args *tool.AddUrlArgs) (string, error) { + t.refreshTaskCache = true + storage, actualPath, err := op.GetStorageAndActualPath(args.TempDir) + if err != nil { + return "", err + } + driver, ok := storage.(*thunderx.ThunderX) + if !ok { + return "", fmt.Errorf("unsupported storage driver for offline download, only ThunderX is supported") + } + + ctx := context.Background() + if err := op.MakeDir(ctx, storage, actualPath); err != nil { + if _, getErr := op.GetUnwrap(ctx, storage, actualPath); getErr != nil { + return "", err + } + } + parentDir, err := op.GetUnwrap(ctx, storage, actualPath) + if err != nil { + return "", err + } + task, err := driver.OfflineDownload(ctx, args.Url, parentDir, "") + if err != nil { + return "", fmt.Errorf("failed to add offline download task: %w", err) + } + if task == nil { + return "", fmt.Errorf("failed to add offline download task: task is nil") + } + return task.ID, nil +} + +func (t *ThunderX) Remove(task *tool.DownloadTask) error { + storage, _, err := op.GetStorageAndActualPath(task.TempDir) + if err != nil { + return err + } + driver, ok := storage.(*thunderx.ThunderX) + if !ok { + return fmt.Errorf("unsupported storage driver for offline download, only ThunderX is supported") + } + return driver.DeleteOfflineTasks(context.Background(), []string{task.GID}, false) +} + +func (t *ThunderX) Status(task *tool.DownloadTask) (*tool.Status, error) { + storage, _, err := op.GetStorageAndActualPath(task.TempDir) + if err != nil { + return nil, err + } + driver, ok := storage.(*thunderx.ThunderX) + if !ok { + return nil, fmt.Errorf("unsupported storage driver for offline download, only ThunderX is supported") + } + tasks, err := t.getTasks(driver) + if err != nil { + return nil, err + } + s := &tool.Status{ + Progress: 0, + Completed: false, + Status: "the task has been deleted", + } + for _, taskInfo := range tasks { + if taskInfo.ID == task.GID { + s.Progress = float64(taskInfo.Progress) + s.Status = taskInfo.Message + s.Completed = taskInfo.Phase == "PHASE_TYPE_COMPLETE" + if taskInfo.Phase == "PHASE_TYPE_ERROR" { + s.Err = fmt.Errorf(taskInfo.Message) + } + return s, nil + } + } + s.Err = fmt.Errorf("the task has been deleted") + return s, nil +} + +func (t *ThunderX) Run(task *tool.DownloadTask) error { + return errs.NotSupport +} + +func init() { + tool.Tools.Add(&ThunderX{}) +} diff --git a/internal/offline_download/thunderx/utils.go b/internal/offline_download/thunderx/utils.go new file mode 100644 index 00000000000..6ac372fb8d4 --- /dev/null +++ b/internal/offline_download/thunderx/utils.go @@ -0,0 +1,40 @@ +package thunderx + +import ( + "context" + "time" + + "github.com/Xhofe/go-cache" + "github.com/alist-org/alist/v3/drivers/thunderx" + "github.com/alist-org/alist/v3/internal/op" + "github.com/alist-org/alist/v3/pkg/singleflight" +) + +var taskCache = cache.NewMemCache(cache.WithShards[[]thunderx.OfflineTask](16)) +var taskG singleflight.Group[[]thunderx.OfflineTask] + +func (t *ThunderX) getTasks(driver *thunderx.ThunderX) ([]thunderx.OfflineTask, error) { + key := op.Key(driver, "/drive/v1/tasks") + if !t.refreshTaskCache { + if tasks, ok := taskCache.Get(key); ok { + return tasks, nil + } + } + t.refreshTaskCache = false + tasks, err, _ := taskG.Do(key, func() ([]thunderx.OfflineTask, error) { + tasks, err := driver.OfflineList(context.Background(), "", nil) + if err != nil { + return nil, err + } + if len(tasks) > 0 { + taskCache.Set(key, tasks, cache.WithEx[[]thunderx.OfflineTask](10*time.Second)) + } else { + taskCache.Del(key) + } + return tasks, nil + }) + if err != nil { + return nil, err + } + return tasks, nil +} diff --git a/internal/offline_download/tool/add.go b/internal/offline_download/tool/add.go index 1c9da1467b5..93cab123092 100644 --- a/internal/offline_download/tool/add.go +++ b/internal/offline_download/tool/add.go @@ -77,6 +77,10 @@ func AddURL(ctx context.Context, args *AddURLArgs) (task.TaskInfoWithCreator, er tempDir = args.DstDirPath // 防止将下载好的文件删除 deletePolicy = DeleteNever + case "Thunder", "ThunderBrowser", "ThunderX": + tempDir = args.DstDirPath + // 防止将下载好的文件删除 + deletePolicy = DeleteNever } taskCreator, _ := ctx.Value("user").(*model.User) // taskCreator is nil when convert failed diff --git a/internal/offline_download/tool/download.go b/internal/offline_download/tool/download.go index 038baf9690b..cdeae66e92c 100644 --- a/internal/offline_download/tool/download.go +++ b/internal/offline_download/tool/download.go @@ -77,7 +77,7 @@ outer: if err != nil { return err } - if t.tool.Name() == "pikpak" { + if isRemoteCloudOfflineTool(t.tool.Name()) { return nil } if t.tool.Name() == "115 Cloud" { @@ -154,10 +154,7 @@ func (t *DownloadTask) Complete() error { files []File err error ) - if t.tool.Name() == "pikpak" { - return nil - } - if t.tool.Name() == "115 Cloud" { + if isRemoteCloudOfflineTool(t.tool.Name()) || t.tool.Name() == "115 Cloud" { return nil } if getFileser, ok := t.tool.(GetFileser); ok { @@ -185,6 +182,15 @@ func (t *DownloadTask) Complete() error { return nil } +func isRemoteCloudOfflineTool(name string) bool { + switch name { + case "pikpak", "Thunder", "ThunderBrowser", "ThunderX": + return true + default: + return false + } +} + func (t *DownloadTask) GetName() string { return fmt.Sprintf("download %s to (%s)", t.Url, t.DstDirPath) } diff --git a/server/handles/fsthunder_batch.go b/server/handles/fsthunder_batch.go new file mode 100644 index 00000000000..01223bb23fa --- /dev/null +++ b/server/handles/fsthunder_batch.go @@ -0,0 +1,130 @@ +package handles + +import ( + "fmt" + stdpath "path" + + "github.com/alist-org/alist/v3/drivers/thunder_browser" + "github.com/alist-org/alist/v3/internal/errs" + "github.com/alist-org/alist/v3/internal/model" + "github.com/alist-org/alist/v3/internal/op" + "github.com/alist-org/alist/v3/server/common" + "github.com/gin-gonic/gin" +) + +type ThunderBatchMoveReq struct { + SrcDir string `json:"src_dir"` + DstDir string `json:"dst_dir"` + Names []string `json:"names"` +} + +func FsMoveThunderBatch(c *gin.Context) { + var req ThunderBatchMoveReq + if err := c.ShouldBind(&req); err != nil { + common.ErrorResp(c, err, 400) + return + } + if len(req.Names) == 0 { + common.ErrorStrResp(c, "Empty file names", 400) + return + } + + user := c.MustGet("user").(*model.User) + if !user.CanMove() { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } + + srcDir, err := user.JoinPath(req.SrcDir) + if err != nil { + common.ErrorResp(c, err, 403) + return + } + dstDir, err := user.JoinPath(req.DstDir) + if err != nil { + common.ErrorResp(c, err, 403) + return + } + + srcStorage, srcActual, err := op.GetStorageAndActualPath(srcDir) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + dstStorage, dstActual, err := op.GetStorageAndActualPath(dstDir) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + if srcStorage.GetStorage().MountPath != dstStorage.GetStorage().MountPath { + common.ErrorStrResp(c, "cross-storage move is not supported", 400) + return + } + + var xc *thunder_browser.XunLeiBrowserCommon + switch s := srcStorage.(type) { + case *thunder_browser.ThunderBrowser: + xc = s.XunLeiBrowserCommon + case *thunder_browser.ThunderBrowserExpert: + xc = s.XunLeiBrowserCommon + default: + common.ErrorStrResp(c, "storage is not thunder_browser", 400) + return + } + if xc == nil { + common.ErrorStrResp(c, "thunder_browser is not initialized", 500) + return + } + + srcList, err := op.List(c, srcStorage, srcActual, model.ListArgs{}) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + nameToFile := make(map[string]*thunder_browser.Files, len(srcList)) + for _, obj := range srcList { + if f, ok := model.UnwrapObj(obj).(*thunder_browser.Files); ok { + nameToFile[f.GetName()] = f + } + } + + dstDirObj, err := op.GetUnwrap(c, dstStorage, dstActual) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + + dstParentID := "" + dstSpace := "" + if f, ok := dstDirObj.(*thunder_browser.Files); ok { + dstParentID = f.GetID() + dstSpace = f.GetSpace() + } + + idsBySpace := make(map[string][]string) + moved := 0 + for _, name := range req.Names { + cleanName := stdpath.Base(name) + f, ok := nameToFile[cleanName] + if !ok { + common.ErrorStrResp(c, fmt.Sprintf("file not found: %s", cleanName), 404) + return + } + space := f.GetSpace() + idsBySpace[space] = append(idsBySpace[space], f.GetID()) + moved += 1 + } + + for space, ids := range idsBySpace { + if err := xc.BatchMoveByIDs(c, ids, space, dstParentID, dstSpace); err != nil { + common.ErrorResp(c, err, 500) + return + } + } + + op.ClearCache(srcStorage, srcActual) + op.ClearCache(dstStorage, dstActual) + common.SuccessResp(c, gin.H{ + "moved": moved, + }) +} diff --git a/server/router.go b/server/router.go index 3aa8e69670b..8cd0047ed15 100644 --- a/server/router.go +++ b/server/router.go @@ -159,6 +159,7 @@ func _fs(g *gin.RouterGroup) { g.POST("/batch_rename", handles.FsBatchRename) g.POST("/regex_rename", handles.FsRegexRename) g.POST("/move", handles.FsMove) + g.POST("/move_thunder_batch", handles.FsMoveThunderBatch) g.POST("/recursive_move", handles.FsRecursiveMove) g.POST("/copy", handles.FsCopy) g.POST("/remove", handles.FsRemove) From 01e110b09b5b72b3dc2fb1633c3462188ba2b07e Mon Sep 17 00:00:00 2001 From: Haowei Li Date: Mon, 9 Mar 2026 13:58:24 -0500 Subject: [PATCH 06/10] ci: publish docker images to haoweil namespace --- .github/workflows/build_docker.yml | 8 +++++--- .github/workflows/release_docker.yml | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build_docker.yml b/.github/workflows/build_docker.yml index 6384c374bf6..69c3ba31c76 100644 --- a/.github/workflows/build_docker.yml +++ b/.github/workflows/build_docker.yml @@ -22,19 +22,20 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: xhofe/alist + images: haoweil/alist tags: | type=schedule type=ref,event=branch type=ref,event=tag type=ref,event=pr type=raw,value=beta,enable={{is_default_branch}} + type=raw,value=latest,enable={{is_default_branch}} - name: Docker meta with ffmpeg id: meta-ffmpeg uses: docker/metadata-action@v5 with: - images: xhofe/alist + images: haoweil/alist flavor: | suffix=-ffmpeg tags: | @@ -43,6 +44,7 @@ jobs: type=ref,event=tag type=ref,event=pr type=raw,value=beta,enable={{is_default_branch}} + type=raw,value=latest,enable={{is_default_branch}} - uses: actions/setup-go@v5 with: @@ -72,7 +74,7 @@ jobs: if: github.event_name == 'push' uses: docker/login-action@v3 with: - username: xhofe + username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push diff --git a/.github/workflows/release_docker.yml b/.github/workflows/release_docker.yml index a2dd2dd72d8..ed789eb31c4 100644 --- a/.github/workflows/release_docker.yml +++ b/.github/workflows/release_docker.yml @@ -35,7 +35,9 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: xhofe/alist + images: haoweil/alist + flavor: | + latest=true - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -46,7 +48,7 @@ jobs: - name: Login to DockerHub uses: docker/login-action@v3 with: - username: xhofe + username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push @@ -64,7 +66,7 @@ jobs: id: meta-ffmpeg uses: docker/metadata-action@v5 with: - images: xhofe/alist + images: haoweil/alist flavor: | latest=true suffix=-ffmpeg,onlatest=true From 9028398afa2d2b49aecdc0e2d7d0a5d147fcc591 Mon Sep 17 00:00:00 2001 From: Haowei Li Date: Mon, 9 Mar 2026 14:04:52 -0500 Subject: [PATCH 07/10] ci: add manual workflow triggers --- .github/workflows/build_docker.yml | 1 + .github/workflows/release_docker.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/build_docker.yml b/.github/workflows/build_docker.yml index 69c3ba31c76..0bffb6d350c 100644 --- a/.github/workflows/build_docker.yml +++ b/.github/workflows/build_docker.yml @@ -5,6 +5,7 @@ on: branches: [ main ] pull_request: branches: [ main ] + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} diff --git a/.github/workflows/release_docker.yml b/.github/workflows/release_docker.yml index ed789eb31c4..c0b6206db4c 100644 --- a/.github/workflows/release_docker.yml +++ b/.github/workflows/release_docker.yml @@ -4,6 +4,7 @@ on: push: tags: - 'v*' + workflow_dispatch: jobs: release_docker: From e9f2826a6475fdcb36f3635c52f9dbb576d70a3a Mon Sep 17 00:00:00 2001 From: Haowei Li Date: Mon, 9 Mar 2026 14:15:20 -0500 Subject: [PATCH 08/10] ci: build docker images with buildx only --- .github/workflows/build_docker.yml | 26 ++++---------------------- .github/workflows/release_docker.yml | 26 ++++---------------------- 2 files changed, 8 insertions(+), 44 deletions(-) diff --git a/.github/workflows/build_docker.yml b/.github/workflows/build_docker.yml index 0bffb6d350c..a1fd47ead6c 100644 --- a/.github/workflows/build_docker.yml +++ b/.github/workflows/build_docker.yml @@ -47,24 +47,6 @@ jobs: type=raw,value=beta,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}} - - uses: actions/setup-go@v5 - with: - go-version: 'stable' - - - name: Cache Musl - id: cache-musl - uses: actions/cache@v4 - with: - path: build/musl-libs - key: docker-musl-libs-v2 - - - name: Download Musl Library - if: steps.cache-musl.outputs.cache-hit != 'true' - run: bash build.sh prepare docker-multiplatform - - - name: Build go binary - run: bash build.sh dev docker-multiplatform - - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -83,23 +65,23 @@ jobs: uses: docker/build-push-action@v6 with: context: . - file: Dockerfile.ci + file: Dockerfile push: ${{ github.event_name == 'push' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/386,linux/arm/v6,linux/s390x,linux/ppc64le,linux/riscv64 + platforms: linux/amd64,linux/arm64 - name: Build and push with ffmpeg id: docker_build_ffmpeg uses: docker/build-push-action@v6 with: context: . - file: Dockerfile.ci + file: Dockerfile push: ${{ github.event_name == 'push' }} tags: ${{ steps.meta-ffmpeg.outputs.tags }} labels: ${{ steps.meta-ffmpeg.outputs.labels }} build-args: INSTALL_FFMPEG=true - platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/386,linux/arm/v6,linux/s390x,linux/ppc64le,linux/riscv64 + platforms: linux/amd64,linux/arm64 build_docker_with_aria2: needs: build_docker diff --git a/.github/workflows/release_docker.yml b/.github/workflows/release_docker.yml index c0b6206db4c..1c9501138c7 100644 --- a/.github/workflows/release_docker.yml +++ b/.github/workflows/release_docker.yml @@ -14,24 +14,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: 'stable' - - - name: Cache Musl - id: cache-musl - uses: actions/cache@v4 - with: - path: build/musl-libs - key: docker-musl-libs-v2 - - - name: Download Musl Library - if: steps.cache-musl.outputs.cache-hit != 'true' - run: bash build.sh prepare docker-multiplatform - - - name: Build go binary - run: bash build.sh release docker-multiplatform - - name: Docker meta id: meta uses: docker/metadata-action@v5 @@ -57,11 +39,11 @@ jobs: uses: docker/build-push-action@v6 with: context: . - file: Dockerfile.ci + file: Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/386,linux/arm/v6,linux/s390x,linux/ppc64le,linux/riscv64 + platforms: linux/amd64,linux/arm64 - name: Docker meta with ffmpeg id: meta-ffmpeg @@ -77,12 +59,12 @@ jobs: uses: docker/build-push-action@v6 with: context: . - file: Dockerfile.ci + file: Dockerfile push: true tags: ${{ steps.meta-ffmpeg.outputs.tags }} labels: ${{ steps.meta-ffmpeg.outputs.labels }} build-args: INSTALL_FFMPEG=true - platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/386,linux/arm/v6,linux/s390x,linux/ppc64le,linux/riscv64 + platforms: linux/amd64,linux/arm64 release_docker_with_aria2: needs: release_docker From 0e135f7900a0df5a7f9e9067db01f412e5542821 Mon Sep 17 00:00:00 2001 From: Haowei Li Date: Wed, 25 Mar 2026 13:21:43 -0500 Subject: [PATCH 09/10] Fix WebDAV stat refresh for moved paths --- docker-compose.yml | 5 ++++- server/webdav/webdav.go | 18 +++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 05e9f8d790f..8740d562373 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,9 @@ version: '3.3' services: alist: restart: always + build: + context: . + dockerfile: Dockerfile volumes: - '/etc/alist:/opt/alist/data' ports: @@ -13,4 +16,4 @@ services: - UMASK=022 - TZ=UTC container_name: alist - image: 'xhofe/alist:latest' + image: 'alist-local:latest' diff --git a/server/webdav/webdav.go b/server/webdav/webdav.go index b84e65b06b7..0f22b68690e 100644 --- a/server/webdav/webdav.go +++ b/server/webdav/webdav.go @@ -630,13 +630,6 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) (status if err != nil { return 403, err } - fi, err := fs.Get(ctx, reqPath, &fs.GetArgs{}) - if err != nil { - if errs.IsNotFoundError(err) { - return http.StatusNotFound, err - } - return http.StatusMethodNotAllowed, err - } depth := infiniteDepth if hdr := r.Header.Get("Depth"); hdr != "" { depth = parseDepth(hdr) @@ -644,6 +637,17 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) (status return http.StatusBadRequest, errInvalidDepth } } + // Depth: 0 PROPFIND is commonly used by WebDAV clients as an existence/stat check. + // Force a refresh here so recently moved or deleted paths are not answered from + // AList's parent directory cache. + refresh := depth == 0 + fi, err := fs.Get(ctx, reqPath, &fs.GetArgs{Refresh: refresh}) + if err != nil { + if errs.IsNotFoundError(err) { + return http.StatusNotFound, err + } + return http.StatusMethodNotAllowed, err + } pf, status, err := readPropfind(r.Body) if err != nil { return status, err From f97a920c7e3349346870b492a1262448cf7028b3 Mon Sep 17 00:00:00 2001 From: Haowei Li Date: Wed, 25 Mar 2026 13:26:33 -0500 Subject: [PATCH 10/10] Ignore local runtime data in Docker builds --- .dockerignore | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..b2dafac6665 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.github +.air.toml +data +data-local +bin +build +dist +test-results +*.db +*.db-shm +*.db-wal +*.log +coverage.out +node_modules