diff --git a/cmd/flags/config.go b/cmd/flags/config.go index f74e2cb4..0409731e 100644 --- a/cmd/flags/config.go +++ b/cmd/flags/config.go @@ -2,6 +2,7 @@ package flags var ( DataDir string + ConfigPath string Debug bool NoPrefix bool Dev bool diff --git a/cmd/root.go b/cmd/root.go index 86e45a35..42121aac 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,7 +27,8 @@ func Execute() { } func init() { - RootCmd.PersistentFlags().StringVar(&flags.DataDir, "data", "data", "data folder") + RootCmd.PersistentFlags().StringVar(&flags.DataDir, "data", "data", "data directory (relative paths are resolved against the current working directory)") + RootCmd.PersistentFlags().StringVar(&flags.ConfigPath, "config", "", "path to config.json (relative to current working directory; defaults to [data directory]/config.json, where [data directory] is set by --data)") RootCmd.PersistentFlags().BoolVar(&flags.Debug, "debug", false, "start with debug mode") RootCmd.PersistentFlags().BoolVar(&flags.NoPrefix, "no-prefix", false, "disable env prefix") RootCmd.PersistentFlags().BoolVar(&flags.Dev, "dev", false, "start with dev mode") diff --git a/cmd/server.go b/cmd/server.go index 3758009f..9f45161d 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -27,6 +27,8 @@ import ( "github.com/spf13/cobra" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" + + "github.com/quic-go/quic-go/http3" ) // ServerCmd represents the server command @@ -63,6 +65,7 @@ the address is defined in config file`, httpHandler = h2c.NewHandler(r, &http2.Server{}) } var httpSrv, httpsSrv, unixSrv *http.Server + var quicSrv *http3.Server if conf.Conf.Scheme.HttpPort != -1 { httpBase := fmt.Sprintf("%s:%d", conf.Conf.Scheme.Address, conf.Conf.Scheme.HttpPort) fmt.Printf("start HTTP server @ %s\n", httpBase) @@ -86,6 +89,24 @@ the address is defined in config file`, utils.Log.Fatalf("failed to start https: %s", err.Error()) } }() + if conf.Conf.Scheme.EnableH3 { + fmt.Printf("start HTTP3 (quic) server @ %s\n", httpsBase) + utils.Log.Infof("start HTTP3 (quic) server @ %s", httpsBase) + r.Use(func(c *gin.Context) { + if c.Request.TLS != nil { + port := conf.Conf.Scheme.HttpsPort + c.Header("Alt-Svc", fmt.Sprintf("h3=\":%d\"; ma=86400", port)) + } + c.Next() + }) + quicSrv = &http3.Server{Addr: httpsBase, Handler: r} + go func() { + err := quicSrv.ListenAndServeTLS(conf.Conf.Scheme.CertFile, conf.Conf.Scheme.KeyFile) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + utils.Log.Fatalf("failed to start http3 (quic): %s", err.Error()) + } + }() + } } if conf.Conf.Scheme.UnixFile != "" { fmt.Printf("start unix server @ %s\n", conf.Conf.Scheme.UnixFile) @@ -203,6 +224,15 @@ the address is defined in config file`, utils.Log.Fatal("HTTPS server shutdown err: ", err) } }() + if conf.Conf.Scheme.EnableH3 { + wg.Add(1) + go func() { + defer wg.Done() + if err := quicSrv.Shutdown(ctx); err != nil { + utils.Log.Fatal("HTTP3 (quic) server shutdown err: ", err) + } + }() + } } if conf.Conf.Scheme.UnixFile != "" { wg.Add(1) diff --git a/drivers/115/driver.go b/drivers/115/driver.go index a9a9d520..bae8bf13 100644 --- a/drivers/115/driver.go +++ b/drivers/115/driver.go @@ -266,4 +266,17 @@ func (d *Pan115) DeleteOfflineTasks(ctx context.Context, hashes []string, delete return d.client.DeleteOfflineTasks(hashes, deleteFiles) } +func (d *Pan115) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + info, err := d.client.GetInfo() + if err != nil { + return nil, err + } + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: uint64(info.SpaceInfo.AllTotal.Size), + FreeSpace: uint64(info.SpaceInfo.AllRemain.Size), + }, + }, nil +} + var _ driver.Driver = (*Pan115)(nil) diff --git a/drivers/115/meta.go b/drivers/115/meta.go index c14dc0b8..7ea573e4 100644 --- a/drivers/115/meta.go +++ b/drivers/115/meta.go @@ -19,10 +19,9 @@ type Addition struct { } var config = driver.Config{ - Name: "115 Cloud", - DefaultRoot: "0", - // OnlyProxy: true, - // NoOverwriteUpload: true, + Name: "115 Cloud", + DefaultRoot: "0", + LinkCacheMode: driver.LinkCacheUA, } func init() { diff --git a/drivers/115_open/driver.go b/drivers/115_open/driver.go index 0f5b44e1..88981ffa 100644 --- a/drivers/115_open/driver.go +++ b/drivers/115_open/driver.go @@ -344,6 +344,27 @@ func (d *Open115) OfflineList(ctx context.Context) (*sdk.OfflineTaskListResp, er return resp, nil } +func (d *Open115) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + userInfo, err := d.client.UserInfo(ctx) + if err != nil { + return nil, err + } + total, err := userInfo.RtSpaceInfo.AllTotal.Size.Int64() + if err != nil { + return nil, err + } + free, err := userInfo.RtSpaceInfo.AllRemain.Size.Int64() + if err != nil { + return nil, err + } + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: uint64(total), + FreeSpace: uint64(free), + }, + }, nil +} + // func (d *Open115) GetArchiveMeta(ctx context.Context, obj model.Obj, args model.ArchiveArgs) (model.ArchiveMeta, error) { // // TODO get archive file meta-info, return errs.NotImplement to use an internal archive tool, optional // return nil, errs.NotImplement diff --git a/drivers/115_open/meta.go b/drivers/115_open/meta.go index c8777132..34722d91 100644 --- a/drivers/115_open/meta.go +++ b/drivers/115_open/meta.go @@ -20,8 +20,9 @@ type Addition struct { } var config = driver.Config{ - Name: "115 Open", - DefaultRoot: "0", + Name: "115 Open", + DefaultRoot: "0", + LinkCacheMode: driver.LinkCacheUA, } func init() { diff --git a/drivers/123/driver.go b/drivers/123/driver.go index 1f58e7d4..9de6c5a2 100644 --- a/drivers/123/driver.go +++ b/drivers/123/driver.go @@ -288,4 +288,15 @@ func (d *Pan123) APIRateLimit(ctx context.Context, api string) error { return limiter.Wait(ctx) } +func (d *Pan123) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + userInfo, err := d.getUserInfo(ctx) + if err != nil { + return nil, err + } + total := userInfo.Data.SpacePermanent + userInfo.Data.SpaceTemp + return &model.StorageDetails{ + DiskUsage: driver.DiskUsageFromUsedAndTotal(userInfo.Data.SpaceUsed, total), + }, nil +} + var _ driver.Driver = (*Pan123)(nil) diff --git a/drivers/123/types.go b/drivers/123/types.go index 17510d94..587eaf71 100644 --- a/drivers/123/types.go +++ b/drivers/123/types.go @@ -28,7 +28,7 @@ func (f File) CreateTime() time.Time { } func (f File) GetHash() utils.HashInfo { - return utils.HashInfo{} + return utils.NewHashInfo(utils.MD5, f.Etag) } func (f File) GetPath() string { @@ -140,3 +140,14 @@ type QrCodeResultResp struct { ScanPlatform int `json:"scanPlatform"` } `json:"data"` } + +type UserInfoResp struct { + Data struct { + Uid int64 `json:"UID"` + Nickname string `json:"Nickname"` + SpaceUsed uint64 `json:"SpaceUsed"` + SpacePermanent uint64 `json:"SpacePermanent"` + SpaceTemp uint64 `json:"SpaceTemp"` + FileCount int `json:"FileCount"` + } `json:"data"` +} diff --git a/drivers/123/upload.go b/drivers/123/upload.go index eb05e094..a4ae988d 100644 --- a/drivers/123/upload.go +++ b/drivers/123/upload.go @@ -124,7 +124,7 @@ func (d *Pan123) newUpload(ctx context.Context, upReq *UploadResp, file model.Fi if cur == chunkCount { curSize = lastChunkSize } - var reader *stream.SectionReader + var reader io.ReadSeeker var rateLimitedRd io.Reader threadG.GoWithLifecycle(errgroup.Lifecycle{ Before: func(ctx context.Context) error { diff --git a/drivers/123/util.go b/drivers/123/util.go index 019027e0..bbaf78b0 100644 --- a/drivers/123/util.go +++ b/drivers/123/util.go @@ -432,3 +432,14 @@ func generateAuthKey() string { uuidStr := strings.ReplaceAll(uuid.New().String(), "-", "") // 去掉 UUID 中的所有 - return fmt.Sprintf("%d-%09d-%s", timestamp, randomInt, uuidStr) // 确保随机整数是9位 } + +func (d *Pan123) getUserInfo(ctx context.Context) (*UserInfoResp, error) { + var resp UserInfoResp + _, err := d.Request(UserInfo, http.MethodGet, func(req *resty.Request) { + req.SetContext(ctx) + }, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} diff --git a/drivers/123_open/driver.go b/drivers/123_open/driver.go index 04785ac1..ac75e51d 100644 --- a/drivers/123_open/driver.go +++ b/drivers/123_open/driver.go @@ -17,6 +17,7 @@ import ( type Open123 struct { model.Storage Addition + UID uint64 } func (d *Open123) Config() driver.Config { @@ -83,7 +84,7 @@ func (d *Open123) Link(ctx context.Context, file model.Obj, args model.LinkArgs) }, nil } - u, err := d.getUserInfo() + uid, err := d.getUID(ctx) if err != nil { return nil, err } @@ -91,7 +92,7 @@ func (d *Open123) Link(ctx context.Context, file model.Obj, args model.LinkArgs) duration := time.Duration(d.DirectLinkValidDuration) * time.Minute newURL, err := d.SignURL(res.Data.URL, d.DirectLinkPrivateKey, - u.Data.UID, duration) + uid, duration) if err != nil { return nil, err } @@ -213,5 +214,30 @@ func (d *Open123) Put(ctx context.Context, dstDir model.Obj, file model.FileStre return nil, fmt.Errorf("upload complete timeout") } -var _ driver.Driver = (*Open123)(nil) -var _ driver.PutResult = (*Open123)(nil) +func (d *Open123) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + userInfo, err := d.getUserInfo(ctx) + if err != nil { + return nil, err + } + total := userInfo.Data.SpacePermanent + userInfo.Data.SpaceTemp + free := total - userInfo.Data.SpaceUsed + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: total, + FreeSpace: free, + }, + }, nil +} + +func (d *Open123) OfflineDownload(ctx context.Context, url string, dir model.Obj, callback string) (int, error) { + return d.createOfflineDownloadTask(ctx, url, dir.GetID(), callback) +} + +func (d *Open123) OfflineDownloadProcess(ctx context.Context, taskID int) (float64, int, error) { + return d.queryOfflineDownloadStatus(ctx, taskID) +} + +var ( + _ driver.Driver = (*Open123)(nil) + _ driver.PutResult = (*Open123)(nil) +) diff --git a/drivers/123_open/meta.go b/drivers/123_open/meta.go index effc7ecf..db4ccc18 100644 --- a/drivers/123_open/meta.go +++ b/drivers/123_open/meta.go @@ -24,7 +24,7 @@ type Addition struct { UploadThread int `json:"UploadThread" type:"number" default:"3" help:"the threads of upload"` // 使用直链 - DirectLink bool `json:"DirectLink" type:"boolean" default:"false" required:"false" help:"use direct link when download file"` + DirectLink bool `json:"DirectLink" type:"bool" default:"false" required:"false" help:"use direct link when download file"` DirectLinkPrivateKey string `json:"DirectLinkPrivateKey" required:"false" help:"private key for direct link, if URL authentication is enabled"` DirectLinkValidDuration int64 `json:"DirectLinkValidDuration" type:"number" default:"30" required:"false" help:"minutes, if URL authentication is enabled"` diff --git a/drivers/123_open/types.go b/drivers/123_open/types.go index eb08529f..dd51cb2a 100644 --- a/drivers/123_open/types.go +++ b/drivers/123_open/types.go @@ -19,6 +19,7 @@ func (a *ApiInfo) Require() { a.token <- struct{}{} } } + func (a *ApiInfo) Release() { if a.qps > 0 { time.AfterFunc(time.Second, func() { @@ -26,13 +27,16 @@ func (a *ApiInfo) Release() { }) } } + func (a *ApiInfo) SetQPS(qps int) { a.qps = qps a.token = make(chan struct{}, qps) } + func (a *ApiInfo) NowLen() int { return len(a.token) } + func InitApiInfo(url string, qps int) *ApiInfo { return &ApiInfo{ url: url, @@ -127,19 +131,19 @@ type RefreshTokenResp struct { type UserInfoResp struct { BaseResp Data struct { - UID uint64 `json:"uid"` - Username string `json:"username"` - DisplayName string `json:"displayName"` - HeadImage string `json:"headImage"` - Passport string `json:"passport"` - Mail string `json:"mail"` - SpaceUsed int64 `json:"spaceUsed"` - SpacePermanent int64 `json:"spacePermanent"` - SpaceTemp int64 `json:"spaceTemp"` - SpaceTempExpr string `json:"spaceTempExpr"` - Vip bool `json:"vip"` - DirectTraffic int64 `json:"directTraffic"` - IsHideUID bool `json:"isHideUID"` + UID uint64 `json:"uid"` + // Username string `json:"username"` + // DisplayName string `json:"displayName"` + // HeadImage string `json:"headImage"` + // Passport string `json:"passport"` + // Mail string `json:"mail"` + SpaceUsed uint64 `json:"spaceUsed"` + SpacePermanent uint64 `json:"spacePermanent"` + SpaceTemp uint64 `json:"spaceTemp"` + // SpaceTempExpr int64 `json:"spaceTempExpr"` + // Vip bool `json:"vip"` + // DirectTraffic int64 `json:"directTraffic"` + // IsHideUID bool `json:"isHideUID"` } `json:"data"` } @@ -185,3 +189,18 @@ type UploadCompleteResp struct { FileID int64 `json:"fileID"` } `json:"data"` } + +type OfflineDownloadResp struct { + BaseResp + Data struct { + TaskID int `json:"taskID"` + } `json:"data"` +} + +type OfflineDownloadProcessResp struct { + BaseResp + Data struct { + Process float64 `json:"process"` + Status int `json:"status"` + } `json:"data"` +} diff --git a/drivers/123_open/upload.go b/drivers/123_open/upload.go index abcde2aa..73395fcf 100644 --- a/drivers/123_open/upload.go +++ b/drivers/123_open/upload.go @@ -67,7 +67,7 @@ func (d *Open123) Upload(ctx context.Context, file model.FileStreamer, createRes partNumber := partIndex + 1 // 分片号从1开始 offset := partIndex * chunkSize size := min(chunkSize, size-offset) - var reader *stream.SectionReader + var reader io.ReadSeeker var rateLimitedRd io.Reader sliceMD5 := "" // 表单 diff --git a/drivers/123_open/util.go b/drivers/123_open/util.go index 52bb5ee8..b09d9eb8 100644 --- a/drivers/123_open/util.go +++ b/drivers/123_open/util.go @@ -1,6 +1,7 @@ package _123_open import ( + "context" "crypto/md5" "encoding/json" "errors" @@ -18,7 +19,7 @@ import ( log "github.com/sirupsen/logrus" ) -var ( //不同情况下获取的AccessTokenQPS限制不同 如下模块化易于拓展 +var ( // 不同情况下获取的AccessTokenQPS限制不同 如下模块化易于拓展 Api = "https://open-api.123pan.com" AccessToken = InitApiInfo(Api+"/api/v1/access_token", 1) @@ -33,6 +34,9 @@ var ( //不同情况下获取的AccessTokenQPS限制不同 如下模块化易于 Trash = InitApiInfo(Api+"/api/v1/file/trash", 2) UploadCreate = InitApiInfo(Api+"/upload/v2/file/create", 2) UploadComplete = InitApiInfo(Api+"/upload/v2/file/upload_complete", 0) + + OfflineDownload = InitApiInfo(Api+"/api/v1/offline/download", 1) + OfflineDownloadProcess = InitApiInfo(Api+"/api/v1/offline/download/process", 5) ) func (d *Open123) Request(apiInfo *ApiInfo, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) { @@ -82,12 +86,27 @@ func (d *Open123) Request(apiInfo *ApiInfo, method string, callback base.ReqCall return nil, errors.New(baseResp.Message) } } - } func (d *Open123) flushAccessToken() error { - if d.Addition.ClientID != "" { - if d.Addition.ClientSecret != "" { + if d.ClientID != "" { + if d.RefreshToken != "" { + var resp RefreshTokenResp + _, err := d.Request(RefreshToken, http.MethodPost, func(req *resty.Request) { + req.SetQueryParam("client_id", d.ClientID) + if d.ClientSecret != "" { + req.SetQueryParam("client_secret", d.ClientSecret) + } + req.SetQueryParam("grant_type", "refresh_token") + req.SetQueryParam("refresh_token", d.RefreshToken) + }, &resp) + if err != nil { + return err + } + d.AccessToken = resp.AccessToken + d.RefreshToken = resp.RefreshToken + op.MustSaveDriverStorage(d) + } else if d.ClientSecret != "" { var resp AccessTokenResp _, err := d.Request(AccessToken, http.MethodPost, func(req *resty.Request) { req.SetBody(base.Json{ @@ -100,19 +119,6 @@ func (d *Open123) flushAccessToken() error { } d.AccessToken = resp.Data.AccessToken op.MustSaveDriverStorage(d) - } else if d.Addition.RefreshToken != "" { - var resp RefreshTokenResp - _, err := d.Request(RefreshToken, http.MethodPost, func(req *resty.Request) { - req.SetQueryParam("client_id", d.ClientID) - req.SetQueryParam("grant_type", "refresh_token") - req.SetQueryParam("refresh_token", d.Addition.RefreshToken) - }, &resp) - if err != nil { - return err - } - d.AccessToken = resp.AccessToken - d.RefreshToken = resp.RefreshToken - op.MustSaveDriverStorage(d) } } return nil @@ -145,16 +151,30 @@ func (d *Open123) SignURL(originURL, privateKey string, uid uint64, validDuratio return objURL.String(), nil } -func (d *Open123) getUserInfo() (*UserInfoResp, error) { +func (d *Open123) getUserInfo(ctx context.Context) (*UserInfoResp, error) { var resp UserInfoResp - if _, err := d.Request(UserInfo, http.MethodGet, nil, &resp); err != nil { + if _, err := d.Request(UserInfo, http.MethodGet, func(req *resty.Request) { + req.SetContext(ctx) + }, &resp); err != nil { return nil, err } return &resp, nil } +func (d *Open123) getUID(ctx context.Context) (uint64, error) { + if d.UID != 0 { + return d.UID, nil + } + resp, err := d.getUserInfo(ctx) + if err != nil { + return 0, err + } + d.UID = resp.Data.UID + return resp.Data.UID, nil +} + func (d *Open123) getFiles(parentFileId int64, limit int, lastFileId int64) (*FileListResp, error) { var resp FileListResp @@ -169,7 +189,6 @@ func (d *Open123) getFiles(parentFileId int64, limit int, lastFileId int64) (*Fi "searchData": "", }) }, &resp) - if err != nil { return nil, err } @@ -197,7 +216,7 @@ func (d *Open123) getDirectLink(fileId int64) (*DirectLinkResp, error) { _, err := d.Request(DirectLink, http.MethodGet, func(req *resty.Request) { req.SetQueryParams(map[string]string{ - "fileId": strconv.FormatInt(fileId, 10), + "fileID": strconv.FormatInt(fileId, 10), }) }, &resp) if err != nil { @@ -261,3 +280,34 @@ func (d *Open123) trash(fileId int64) error { return nil } + +func (d *Open123) createOfflineDownloadTask(ctx context.Context, url string, dirID, callback string) (taskID int, err error) { + body := base.Json{ + "url": url, + "dirID": dirID, + } + if len(callback) > 0 { + body["callBackUrl"] = callback + } + var resp OfflineDownloadResp + _, err = d.Request(OfflineDownload, http.MethodPost, func(req *resty.Request) { + req.SetBody(body) + }, &resp) + if err != nil { + return 0, err + } + return resp.Data.TaskID, nil +} + +func (d *Open123) queryOfflineDownloadStatus(ctx context.Context, taskID int) (process float64, status int, err error) { + var resp OfflineDownloadProcessResp + _, err = d.Request(OfflineDownloadProcess, http.MethodGet, func(req *resty.Request) { + req.SetQueryParams(map[string]string{ + "taskID": strconv.Itoa(taskID), + }) + }, &resp) + if err != nil { + return .0, 0, err + } + return resp.Data.Process, resp.Data.Status, nil +} diff --git a/drivers/123_share/types.go b/drivers/123_share/types.go index 6062e846..3919c5fa 100644 --- a/drivers/123_share/types.go +++ b/drivers/123_share/types.go @@ -24,7 +24,7 @@ type File struct { } func (f File) GetHash() utils.HashInfo { - return utils.HashInfo{} + return utils.NewHashInfo(utils.MD5, f.Etag) } func (f File) GetPath() string { diff --git a/drivers/139/driver.go b/drivers/139/driver.go index 4fbe1c0a..c95d7fc0 100644 --- a/drivers/139/driver.go +++ b/drivers/139/driver.go @@ -54,7 +54,8 @@ func (d *Yun139) Init(ctx context.Context) error { "userInfo": base.Json{ "userType": 1, "accountType": 1, - "accountName": d.Account}, + "accountName": d.Account, + }, "modAddrType": 1, }, &resp) if err != nil { @@ -540,16 +541,15 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr if size > partSize { part = (size + partSize - 1) / partSize } + + // 生成所有 partInfos partInfos := make([]PartInfo, 0, part) for i := int64(0); i < part; i++ { if utils.IsCanceled(ctx) { return ctx.Err() } start := i * partSize - byteSize := size - start - if byteSize > partSize { - byteSize = partSize - } + byteSize := min(size-start, partSize) partNumber := i + 1 partInfo := PartInfo{ PartNumber: partNumber, @@ -597,17 +597,20 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr // resp.Data.RapidUpload: true 支持快传,但此处直接检测是否返回分片的上传地址 // 快传的情况下同样需要手动处理冲突 if resp.Data.PartInfos != nil { - // 读取前100个分片的上传地址 - uploadPartInfos := resp.Data.PartInfos - - // 获取后续分片的上传地址 - for i := 101; i < len(partInfos); i += 100 { - end := i + 100 - if end > len(partInfos) { - end = len(partInfos) - } - batchPartInfos := partInfos[i:end] + // Progress + p := driver.NewProgress(size, up) + rateLimited := driver.NewLimitedUploadStream(ctx, stream) + + // 先上传前100个分片 + err = d.uploadPersonalParts(ctx, partInfos, resp.Data.PartInfos, rateLimited, p) + if err != nil { + return err + } + // 如果还有剩余分片,分批获取上传地址并上传 + for i := 100; i < len(partInfos); i += 100 { + end := min(i+100, len(partInfos)) + batchPartInfos := partInfos[i:end] moredata := base.Json{ "fileId": resp.Data.FileId, "uploadId": resp.Data.UploadId, @@ -623,44 +626,13 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr if err != nil { return err } - uploadPartInfos = append(uploadPartInfos, moreresp.Data.PartInfos...) - } - - // Progress - p := driver.NewProgress(size, up) - - rateLimited := driver.NewLimitedUploadStream(ctx, stream) - // 上传所有分片 - for _, uploadPartInfo := range uploadPartInfos { - index := uploadPartInfo.PartNumber - 1 - partSize := partInfos[index].PartSize - log.Debugf("[139] uploading part %+v/%+v", index, len(uploadPartInfos)) - limitReader := io.LimitReader(rateLimited, partSize) - - // Update Progress - r := io.TeeReader(limitReader, p) - - req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadPartInfo.UploadUrl, r) + err = d.uploadPersonalParts(ctx, partInfos, moreresp.Data.PartInfos, rateLimited, p) if err != nil { return err } - req.Header.Set("Content-Type", "application/octet-stream") - req.Header.Set("Content-Length", fmt.Sprint(partSize)) - req.Header.Set("Origin", "https://yun.139.com") - req.Header.Set("Referer", "https://yun.139.com/") - req.ContentLength = partSize - - res, err := base.HttpClient.Do(req) - if err != nil { - return err - } - _ = res.Body.Close() - log.Debugf("[139] uploaded: %+v", res) - if res.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected status code: %d", res.StatusCode) - } } + // 全部分片上传完毕后,complete data = base.Json{ "contentHash": fullHash, "contentHashAlgorithm": "SHA256", @@ -767,7 +739,7 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr "manualRename": 2, "operation": 0, "path": path.Join(dstDir.GetPath(), dstDir.GetID()), - "seqNo": random.String(32), //序列号不能为空 + "seqNo": random.String(32), // 序列号不能为空 "totalSize": reportSize, "uploadContentList": []base.Json{{ "contentName": stream.GetName(), @@ -869,4 +841,48 @@ func (d *Yun139) Other(ctx context.Context, args model.OtherArgs) (interface{}, } } +func (d *Yun139) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + if d.UserDomainID == "" { + return nil, errs.NotImplement + } + var total, free uint64 + if d.isFamily() { + diskInfo, err := d.getFamilyDiskInfo(ctx) + if err != nil { + return nil, err + } + totalMb, err := strconv.ParseUint(diskInfo.Data.DiskSize, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed convert disk size into integer: %+v", err) + } + usedMb, err := strconv.ParseUint(diskInfo.Data.UsedSize, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed convert used size into integer: %+v", err) + } + total = totalMb * 1024 * 1024 + free = total - (usedMb * 1024 * 1024) + } else { + diskInfo, err := d.getPersonalDiskInfo(ctx) + if err != nil { + return nil, err + } + totalMb, err := strconv.ParseUint(diskInfo.Data.DiskSize, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed convert disk size into integer: %+v", err) + } + freeMb, err := strconv.ParseUint(diskInfo.Data.FreeDiskSize, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed convert free size into integer: %+v", err) + } + total = totalMb * 1024 * 1024 + free = freeMb * 1024 * 1024 + } + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: total, + FreeSpace: free, + }, + }, nil +} + var _ driver.Driver = (*Yun139)(nil) diff --git a/drivers/139/meta.go b/drivers/139/meta.go index d0c713c7..6082d798 100644 --- a/drivers/139/meta.go +++ b/drivers/139/meta.go @@ -11,6 +11,7 @@ type Addition struct { driver.RootID Type string `json:"type" type:"select" options:"personal_new,family,group,personal" default:"personal_new"` CloudID string `json:"cloud_id"` + UserDomainID string `json:"user_domain_id" help:"ud_id in Cookie, fill in to show disk usage"` CustomUploadPartSize int64 `json:"custom_upload_part_size" type:"number" default:"0" help:"0 for auto"` ReportRealSize bool `json:"report_real_size" type:"bool" default:"true" help:"Enable to report the real file size during upload"` UseLargeThumbnail bool `json:"use_large_thumbnail" type:"bool" default:"false" help:"Enable to use large thumbnail for images"` diff --git a/drivers/139/types.go b/drivers/139/types.go index d5f025a1..118655de 100644 --- a/drivers/139/types.go +++ b/drivers/139/types.go @@ -312,3 +312,20 @@ type RefreshTokenResp struct { AccessToken string `xml:"accessToken"` Desc string `xml:"desc"` } + +type PersonalDiskInfoResp struct { + BaseResp + Data struct { + FreeDiskSize string `json:"freeDiskSize"` + DiskSize string `json:"diskSize"` + IsInfinitePicStorage *bool `json:"isInfinitePicStorage"` + } `json:"data"` +} + +type FamilyDiskInfoResp struct { + BaseResp + Data struct { + UsedSize string `json:"usedSize"` + DiskSize string `json:"diskSize"` + } `json:"data"` +} diff --git a/drivers/139/util.go b/drivers/139/util.go index d29dd305..3665660d 100644 --- a/drivers/139/util.go +++ b/drivers/139/util.go @@ -1,11 +1,13 @@ package _139 import ( + "context" "encoding/base64" "errors" "fmt" "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/token" + "io" "net/http" "net/url" "path" @@ -15,6 +17,7 @@ import ( "time" "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/driver" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/op" "github.com/OpenListTeam/OpenList/v4/pkg/utils" @@ -107,8 +110,7 @@ func (d *Yun139) refreshToken() error { return nil } -func (d *Yun139) request(pathname string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) { - url := "https://yun.139.com" + pathname +func (d *Yun139) request(url string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) { req := base.RestyClient.R() randStr := random.String(16) ts := time.Now().Format("2006-01-02 15:04:05") @@ -219,7 +221,7 @@ func (d *Yun139) requestRoute(data interface{}, resp interface{}) ([]byte, error } func (d *Yun139) post(pathname string, data interface{}, resp interface{}) ([]byte, error) { - return d.request(pathname, http.MethodPost, func(req *resty.Request) { + return d.request("https://yun.139.com"+pathname, http.MethodPost, func(req *resty.Request) { req.SetBody(data) }, resp) } @@ -268,7 +270,7 @@ func (d *Yun139) getFiles(catalogID string) ([]model.Obj, error) { HashInfo: utils.NewHashInfo(utils.MD5, content.Digest), }, Thumbnail: model.Thumbnail{Thumbnail: content.ThumbnailURL}, - //Thumbnail: content.BigthumbnailURL, + // Thumbnail: content.BigthumbnailURL, } files = append(files, &f) } @@ -335,7 +337,7 @@ func (d *Yun139) familyGetFiles(catalogID string) ([]model.Obj, error) { Path: path, // 文件所在目录的Path }, Thumbnail: model.Thumbnail{Thumbnail: content.ThumbnailURL}, - //Thumbnail: content.BigthumbnailURL, + // Thumbnail: content.BigthumbnailURL, } files = append(files, &f) } @@ -390,7 +392,7 @@ func (d *Yun139) groupGetFiles(catalogID string) ([]model.Obj, error) { Path: path, // 文件所在目录的Path }, Thumbnail: model.Thumbnail{Thumbnail: content.ThumbnailURL}, - //Thumbnail: content.BigthumbnailURL, + // Thumbnail: content.BigthumbnailURL, } files = append(files, &f) } @@ -418,6 +420,7 @@ func (d *Yun139) getLink(contentId string) (string, error) { } return jsoniter.Get(res, "data", "downloadURL").ToString(), nil } + func (d *Yun139) familyGetLink(contentId string, path string) (string, error) { data := d.newJson(base.Json{ "contentID": contentId, @@ -510,6 +513,7 @@ func (d *Yun139) personalRequest(pathname string, method string, callback base.R } return res.Body(), nil } + func (d *Yun139) personalPost(pathname string, data interface{}, resp interface{}) ([]byte, error) { return d.personalRequest(pathname, http.MethodPost, func(req *resty.Request) { req.SetBody(data) @@ -545,7 +549,7 @@ func (d *Yun139) personalGetFiles(fileId string) ([]model.Obj, error) { } nextPageCursor = resp.Data.NextPageCursor for _, item := range resp.Data.Items { - var isFolder = (item.Type == "folder") + isFolder := (item.Type == "folder") var f model.Obj if isFolder { f = &model.Object{ @@ -557,7 +561,7 @@ func (d *Yun139) personalGetFiles(fileId string) ([]model.Obj, error) { IsFolder: isFolder, } } else { - var Thumbnails = item.Thumbnails + Thumbnails := item.Thumbnails var ThumbnailUrl string if d.UseLargeThumbnail { for _, thumb := range Thumbnails { @@ -600,7 +604,7 @@ func (d *Yun139) personalGetLink(fileId string) (string, error) { if err != nil { return "", err } - var cdnUrl = jsoniter.Get(res, "data", "cdnUrl").ToString() + cdnUrl := jsoniter.Get(res, "data", "cdnUrl").ToString() if cdnUrl != "" { return cdnUrl, nil } else { @@ -614,15 +618,91 @@ func (d *Yun139) getAuthorization() string { } return d.Authorization } + func (d *Yun139) getAccount() string { if d.ref != nil { return d.ref.getAccount() } return d.Account } + func (d *Yun139) getPersonalCloudHost() string { if d.ref != nil { return d.ref.getPersonalCloudHost() } return d.PersonalCloudHost } + +func (d *Yun139) uploadPersonalParts(ctx context.Context, partInfos []PartInfo, uploadPartInfos []PersonalPartInfo, rateLimited *driver.RateLimitReader, p *driver.Progress) error { + // 确保数组以 PartNumber 从小到大排序 + sort.Slice(uploadPartInfos, func(i, j int) bool { + return uploadPartInfos[i].PartNumber < uploadPartInfos[j].PartNumber + }) + + for _, uploadPartInfo := range uploadPartInfos { + index := uploadPartInfo.PartNumber - 1 + if index < 0 || index >= len(partInfos) { + return fmt.Errorf("invalid PartNumber %d: index out of bounds (partInfos length: %d)", uploadPartInfo.PartNumber, len(partInfos)) + } + partSize := partInfos[index].PartSize + log.Debugf("[139] uploading part %+v/%+v", index, len(partInfos)) + limitReader := io.LimitReader(rateLimited, partSize) + r := io.TeeReader(limitReader, p) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadPartInfo.UploadUrl, r) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Content-Length", fmt.Sprint(partSize)) + req.Header.Set("Origin", "https://yun.139.com") + req.Header.Set("Referer", "https://yun.139.com/") + req.ContentLength = partSize + err = func() error { + res, err := base.HttpClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + log.Debugf("[139] uploaded: %+v", res) + if res.StatusCode != http.StatusOK { + body, _ := io.ReadAll(res.Body) + return fmt.Errorf("unexpected status code: %d, body: %s", res.StatusCode, string(body)) + } + return nil + }() + if err != nil { + return err + } + } + return nil +} + +func (d *Yun139) getPersonalDiskInfo(ctx context.Context) (*PersonalDiskInfoResp, error) { + data := map[string]interface{}{ + "userDomainId": d.UserDomainID, + } + var resp PersonalDiskInfoResp + _, err := d.request("https://user-njs.yun.139.com/user/disk/getPersonalDiskInfo", http.MethodPost, func(req *resty.Request) { + req.SetBody(data) + req.SetContext(ctx) + }, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (d *Yun139) getFamilyDiskInfo(ctx context.Context) (*FamilyDiskInfoResp, error) { + data := map[string]interface{}{ + "userDomainId": d.UserDomainID, + } + var resp FamilyDiskInfoResp + _, err := d.request("https://user-njs.yun.139.com/user/disk/getFamilyDiskInfo", http.MethodPost, func(req *resty.Request) { + req.SetBody(data) + req.SetContext(ctx) + }, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} diff --git a/drivers/189/driver.go b/drivers/189/driver.go index 62287c73..acefeae8 100644 --- a/drivers/189/driver.go +++ b/drivers/189/driver.go @@ -196,4 +196,17 @@ func (d *Cloud189) Put(ctx context.Context, dstDir model.Obj, stream model.FileS return d.newUpload(ctx, dstDir, stream, up) } +func (d *Cloud189) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + capacityInfo, err := d.getCapacityInfo(ctx) + if err != nil { + return nil, err + } + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: capacityInfo.CloudCapacityInfo.TotalSize, + FreeSpace: capacityInfo.CloudCapacityInfo.FreeSize, + }, + }, nil +} + var _ driver.Driver = (*Cloud189)(nil) diff --git a/drivers/189/types.go b/drivers/189/types.go index 5354db95..4aee16e1 100644 --- a/drivers/189/types.go +++ b/drivers/189/types.go @@ -66,3 +66,21 @@ type DownResp struct { ResMessage string `json:"res_message"` FileDownloadUrl string `json:"downloadUrl"` } + +type CapacityResp struct { + ResCode int `json:"res_code"` + ResMessage string `json:"res_message"` + Account string `json:"account"` + CloudCapacityInfo struct { + FreeSize uint64 `json:"freeSize"` + MailUsedSize uint64 `json:"mail189UsedSize"` + TotalSize uint64 `json:"totalSize"` + UsedSize uint64 `json:"usedSize"` + } `json:"cloudCapacityInfo"` + FamilyCapacityInfo struct { + FreeSize uint64 `json:"freeSize"` + TotalSize uint64 `json:"totalSize"` + UsedSize uint64 `json:"usedSize"` + } `json:"familyCapacityInfo"` + TotalSize uint64 `json:"totalSize"` +} diff --git a/drivers/189/util.go b/drivers/189/util.go index d10c7e8b..6e0682ea 100644 --- a/drivers/189/util.go +++ b/drivers/189/util.go @@ -157,7 +157,7 @@ func (d *Cloud189) request(url string, method string, callback base.ReqCallback, if err != nil { return nil, err } - //log.Debug(res.String()) + // log.Debug(res.String()) if e.ErrorCode != "" { if e.ErrorCode == "InvalidSessionKey" { err = d.newLogin() @@ -186,8 +186,8 @@ func (d *Cloud189) getFiles(fileId string) ([]model.Obj, error) { "mediaType": "0", "folderId": fileId, "iconOption": "5", - "orderBy": "lastOpTime", //account.OrderBy - "descending": "true", //account.OrderDirection + "orderBy": "lastOpTime", // account.OrderBy + "descending": "true", // account.OrderDirection }) }, &resp) if err != nil { @@ -311,7 +311,7 @@ func (d *Cloud189) newUpload(ctx context.Context, dstDir model.Obj, file model.F } d.sessionKey = sessionKey const DEFAULT int64 = 10485760 - var count = int64(math.Ceil(float64(file.GetSize()) / float64(DEFAULT))) + count := int64(math.Ceil(float64(file.GetSize()) / float64(DEFAULT))) res, err := d.uploadRequest("/person/initMultiUpload", map[string]string{ "parentFolderId": dstDir.GetID(), @@ -340,10 +340,10 @@ func (d *Cloud189) newUpload(ctx context.Context, dstDir model.Obj, file model.F if DEFAULT < byteSize { byteSize = DEFAULT } - //log.Debugf("%d,%d", byteSize, finish) + // log.Debugf("%d,%d", byteSize, finish) byteData := make([]byte, byteSize) n, err := io.ReadFull(file, byteData) - //log.Debug(err, n) + // log.Debug(err, n) if err != nil { return err } @@ -395,3 +395,14 @@ func (d *Cloud189) newUpload(ctx context.Context, dstDir model.Obj, file model.F }, nil) return err } + +func (d *Cloud189) getCapacityInfo(ctx context.Context) (*CapacityResp, error) { + var resp CapacityResp + _, err := d.request("https://cloud.189.cn/api/portal/getUserSizeInfo.action", http.MethodGet, func(req *resty.Request) { + req.SetContext(ctx) + }, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} diff --git a/drivers/189_tv/driver.go b/drivers/189_tv/driver.go index cf943a42..6ee69c7e 100644 --- a/drivers/189_tv/driver.go +++ b/drivers/189_tv/driver.go @@ -1,7 +1,6 @@ package _189_tv import ( - "container/ring" "context" "net/http" "strconv" @@ -12,18 +11,20 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/driver" "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/pkg/cron" "github.com/go-resty/resty/v2" ) type Cloud189TV struct { model.Storage Addition - client *resty.Client - tokenInfo *AppSessionResp - uploadThread int - familyTransferFolder *ring.Ring - cleanFamilyTransferFile func() - storageConfig driver.Config + client *resty.Client + tokenInfo *AppSessionResp + uploadThread int + storageConfig driver.Config + + TempUuid string + cron *cron.Cron // 新增 cron 字段 } func (y *Cloud189TV) Config() driver.Config { @@ -68,7 +69,7 @@ func (y *Cloud189TV) Init(ctx context.Context) (err error) { // 避免重复登陆 if !y.isLogin() || y.Addition.AccessToken == "" { if err = y.login(); err != nil { - return + return err } } @@ -79,10 +80,17 @@ func (y *Cloud189TV) Init(ctx context.Context) (err error) { } } - return + y.cron = cron.NewCron(time.Minute * 5) + y.cron.Do(y.keepAlive) + + return err } func (y *Cloud189TV) Drop(ctx context.Context) error { + if y.cron != nil { + y.cron.Stop() + y.cron = nil + } return nil } @@ -236,7 +244,6 @@ func (y *Cloud189TV) Copy(ctx context.Context, srcObj, dstDir model.Obj) error { FileName: srcObj.GetName(), IsFolder: BoolToNumber(srcObj.IsDir()), }) - if err != nil { return err } @@ -270,5 +277,25 @@ func (y *Cloud189TV) Put(ctx context.Context, dstDir model.Obj, stream model.Fil } return y.OldUpload(ctx, dstDir, stream, up, isFamily, overwrite) +} +func (y *Cloud189TV) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + capacityInfo, err := y.getCapacityInfo(ctx) + if err != nil { + return nil, err + } + var total, free uint64 + if y.isFamily() { + total = capacityInfo.FamilyCapacityInfo.TotalSize + free = capacityInfo.FamilyCapacityInfo.FreeSize + } else { + total = capacityInfo.CloudCapacityInfo.TotalSize + free = capacityInfo.CloudCapacityInfo.FreeSize + } + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: total, + FreeSpace: free, + }, + }, nil } diff --git a/drivers/189_tv/meta.go b/drivers/189_tv/meta.go index efe344e3..f50fe7ea 100644 --- a/drivers/189_tv/meta.go +++ b/drivers/189_tv/meta.go @@ -8,7 +8,6 @@ import ( type Addition struct { driver.RootID AccessToken string `json:"access_token"` - TempUuid string OrderBy string `json:"order_by" type:"select" options:"filename,filesize,lastOpTime" default:"filename"` OrderDirection string `json:"order_direction" type:"select" options:"asc,desc" default:"asc"` Type string `json:"type" type:"select" options:"personal,family" default:"personal"` diff --git a/drivers/189_tv/types.go b/drivers/189_tv/types.go index fbec8016..686a090e 100644 --- a/drivers/189_tv/types.go +++ b/drivers/189_tv/types.go @@ -316,3 +316,21 @@ type BatchTaskConflictTaskInfoResp struct { TaskInfos []BatchTaskInfo TaskType int `json:"taskType"` } + +type CapacityResp struct { + ResCode int `json:"res_code"` + ResMessage string `json:"res_message"` + Account string `json:"account"` + CloudCapacityInfo struct { + FreeSize uint64 `json:"freeSize"` + MailUsedSize uint64 `json:"mail189UsedSize"` + TotalSize uint64 `json:"totalSize"` + UsedSize uint64 `json:"usedSize"` + } `json:"cloudCapacityInfo"` + FamilyCapacityInfo struct { + FreeSize uint64 `json:"freeSize"` + TotalSize uint64 `json:"totalSize"` + UsedSize uint64 `json:"usedSize"` + } `json:"familyCapacityInfo"` + TotalSize uint64 `json:"totalSize"` +} diff --git a/drivers/189_tv/utils.go b/drivers/189_tv/utils.go index fd4d74df..cea9411e 100644 --- a/drivers/189_tv/utils.go +++ b/drivers/189_tv/utils.go @@ -66,6 +66,13 @@ func (y *Cloud189TV) AppKeySignatureHeader(url, method string) map[string]string } func (y *Cloud189TV) request(url, method string, callback base.ReqCallback, params map[string]string, resp interface{}, isFamily ...bool) ([]byte, error) { + return y.requestWithRetry(url, method, callback, params, resp, 0, isFamily...) +} + +func (y *Cloud189TV) requestWithRetry(url, method string, callback base.ReqCallback, params map[string]string, resp interface{}, retryCount int, isFamily ...bool) ([]byte, error) { + if y.tokenInfo == nil { + return nil, fmt.Errorf("login failed") + } req := y.client.R().SetQueryParams(clientSuffix()) if params != nil { @@ -91,7 +98,22 @@ func (y *Cloud189TV) request(url, method string, callback base.ReqCallback, para if strings.Contains(res.String(), "userSessionBO is null") || strings.Contains(res.String(), "InvalidSessionKey") { - return nil, errors.New("session expired") + // 限制重试次数,避免无限递归 + if retryCount >= 3 { + y.Addition.AccessToken = "" + op.MustSaveDriverStorage(y) + return nil, errors.New("session expired after retry") + } + + // 尝试刷新会话 + if err := y.refreshSession(); err != nil { + // 如果刷新失败,说明AccessToken也已过期,需要重新登录 + y.Addition.AccessToken = "" + op.MustSaveDriverStorage(y) + return nil, errors.New("session expired") + } + // 如果刷新成功,则重试原始请求(增加重试计数) + return y.requestWithRetry(url, method, callback, params, resp, retryCount+1, isFamily...) } // 处理错误 @@ -131,6 +153,7 @@ func (y *Cloud189TV) put(ctx context.Context, url string, headers map[string]str } } + // 请求完成后http.Client会Close Request.Body resp, err := base.HttpClient.Do(req) if err != nil { return nil, err @@ -153,6 +176,7 @@ func (y *Cloud189TV) put(ctx context.Context, url string, headers map[string]str } return body, nil } + func (y *Cloud189TV) getFiles(ctx context.Context, fileId string, isFamily bool) ([]model.Obj, error) { fullUrl := ApiUrl if isFamily { @@ -210,7 +234,7 @@ func (y *Cloud189TV) login() (err error) { var erron RespErr var tokenInfo AppSessionResp if y.Addition.AccessToken == "" { - if y.Addition.TempUuid == "" { + if y.TempUuid == "" { // 获取登录参数 var uuidInfo UuidInfoResp req.SetResult(&uuidInfo).SetError(&erron) @@ -218,9 +242,8 @@ func (y *Cloud189TV) login() (err error) { req.SetHeaders(y.AppKeySignatureHeader(ApiUrl+"/family/manage/getQrCodeUUID.action", http.MethodGet)) _, err = req.Execute(http.MethodGet, ApiUrl+"/family/manage/getQrCodeUUID.action") - if err != nil { - return + return err } if erron.HasError() { return &erron @@ -229,7 +252,7 @@ func (y *Cloud189TV) login() (err error) { if uuidInfo.Uuid == "" { return errors.New("uuidInfo is empty") } - y.Addition.TempUuid = uuidInfo.Uuid + y.TempUuid = uuidInfo.Uuid op.MustSaveDriverStorage(y) // 展示二维码 @@ -257,10 +280,10 @@ func (y *Cloud189TV) login() (err error) { // Signature req.SetHeaders(y.AppKeySignatureHeader(ApiUrl+"/family/manage/qrcodeLoginResult.action", http.MethodGet)) - req.SetQueryParam("uuid", y.Addition.TempUuid) + req.SetQueryParam("uuid", y.TempUuid) _, err = req.Execute(http.MethodGet, ApiUrl+"/family/manage/qrcodeLoginResult.action") if err != nil { - return + return err } if erron.HasError() { return &erron @@ -269,7 +292,6 @@ func (y *Cloud189TV) login() (err error) { return errors.New("E189AccessToken is empty") } y.Addition.AccessToken = accessTokenResp.E189AccessToken - y.Addition.TempUuid = "" } } // 获取SessionKey 和 SessionSecret @@ -281,7 +303,7 @@ func (y *Cloud189TV) login() (err error) { reqb.SetQueryParam("e189AccessToken", y.Addition.AccessToken) _, err = reqb.Execute(http.MethodGet, ApiUrl+"/family/manage/loginFamilyMerge.action") if err != nil { - return + return err } if erron.HasError() { @@ -290,7 +312,45 @@ func (y *Cloud189TV) login() (err error) { y.tokenInfo = &tokenInfo op.MustSaveDriverStorage(y) - return + return err +} + +// refreshSession 尝试使用现有的 AccessToken 刷新会话 +func (y *Cloud189TV) refreshSession() (err error) { + var erron RespErr + var tokenInfo AppSessionResp + reqb := y.client.R().SetQueryParams(clientSuffix()) + reqb.SetResult(&tokenInfo).SetError(&erron) + // Signature + reqb.SetHeaders(y.AppKeySignatureHeader(ApiUrl+"/family/manage/loginFamilyMerge.action", + http.MethodGet)) + reqb.SetQueryParam("e189AccessToken", y.Addition.AccessToken) + _, err = reqb.Execute(http.MethodGet, ApiUrl+"/family/manage/loginFamilyMerge.action") + if err != nil { + return err + } + + if erron.HasError() { + return &erron + } + + y.tokenInfo = &tokenInfo + return nil +} + +func (y *Cloud189TV) keepAlive() { + _, err := y.get(ApiUrl+"/keepUserSession.action", func(r *resty.Request) { + r.SetQueryParams(clientSuffix()) + }, nil) + if err != nil { + utils.Log.Warnf("189tv: Failed to keep user session alive: %v", err) + // 如果keepAlive失败,尝试刷新session + if refreshErr := y.refreshSession(); refreshErr != nil { + utils.Log.Errorf("189tv: Failed to refresh session after keepAlive error: %v", refreshErr) + } + } else { + utils.Log.Debugf("189tv: User session kept alive successfully.") + } } func (y *Cloud189TV) RapidUpload(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, isFamily bool, overwrite bool) (model.Obj, error) { @@ -314,7 +374,7 @@ func (y *Cloud189TV) RapidUpload(ctx context.Context, dstDir model.Obj, stream m // 旧版本上传,家庭云不支持覆盖 func (y *Cloud189TV) OldUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress, isFamily bool, overwrite bool) (model.Obj, error) { fileMd5 := file.GetHash().GetHash(utils.MD5) - var tempFile = file.GetFile() + tempFile := file.GetFile() var err error if len(fileMd5) != utils.MD5.Width { tempFile, fileMd5, err = stream.CacheFullAndHash(file, &up, utils.MD5) @@ -333,6 +393,10 @@ func (y *Cloud189TV) OldUpload(ctx context.Context, dstDir model.Obj, file model // 网盘中不存在该文件,开始上传 status := GetUploadFileStatusResp{CreateUploadFileResp: *uploadInfo} + // driver.RateLimitReader会尝试Close底层的reader + // 但这里的tempFile是一个*os.File,Close后就没法继续读了 + // 所以这里用io.NopCloser包一层 + rateLimitedRd := driver.NewLimitedUploadStream(ctx, io.NopCloser(tempFile)) for status.GetSize() < file.GetSize() && status.FileDataExists != 1 { if utils.IsCanceled(ctx) { return nil, ctx.Err() @@ -350,7 +414,7 @@ func (y *Cloud189TV) OldUpload(ctx context.Context, dstDir model.Obj, file model header["Edrive-UploadFileId"] = fmt.Sprint(status.UploadFileId) } - _, err := y.put(ctx, status.FileUploadUrl, header, true, tempFile, isFamily) + _, err := y.put(ctx, status.FileUploadUrl, header, true, rateLimitedRd, isFamily) if err, ok := err.(*RespErr); ok && err.Code != "InputStreamReadError" { return nil, err } @@ -413,7 +477,6 @@ func (y *Cloud189TV) OldUploadCreate(ctx context.Context, parentID string, fileM }) } }, &uploadInfo, isFamily) - if err != nil { return nil, err } @@ -567,3 +630,15 @@ func (y *Cloud189TV) WaitBatchTask(aType string, taskID string, t time.Duration) time.Sleep(t) } } + +func (y *Cloud189TV) getCapacityInfo(ctx context.Context) (*CapacityResp, error) { + fullUrl := ApiUrl + "/portal/getUserSizeInfo.action" + var resp CapacityResp + _, err := y.get(fullUrl, func(req *resty.Request) { + req.SetContext(ctx) + }, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} diff --git a/drivers/189pc/driver.go b/drivers/189pc/driver.go index 00c6b684..8b69556a 100644 --- a/drivers/189pc/driver.go +++ b/drivers/189pc/driver.go @@ -27,8 +27,10 @@ type Cloud189PC struct { client *resty.Client - loginParam *LoginParam - tokenInfo *AppSessionResp + loginParam *LoginParam + qrcodeParam *QRLoginParam + + tokenInfo *AppSessionResp uploadThread int @@ -114,6 +116,11 @@ func (y *Cloud189PC) Init(ctx context.Context) (err error) { return nil } } + + // 初始化并启动 cron 任务 + y.cron = cron.NewCron(time.Duration(time.Minute * 5)) + // 每5分钟执行一次 keepAlive + y.cron.Do(y.keepAlive) } // 处理家庭云ID @@ -160,6 +167,7 @@ func (y *Cloud189PC) Drop(ctx context.Context) error { y.ref = nil if y.cron != nil { y.cron.Stop() + y.cron = nil } return nil } @@ -328,7 +336,6 @@ func (y *Cloud189PC) Copy(ctx context.Context, srcObj, dstDir model.Obj) error { FileName: srcObj.GetName(), IsFolder: BoolToNumber(srcObj.IsDir()), }) - if err != nil { return err } @@ -434,3 +441,24 @@ func (y *Cloud189PC) Put(ctx context.Context, dstDir model.Obj, stream model.Fil return y.StreamUpload(ctx, dstDir, stream, up, isFamily, overwrite) } } + +func (y *Cloud189PC) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + capacityInfo, err := y.getCapacityInfo(ctx) + if err != nil { + return nil, err + } + var total, free uint64 + if y.isFamily() { + total = capacityInfo.FamilyCapacityInfo.TotalSize + free = capacityInfo.FamilyCapacityInfo.FreeSize + } else { + total = capacityInfo.CloudCapacityInfo.TotalSize + free = capacityInfo.CloudCapacityInfo.FreeSize + } + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: total, + FreeSpace: free, + }, + }, nil +} diff --git a/drivers/189pc/help.go b/drivers/189pc/help.go index 8bd90d47..6f6c59f3 100644 --- a/drivers/189pc/help.go +++ b/drivers/189pc/help.go @@ -80,6 +80,20 @@ func timestamp() int64 { return time.Now().UTC().UnixNano() / 1e6 } +// formatDate formats a time.Time object into the "YYYY-MM-DDHH:mm:ssSSS" format. +func formatDate(t time.Time) string { + // The layout string "2006-01-0215:04:05.000" corresponds to: + // 2006 -> Year (YYYY) + // 01 -> Month (MM) + // 02 -> Day (DD) + // 15 -> Hour (HH) + // 04 -> Minute (mm) + // 05 -> Second (ss) + // 000 -> Millisecond (SSS) with leading zeros + // Note the lack of a separator between the date and hour, matching the desired output. + return t.Format("2006-01-0215:04:05.000") +} + func MustParseTime(str string) *time.Time { lastOpTime, _ := time.ParseInLocation("2006-01-02 15:04:05 -07", str+" +08", time.Local) return &lastOpTime diff --git a/drivers/189pc/meta.go b/drivers/189pc/meta.go index cbfb6498..a3325efe 100644 --- a/drivers/189pc/meta.go +++ b/drivers/189pc/meta.go @@ -6,9 +6,11 @@ import ( ) type Addition struct { - Username string `json:"username" required:"true"` - Password string `json:"password" required:"true"` - VCode string `json:"validate_code"` + LoginType string `json:"login_type" type:"select" options:"password,qrcode" default:"password" required:"true"` + Username string `json:"username" required:"true"` + Password string `json:"password" required:"true"` + VCode string `json:"validate_code"` + RefreshToken string `json:"refresh_token" help:"To switch accounts, please clear this field"` driver.RootID OrderBy string `json:"order_by" type:"select" options:"filename,filesize,lastOpTime" default:"filename"` OrderDirection string `json:"order_direction" type:"select" options:"asc,desc" default:"asc"` diff --git a/drivers/189pc/types.go b/drivers/189pc/types.go index 6620483f..fe219d8c 100644 --- a/drivers/189pc/types.go +++ b/drivers/189pc/types.go @@ -68,15 +68,7 @@ func (e *RespErr) Error() string { return "" } -// 登陆需要的参数 -type LoginParam struct { - // 加密后的用户名和密码 - RsaUsername string - RsaPassword string - - // rsa密钥 - jRsaKey string - +type BaseLoginParam struct { // 请求头参数 Lt string ReqId string @@ -88,6 +80,27 @@ type LoginParam struct { CaptchaToken string } +// QRLoginParam 用于暂存二维码登录过程中的参数 +type QRLoginParam struct { + BaseLoginParam + + UUID string `json:"uuid"` + EncodeUUID string `json:"encodeuuid"` + EncryUUID string `json:"encryuuid"` +} + +// 登陆需要的参数 +type LoginParam struct { + // 加密后的用户名和密码 + RsaUsername string + RsaPassword string + + // rsa密钥 + jRsaKey string + + BaseLoginParam +} + // 登陆加密相关 type EncryptConfResp struct { Result int `json:"result"` @@ -396,3 +409,21 @@ func (p Params) Encode() string { } return buf.String() } + +type CapacityResp struct { + ResCode int `json:"res_code"` + ResMessage string `json:"res_message"` + Account string `json:"account"` + CloudCapacityInfo struct { + FreeSize uint64 `json:"freeSize"` + MailUsedSize uint64 `json:"mail189UsedSize"` + TotalSize uint64 `json:"totalSize"` + UsedSize uint64 `json:"usedSize"` + } `json:"cloudCapacityInfo"` + FamilyCapacityInfo struct { + FreeSize uint64 `json:"freeSize"` + TotalSize uint64 `json:"totalSize"` + UsedSize uint64 `json:"usedSize"` + } `json:"familyCapacityInfo"` + TotalSize uint64 `json:"totalSize"` +} diff --git a/drivers/189pc/utils.go b/drivers/189pc/utils.go index c791e755..64a06663 100644 --- a/drivers/189pc/utils.go +++ b/drivers/189pc/utils.go @@ -29,6 +29,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/stream" "github.com/OpenListTeam/OpenList/v4/pkg/errgroup" "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "github.com/skip2/go-qrcode" "github.com/avast/retry-go" "github.com/go-resty/resty/v2" @@ -54,6 +55,9 @@ const ( MAC = "TELEMAC" CHANNEL_ID = "web_cloud.189.cn" + + // Error codes + UserInvalidOpenTokenError = "UserInvalidOpenToken" ) func (y *Cloud189PC) SignatureHeader(url, method, params string, isFamily bool) map[string]string { @@ -86,6 +90,9 @@ func (y *Cloud189PC) EncryptParams(params Params, isFamily bool) string { } func (y *Cloud189PC) request(url, method string, callback base.ReqCallback, params Params, resp interface{}, isFamily ...bool) ([]byte, error) { + if y.getTokenInfo() == nil { + return nil, fmt.Errorf("login failed") + } req := y.getClient().R().SetQueryParams(clientSuffix()) // 设置params @@ -185,6 +192,7 @@ func (y *Cloud189PC) put(ctx context.Context, url string, headers map[string]str } return body, nil } + func (y *Cloud189PC) getFiles(ctx context.Context, fileId string, isFamily bool) ([]model.Obj, error) { res := make([]model.Obj, 0, 100) for pageNum := 1; ; pageNum++ { @@ -264,7 +272,14 @@ func (y *Cloud189PC) findFileByName(ctx context.Context, searchName string, fold } } -func (y *Cloud189PC) login() (err error) { +func (y *Cloud189PC) login() error { + if y.LoginType == "qrcode" { + return y.loginByQRCode() + } + return y.loginByPassword() +} + +func (y *Cloud189PC) loginByPassword() (err error) { // 初始化登陆所需参数 if y.loginParam == nil { if err = y.initLoginParam(); err != nil { @@ -278,10 +293,15 @@ func (y *Cloud189PC) login() (err error) { // 销毁登陆参数 y.loginParam = nil // 遇到错误,重新加载登陆参数(刷新验证码) - if err != nil && y.NoUseOcr { - if err1 := y.initLoginParam(); err1 != nil { - err = fmt.Errorf("err1: %s \nerr2: %s", err, err1) + if err != nil { + if y.NoUseOcr { + if err1 := y.initLoginParam(); err1 != nil { + err = fmt.Errorf("err1: %s \nerr2: %s", err, err1) + } } + + y.Status = err.Error() + op.MustSaveDriverStorage(y) } }() @@ -326,7 +346,7 @@ func (y *Cloud189PC) login() (err error) { SetQueryParam("redirectURL", loginresp.ToUrl). Post(API_URL + "/getSessionForPC.action") if err != nil { - return + return err } if erron.HasError() { @@ -334,16 +354,106 @@ func (y *Cloud189PC) login() (err error) { } if tokenInfo.ResCode != 0 { err = fmt.Errorf(tokenInfo.ResMessage) - return + return err } + y.Addition.RefreshToken = tokenInfo.RefreshToken y.tokenInfo = &tokenInfo - return + op.MustSaveDriverStorage(y) + return err } -/* 初始化登陆需要的参数 -* 如果遇到验证码返回错误 - */ -func (y *Cloud189PC) initLoginParam() error { +func (y *Cloud189PC) loginByQRCode() error { + if y.qrcodeParam == nil { + if err := y.initQRCodeParam(); err != nil { + // 二维码也通过错误返回 + return err + } + } + + var state struct { + Status int `json:"status"` + RedirectUrl string `json:"redirectUrl"` + Msg string `json:"msg"` + } + + now := time.Now() + _, err := y.client.R(). + SetHeaders(map[string]string{ + "Referer": AUTH_URL, + "Reqid": y.qrcodeParam.ReqId, + "lt": y.qrcodeParam.Lt, + }). + SetFormData(map[string]string{ + "appId": APP_ID, + "clientType": CLIENT_TYPE, + "returnUrl": RETURN_URL, + "paramId": y.qrcodeParam.ParamId, + "uuid": y.qrcodeParam.UUID, + "encryuuid": y.qrcodeParam.EncryUUID, + "date": formatDate(now), + "timeStamp": fmt.Sprint(now.UTC().UnixNano() / 1e6), + }). + ForceContentType("application/json;charset=UTF-8"). + SetResult(&state). + Post(AUTH_URL + "/api/logbox/oauth2/qrcodeLoginState.do") + if err != nil { + return fmt.Errorf("failed to check QR code state: %w", err) + } + + switch state.Status { + case 0: // 登录成功 + var tokenInfo AppSessionResp + _, err = y.client.R(). + SetResult(&tokenInfo). + SetQueryParams(clientSuffix()). + SetQueryParam("redirectURL", state.RedirectUrl). + Post(API_URL + "/getSessionForPC.action") + if err != nil { + return err + } + if tokenInfo.ResCode != 0 { + return fmt.Errorf(tokenInfo.ResMessage) + } + y.Addition.RefreshToken = tokenInfo.RefreshToken + y.tokenInfo = &tokenInfo + op.MustSaveDriverStorage(y) + return nil + case -11001: // 二维码过期 + y.qrcodeParam = nil + return errors.New("QR code expired, please try again") + case -106: // 等待扫描 + return y.genQRCode("QR code has not been scanned yet, please scan and save again") + case -11002: // 等待确认 + return y.genQRCode("QR code has been scanned, please confirm the login on your phone and save again") + default: // 其他错误 + y.qrcodeParam = nil + return fmt.Errorf("QR code login failed with status %d: %s", state.Status, state.Msg) + } +} + +func (y *Cloud189PC) genQRCode(text string) error { + // 展示二维码 + qrTemplate := ` + state: %s +
+
Or Click here: Login +` + + // Generate QR code + qrCode, err := qrcode.Encode(y.qrcodeParam.UUID, qrcode.Medium, 256) + if err != nil { + return fmt.Errorf("failed to generate QR code: %v", err) + } + + // Encode QR code to base64 + qrCodeBase64 := base64.StdEncoding.EncodeToString(qrCode) + + // Create the HTML page + qrPage := fmt.Sprintf(qrTemplate, text, qrCodeBase64, y.qrcodeParam.UUID) + return fmt.Errorf("need verify: \n%s", qrPage) +} + +func (y *Cloud189PC) initBaseParams() (*BaseLoginParam, error) { // 清除cookie jar, _ := cookiejar.New(nil) y.client.SetCookieJar(jar) @@ -357,17 +467,30 @@ func (y *Cloud189PC) initLoginParam() error { }). Get(WEB_URL + "/api/portal/unifyLoginForPC.action") if err != nil { - return err + return nil, err } - param := LoginParam{ + return &BaseLoginParam{ CaptchaToken: regexp.MustCompile(`'captchaToken' value='(.+?)'`).FindStringSubmatch(res.String())[1], Lt: regexp.MustCompile(`lt = "(.+?)"`).FindStringSubmatch(res.String())[1], ParamId: regexp.MustCompile(`paramId = "(.+?)"`).FindStringSubmatch(res.String())[1], ReqId: regexp.MustCompile(`reqId = "(.+?)"`).FindStringSubmatch(res.String())[1], - // jRsaKey: regexp.MustCompile(`"j_rsaKey" value="(.+?)"`).FindStringSubmatch(res.String())[1], + }, nil +} + +/* 初始化登陆需要的参数 + * 如果遇到验证码返回错误 + */ +func (y *Cloud189PC) initLoginParam() error { + y.loginParam = nil + + baseParam, err := y.initBaseParams() + if err != nil { + return err } + y.loginParam = &LoginParam{BaseLoginParam: *baseParam} + // 获取rsa公钥 var encryptConf EncryptConfResp _, err = y.client.R(). @@ -378,18 +501,17 @@ func (y *Cloud189PC) initLoginParam() error { return err } - param.jRsaKey = fmt.Sprintf("-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----", encryptConf.Data.PubKey) - param.RsaUsername = encryptConf.Data.Pre + RsaEncrypt(param.jRsaKey, y.Username) - param.RsaPassword = encryptConf.Data.Pre + RsaEncrypt(param.jRsaKey, y.Password) - y.loginParam = ¶m + y.loginParam.jRsaKey = fmt.Sprintf("-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----", encryptConf.Data.PubKey) + y.loginParam.RsaUsername = encryptConf.Data.Pre + RsaEncrypt(y.loginParam.jRsaKey, y.Username) + y.loginParam.RsaPassword = encryptConf.Data.Pre + RsaEncrypt(y.loginParam.jRsaKey, y.Password) // 判断是否需要验证码 resp, err := y.client.R(). - SetHeader("REQID", param.ReqId). + SetHeader("REQID", y.loginParam.ReqId). SetFormData(map[string]string{ "appKey": APP_ID, "accountType": ACCOUNT_TYPE, - "userName": param.RsaUsername, + "userName": y.loginParam.RsaUsername, }).Post(AUTH_URL + "/api/logbox/oauth2/needcaptcha.do") if err != nil { return err @@ -401,8 +523,8 @@ func (y *Cloud189PC) initLoginParam() error { // 拉取验证码 imgRes, err := y.client.R(). SetQueryParams(map[string]string{ - "token": param.CaptchaToken, - "REQID": param.ReqId, + "token": y.loginParam.CaptchaToken, + "REQID": y.loginParam.ReqId, "rnd": fmt.Sprint(timestamp()), }). Get(AUTH_URL + "/api/logbox/oauth2/picCaptcha.do") @@ -429,10 +551,38 @@ func (y *Cloud189PC) initLoginParam() error { return nil } +// getQRCode 获取并返回二维码 +func (y *Cloud189PC) initQRCodeParam() (err error) { + y.qrcodeParam = nil + + baseParam, err := y.initBaseParams() + if err != nil { + return err + } + + var qrcodeParam QRLoginParam + _, err = y.client.R(). + SetFormData(map[string]string{"appId": APP_ID}). + ForceContentType("application/json;charset=UTF-8"). + SetResult(&qrcodeParam). + Post(AUTH_URL + "/api/logbox/oauth2/getUUID.do") + if err != nil { + return err + } + qrcodeParam.BaseLoginParam = *baseParam + y.qrcodeParam = &qrcodeParam + + return y.genQRCode("please scan the QR code with the 189 Cloud app, then save the settings again.") +} + // 刷新会话 func (y *Cloud189PC) refreshSession() (err error) { + return y.refreshSessionWithRetry(0) +} + +func (y *Cloud189PC) refreshSessionWithRetry(retryCount int) (err error) { if y.ref != nil { - return y.ref.refreshSession() + return y.ref.refreshSessionWithRetry(retryCount) } var erron RespErr var userSessionResp UserSessionResp @@ -449,37 +599,102 @@ func (y *Cloud189PC) refreshSession() (err error) { return err } - // 错误影响正常访问,下线该储存 - defer func() { - if err != nil { - y.GetStorage().SetStatus(fmt.Sprintf("%+v", err.Error())) + // token生效刷新token + if erron.HasError() { + if erron.ResCode == UserInvalidOpenTokenError { + return y.refreshTokenWithRetry(retryCount) + } + return &erron + } + y.tokenInfo.UserSessionResp = userSessionResp + return nil +} + +// refreshToken 刷新token,失败时返回错误,不再直接调用login +func (y *Cloud189PC) refreshToken() (err error) { + return y.refreshTokenWithRetry(0) +} + +func (y *Cloud189PC) refreshTokenWithRetry(retryCount int) (err error) { + if y.ref != nil { + return y.ref.refreshTokenWithRetry(retryCount) + } + + // 限制重试次数,避免无限递归 + if retryCount >= 3 { + if y.Addition.RefreshToken != "" { + y.Addition.RefreshToken = "" op.MustSaveDriverStorage(y) } - }() + return errors.New("refresh token failed after maximum retries") + } + + var erron RespErr + var tokenInfo AppSessionResp + _, err = y.client.R(). + SetResult(&tokenInfo). + ForceContentType("application/json;charset=UTF-8"). + SetError(&erron). + SetFormData(map[string]string{ + "clientId": APP_ID, + "refreshToken": y.tokenInfo.RefreshToken, + "grantType": "refresh_token", + "format": "json", + }). + Post(AUTH_URL + "/api/oauth2/refreshToken.do") + if err != nil { + return err + } + // 如果刷新失败,返回错误给上层处理 if erron.HasError() { - if erron.ResCode == "UserInvalidOpenToken" { - if err = y.login(); err != nil { - return err - } + if y.Addition.RefreshToken != "" { + y.Addition.RefreshToken = "" + op.MustSaveDriverStorage(y) } - return &erron + + // 根据登录类型决定下一步行为 + if y.LoginType == "qrcode" { + return errors.New("QR code session has expired, please re-scan the code to log in") + } + // 密码登录模式下,尝试回退到完整登录 + return y.login() + } + + y.Addition.RefreshToken = tokenInfo.RefreshToken + y.tokenInfo = &tokenInfo + op.MustSaveDriverStorage(y) + return y.refreshSessionWithRetry(retryCount + 1) +} + +func (y *Cloud189PC) keepAlive() { + _, err := y.get(API_URL+"/keepUserSession.action", func(r *resty.Request) { + r.SetQueryParams(clientSuffix()) + }, nil) + if err != nil { + utils.Log.Warnf("189pc: Failed to keep user session alive: %v", err) + // 如果keepAlive失败,尝试刷新session + if refreshErr := y.refreshSession(); refreshErr != nil { + utils.Log.Errorf("189pc: Failed to refresh session after keepAlive error: %v", refreshErr) + } + } else { + utils.Log.Debugf("189pc: User session kept alive successfully.") } - y.tokenInfo.UserSessionResp = userSessionResp - return } // 普通上传 // 无法上传大小为0的文件 func (y *Cloud189PC) StreamUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress, isFamily bool, overwrite bool) (model.Obj, error) { - size := file.GetSize() - sliceSize := min(size, partSize(size)) + // 文件大小 + fileSize := file.GetSize() + // 分片大小,不得为文件大小 + sliceSize := partSize(fileSize) params := Params{ "parentFolderId": dstDir.GetID(), "fileName": url.QueryEscape(file.GetName()), - "fileSize": fmt.Sprint(file.GetSize()), - "sliceSize": fmt.Sprint(sliceSize), + "fileSize": fmt.Sprint(fileSize), + "sliceSize": fmt.Sprint(sliceSize), // 必须为特定分片大小 "lazyCheck": "1", } @@ -488,7 +703,7 @@ func (y *Cloud189PC) StreamUpload(ctx context.Context, dstDir model.Obj, file mo params.Set("familyId", y.FamilyID) fullUrl += "/family" } else { - //params.Set("extend", `{"opScene":"1","relativepath":"","rootfolderid":""}`) + // params.Set("extend", `{"opScene":"1","relativepath":"","rootfolderid":""}`) fullUrl += "/person" } @@ -512,10 +727,10 @@ func (y *Cloud189PC) StreamUpload(ctx context.Context, dstDir model.Obj, file mo retry.DelayType(retry.BackOffDelay)) count := 1 - if size > sliceSize { - count = int((size + sliceSize - 1) / sliceSize) + if fileSize > sliceSize { + count = int((fileSize + sliceSize - 1) / sliceSize) } - lastPartSize := size % sliceSize + lastPartSize := fileSize % sliceSize if lastPartSize == 0 { lastPartSize = sliceSize } @@ -535,25 +750,25 @@ func (y *Cloud189PC) StreamUpload(ctx context.Context, dstDir model.Obj, file mo break } offset := int64((i)-1) * sliceSize - size := sliceSize + partSize := sliceSize if i == count { - size = lastPartSize + partSize = lastPartSize } partInfo := "" - var reader *stream.SectionReader + var reader io.ReadSeeker var rateLimitedRd io.Reader threadG.GoWithLifecycle(errgroup.Lifecycle{ Before: func(ctx context.Context) error { if reader == nil { var err error - reader, err = ss.GetSectionReader(offset, size) + reader, err = ss.GetSectionReader(offset, partSize) if err != nil { return err } silceMd5.Reset() w, err := utils.CopyWithBuffer(writers, reader) - if w != size { - return fmt.Errorf("failed to read all data: (expect =%d, actual =%d) %w", size, w, err) + if w != partSize { + return fmt.Errorf("failed to read all data: (expect =%d, actual =%d) %w", partSize, w, err) } // 计算块md5并进行hex和base64编码 md5Bytes := silceMd5.Sum(nil) @@ -573,8 +788,7 @@ func (y *Cloud189PC) StreamUpload(ctx context.Context, dstDir model.Obj, file mo // step.4 上传切片 uploadUrl := uploadUrls[0] - _, err = y.put(ctx, uploadUrl.RequestURL, uploadUrl.Headers, false, - driver.NewLimitedUploadStream(ctx, rateLimitedRd), isFamily) + _, err = y.put(ctx, uploadUrl.RequestURL, uploadUrl.Headers, false, rateLimitedRd, isFamily) if err != nil { return err } @@ -595,7 +809,7 @@ func (y *Cloud189PC) StreamUpload(ctx context.Context, dstDir model.Obj, file mo fileMd5Hex = strings.ToUpper(hex.EncodeToString(fileMd5.Sum(nil))) } sliceMd5Hex := fileMd5Hex - if file.GetSize() > sliceSize { + if fileSize > sliceSize { sliceMd5Hex = strings.ToUpper(utils.GetMD5EncodeStr(strings.Join(silceMd5Hexs, "\n"))) } @@ -665,7 +879,7 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode lastSliceSize = sliceSize } - //step.1 优先计算所需信息 + // step.1 优先计算所需信息 byteSize := sliceSize fileMd5 := utils.MD5.NewFunc() sliceMd5 := utils.MD5.NewFunc() @@ -716,14 +930,14 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode if isFamily { fullUrl += "/family" } else { - //params.Set("extend", `{"opScene":"1","relativepath":"","rootfolderid":""}`) + // params.Set("extend", `{"opScene":"1","relativepath":"","rootfolderid":""}`) fullUrl += "/person" } // 尝试恢复进度 uploadProgress, ok := base.GetUploadProgress[*UploadProgress](y, y.getTokenInfo().SessionKey, fileMd5Hex) if !ok { - //step.2 预上传 + // step.2 预上传 params := Params{ "parentFolderId": dstDir.GetID(), "fileName": url.QueryEscape(file.GetName()), @@ -952,7 +1166,6 @@ func (y *Cloud189PC) OldUploadCreate(ctx context.Context, parentID string, fileM }) } }, &uploadInfo, isFamily) - if err != nil { return nil, err } @@ -1262,3 +1475,15 @@ func (y *Cloud189PC) getClient() *resty.Client { } return y.client } + +func (y *Cloud189PC) getCapacityInfo(ctx context.Context) (*CapacityResp, error) { + fullUrl := API_URL + "/portal/getUserSizeInfo.action" + var resp CapacityResp + _, err := y.get(fullUrl, func(req *resty.Request) { + req.SetContext(ctx) + }, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} diff --git a/drivers/alias/driver.go b/drivers/alias/driver.go index 6954f2b5..cfa1152d 100644 --- a/drivers/alias/driver.go +++ b/drivers/alias/driver.go @@ -23,6 +23,7 @@ import ( type Alias struct { model.Storage Addition + rootOrder []string pathMap map[string][]string autoFlatten bool oneKey string @@ -40,13 +41,18 @@ func (d *Alias) Init(ctx context.Context) error { if d.Paths == "" { return errors.New("paths is required") } + paths := strings.Split(d.Paths, "\n") + d.rootOrder = make([]string, 0, len(paths)) d.pathMap = make(map[string][]string) - for _, path := range strings.Split(d.Paths, "\n") { + for _, path := range paths { path = strings.TrimSpace(path) if path == "" { continue } k, v := getPair(path) + if _, ok := d.pathMap[k]; !ok { + d.rootOrder = append(d.rootOrder, k) + } d.pathMap[k] = append(d.pathMap[k], v) } if len(d.pathMap) == 1 { @@ -62,6 +68,7 @@ func (d *Alias) Init(ctx context.Context) error { } func (d *Alias) Drop(ctx context.Context) error { + d.rootOrder = nil d.pathMap = nil return nil } @@ -79,27 +86,51 @@ func (d *Alias) Get(ctx context.Context, path string) (model.Obj, error) { if !ok { return nil, errs.ObjectNotFound } + var ret *model.Object + provider := "" for _, dst := range dsts { - obj, err := fs.Get(ctx, stdpath.Join(dst, sub), &fs.GetArgs{NoLog: true}) + rawPath := stdpath.Join(dst, sub) + obj, err := fs.Get(ctx, rawPath, &fs.GetArgs{NoLog: true}) if err != nil { continue } - return &model.Object{ - Path: path, - Name: obj.GetName(), - Size: obj.GetSize(), - Modified: obj.ModTime(), - IsFolder: obj.IsDir(), - HashInfo: obj.GetHash(), + storage, err := fs.GetStorage(rawPath, &fs.GetStoragesArgs{}) + if ret == nil { + ret = &model.Object{ + Path: path, + Name: obj.GetName(), + Size: obj.GetSize(), + Modified: obj.ModTime(), + IsFolder: obj.IsDir(), + HashInfo: obj.GetHash(), + } + if !d.ProviderPassThrough || err != nil { + break + } + provider = storage.Config().Name + } else if err != nil || provider != storage.GetStorage().Driver { + provider = "" + break + } + } + if ret == nil { + return nil, errs.ObjectNotFound + } + if provider != "" { + return &model.ObjectProvider{ + Object: *ret, + Provider: model.Provider{ + Provider: provider, + }, }, nil } - return nil, errs.ObjectNotFound + return ret, nil } func (d *Alias) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { path := dir.GetPath() if utils.PathEqual(path, "/") && !d.autoFlatten { - return d.listRoot(), nil + return d.listRoot(ctx, args.WithStorageDetails && d.DetailsPassThrough, args.Refresh), nil } root, sub := d.getRootAndPath(path) dsts, ok := d.pathMap[root] @@ -107,27 +138,35 @@ func (d *Alias) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([ return nil, errs.ObjectNotFound } var objs []model.Obj - fsArgs := &fs.ListArgs{NoLog: true, Refresh: args.Refresh} for _, dst := range dsts { - tmp, err := fs.List(ctx, stdpath.Join(dst, sub), fsArgs) + tmp, err := fs.List(ctx, stdpath.Join(dst, sub), &fs.ListArgs{ + NoLog: true, + Refresh: args.Refresh, + WithStorageDetails: args.WithStorageDetails && d.DetailsPassThrough, + }) if err == nil { tmp, err = utils.SliceConvert(tmp, func(obj model.Obj) (model.Obj, error) { - thumb, ok := model.GetThumb(obj) objRes := model.Object{ Name: obj.GetName(), Size: obj.GetSize(), Modified: obj.ModTime(), IsFolder: obj.IsDir(), } - if !ok { - return &objRes, nil + if thumb, ok := model.GetThumb(obj); ok { + return &model.ObjThumb{ + Object: objRes, + Thumbnail: model.Thumbnail{ + Thumbnail: thumb, + }, + }, nil } - return &model.ObjThumb{ - Object: objRes, - Thumbnail: model.Thumbnail{ - Thumbnail: thumb, - }, - }, nil + if details, ok := model.GetStorageDetails(obj); ok { + return &model.ObjStorageDetails{ + Obj: &objRes, + StorageDetailsWithName: *details, + }, nil + } + return &objRes, nil }) } if err == nil { @@ -172,9 +211,6 @@ func (d *Alias) Link(ctx context.Context, file model.Obj, args model.LinkArgs) ( if resultLink.ContentLength == 0 { resultLink.ContentLength = fi.GetSize() } - if resultLink.MFile != nil { - return &resultLink, nil - } if d.DownloadConcurrency > 0 { resultLink.Concurrency = d.DownloadConcurrency } @@ -186,6 +222,35 @@ func (d *Alias) Link(ctx context.Context, file model.Obj, args model.LinkArgs) ( return nil, errs.ObjectNotFound } +func (d *Alias) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) { + root, sub := d.getRootAndPath(args.Obj.GetPath()) + dsts, ok := d.pathMap[root] + if !ok { + return nil, errs.ObjectNotFound + } + for _, dst := range dsts { + rawPath := stdpath.Join(dst, sub) + storage, actualPath, err := op.GetStorageAndActualPath(rawPath) + if err != nil { + continue + } + other, ok := storage.(driver.Other) + if !ok { + continue + } + obj, err := op.GetUnwrap(ctx, storage, actualPath) + if err != nil { + continue + } + return other.Other(ctx, model.OtherArgs{ + Obj: obj, + Method: args.Method, + Data: args.Data, + }) + } + return nil, errs.NotImplement +} + func (d *Alias) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error { if !d.Writable { return errs.PermissionDenied @@ -197,7 +262,7 @@ func (d *Alias) MakeDir(ctx context.Context, parentDir model.Obj, dirName string } return err } - if errs.IsNotImplement(err) { + if errs.IsNotImplementError(err) { return errors.New("same-name dirs cannot make sub-dir") } return err @@ -208,14 +273,14 @@ func (d *Alias) Move(ctx context.Context, srcObj, dstDir model.Obj) error { return errs.PermissionDenied } srcPath, err := d.getReqPath(ctx, srcObj, false) - if errs.IsNotImplement(err) { + if errs.IsNotImplementError(err) { return errors.New("same-name files cannot be moved") } if err != nil { return err } dstPath, err := d.getReqPath(ctx, dstDir, true) - if errs.IsNotImplement(err) { + if errs.IsNotImplementError(err) { return errors.New("same-name dirs cannot be moved to") } if err != nil { @@ -243,7 +308,7 @@ func (d *Alias) Rename(ctx context.Context, srcObj model.Obj, newName string) er } return err } - if errs.IsNotImplement(err) { + if errs.IsNotImplementError(err) { return errors.New("same-name files cannot be Rename") } return err @@ -254,14 +319,14 @@ func (d *Alias) Copy(ctx context.Context, srcObj, dstDir model.Obj) error { return errs.PermissionDenied } srcPath, err := d.getReqPath(ctx, srcObj, false) - if errs.IsNotImplement(err) { + if errs.IsNotImplementError(err) { return errors.New("same-name files cannot be copied") } if err != nil { return err } dstPath, err := d.getReqPath(ctx, dstDir, true) - if errs.IsNotImplement(err) { + if errs.IsNotImplementError(err) { return errors.New("same-name dirs cannot be copied to") } if err != nil { @@ -295,7 +360,7 @@ func (d *Alias) Remove(ctx context.Context, obj model.Obj) error { } return err } - if errs.IsNotImplement(err) { + if errs.IsNotImplementError(err) { return errors.New("same-name files cannot be Delete") } return err @@ -339,7 +404,7 @@ func (d *Alias) Put(ctx context.Context, dstDir model.Obj, s model.FileStreamer, return err } } - if errs.IsNotImplement(err) { + if errs.IsNotImplementError(err) { return errors.New("same-name dirs cannot be Put") } return err @@ -356,7 +421,7 @@ func (d *Alias) PutURL(ctx context.Context, dstDir model.Obj, name, url string) } return err } - if errs.IsNotImplement(err) { + if errs.IsNotImplementError(err) { return errors.New("same-name files cannot offline download") } return err @@ -429,14 +494,14 @@ func (d *Alias) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.Obj, return errs.PermissionDenied } srcPath, err := d.getReqPath(ctx, srcObj, false) - if errs.IsNotImplement(err) { + if errs.IsNotImplementError(err) { return errors.New("same-name files cannot be decompressed") } if err != nil { return err } dstPath, err := d.getReqPath(ctx, dstDir, true) - if errs.IsNotImplement(err) { + if errs.IsNotImplementError(err) { return errors.New("same-name dirs cannot be decompressed to") } if err != nil { @@ -459,4 +524,25 @@ func (d *Alias) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.Obj, } } +func (d *Alias) ResolveLinkCacheMode(path string) driver.LinkCacheMode { + root, sub := d.getRootAndPath(path) + dsts, ok := d.pathMap[root] + if !ok { + return 0 + } + for _, dst := range dsts { + storage, actualPath, err := op.GetStorageAndActualPath(stdpath.Join(dst, sub)) + if err == nil { + continue + } + mode := storage.Config().LinkCacheMode + if mode == -1 { + return storage.(driver.LinkCacheModeResolver).ResolveLinkCacheMode(actualPath) + } else { + return mode + } + } + return 0 +} + var _ driver.Driver = (*Alias)(nil) diff --git a/drivers/alias/meta.go b/drivers/alias/meta.go index 83e8bba0..763e6647 100644 --- a/drivers/alias/meta.go +++ b/drivers/alias/meta.go @@ -15,6 +15,8 @@ type Addition struct { DownloadConcurrency int `json:"download_concurrency" default:"0" required:"false" type:"number" help:"Need to enable proxy"` DownloadPartSize int `json:"download_part_size" default:"0" type:"number" required:"false" help:"Need to enable proxy. Unit: KB"` Writable bool `json:"writable" type:"bool" default:"false"` + ProviderPassThrough bool `json:"provider_pass_through" type:"bool" default:"false"` + DetailsPassThrough bool `json:"details_pass_through" type:"bool" default:"false"` } var config = driver.Config{ @@ -24,6 +26,7 @@ var config = driver.Config{ NoUpload: false, DefaultRoot: "/", ProxyRangeOption: true, + LinkCacheMode: driver.LinkCacheAuto, } func init() { diff --git a/drivers/alias/util.go b/drivers/alias/util.go index 11c299e9..80391fda 100644 --- a/drivers/alias/util.go +++ b/drivers/alias/util.go @@ -2,8 +2,11 @@ package alias import ( "context" + "errors" stdpath "path" "strings" + "sync" + "time" "github.com/OpenListTeam/OpenList/v4/internal/driver" "github.com/OpenListTeam/OpenList/v4/internal/errs" @@ -11,24 +14,61 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/op" "github.com/OpenListTeam/OpenList/v4/server/common" + log "github.com/sirupsen/logrus" ) -func (d *Alias) listRoot() []model.Obj { +func (d *Alias) listRoot(ctx context.Context, withDetails, refresh bool) []model.Obj { var objs []model.Obj - for k := range d.pathMap { + var wg sync.WaitGroup + for _, k := range d.rootOrder { obj := model.Object{ Name: k, IsFolder: true, Modified: d.Modified, } + idx := len(objs) objs = append(objs, &obj) + v := d.pathMap[k] + if !withDetails || len(v) != 1 { + continue + } + remoteDriver, err := op.GetStorageByMountPath(v[0]) + if err != nil { + continue + } + _, ok := remoteDriver.(driver.WithDetails) + if !ok { + continue + } + objs[idx] = &model.ObjStorageDetails{ + Obj: objs[idx], + StorageDetailsWithName: model.StorageDetailsWithName{ + StorageDetails: nil, + DriverName: remoteDriver.Config().Name, + }, + } + wg.Add(1) + go func() { + defer wg.Done() + c, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + details, e := op.GetStorageDetails(c, remoteDriver, refresh) + if e != nil { + if !errors.Is(e, errs.NotImplement) && !errors.Is(e, errs.StorageNotInit) { + log.Errorf("failed get %s storage details: %+v", remoteDriver.GetStorage().MountPath, e) + } + return + } + objs[idx].(*model.ObjStorageDetails).StorageDetails = details + }() } + wg.Wait() return objs } // do others that not defined in Driver interface func getPair(path string) (string, string) { - //path = strings.TrimSpace(path) + // path = strings.TrimSpace(path) if strings.Contains(path, ":") { pair := strings.SplitN(path, ":", 2) if !strings.Contains(pair[0], "/") { diff --git a/drivers/aliyundrive/driver.go b/drivers/aliyundrive/driver.go index 92df0319..f9093097 100644 --- a/drivers/aliyundrive/driver.go +++ b/drivers/aliyundrive/driver.go @@ -45,7 +45,7 @@ func (d *AliDrive) GetAddition() driver.Additional { func (d *AliDrive) Init(ctx context.Context) error { // TODO login / refresh token - //op.MustSaveDriverStorage(d) + // op.MustSaveDriverStorage(d) err := d.refreshToken() if err != nil { return err @@ -171,7 +171,7 @@ func (d *AliDrive) Put(ctx context.Context, dstDir model.Obj, streamer model.Fil Mimetype: streamer.GetMimetype(), } const DEFAULT int64 = 10485760 - var count = int(math.Ceil(float64(streamer.GetSize()) / float64(DEFAULT))) + count := int(math.Ceil(float64(streamer.GetSize()) / float64(DEFAULT))) partInfoList := make([]base.Json, 0, count) for i := 1; i <= count; i++ { @@ -327,6 +327,20 @@ func (d *AliDrive) Put(ctx context.Context, dstDir model.Obj, streamer model.Fil return fmt.Errorf("%+v", resp2) } +func (d *AliDrive) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + res, err, _ := d.request("https://api.aliyundrive.com/adrive/v1/user/driveCapacityDetails", http.MethodPost, func(req *resty.Request) { + req.SetContext(ctx) + }, nil) + if err != nil { + return nil, err + } + used := utils.Json.Get(res, "drive_used_size").ToUint64() + total := utils.Json.Get(res, "drive_total_size").ToUint64() + return &model.StorageDetails{ + DiskUsage: driver.DiskUsageFromUsedAndTotal(used, total), + }, nil +} + func (d *AliDrive) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) { var resp base.Json var url string diff --git a/drivers/baidu_netdisk/driver.go b/drivers/baidu_netdisk/driver.go index 331cc216..31f35b95 100644 --- a/drivers/baidu_netdisk/driver.go +++ b/drivers/baidu_netdisk/driver.go @@ -221,7 +221,7 @@ func (d *BaiduNetdisk) Put(ctx context.Context, dstDir model.Obj, stream model.F lastBlockSize = sliceSize } - //cal md5 for first 256k data + // cal md5 for first 256k data const SliceSize int64 = 256 * utils.KB // cal md5 blockList := make([]string, 0, count) @@ -293,7 +293,7 @@ func (d *BaiduNetdisk) Put(ctx context.Context, dstDir model.Obj, stream model.F } log.Debugf("%+v", precreateResp) if precreateResp.ReturnType == 2 { - //rapid upload, since got md5 match from baidu server + // rapid upload, since got md5 match from baidu server // 修复时间,具体原因见 Put 方法注释的 **注意** precreateResp.File.Ctime = ctime precreateResp.File.Mtime = mtime @@ -373,4 +373,12 @@ func (d *BaiduNetdisk) uploadSlice(ctx context.Context, params map[string]string return nil } +func (d *BaiduNetdisk) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + du, err := d.quota(ctx) + if err != nil { + return nil, err + } + return &model.StorageDetails{DiskUsage: du}, nil +} + var _ driver.Driver = (*BaiduNetdisk)(nil) diff --git a/drivers/baidu_netdisk/meta.go b/drivers/baidu_netdisk/meta.go index 8dd78571..87874f8e 100644 --- a/drivers/baidu_netdisk/meta.go +++ b/drivers/baidu_netdisk/meta.go @@ -12,6 +12,8 @@ type Addition struct { OrderBy string `json:"order_by" type:"select" options:"name,time,size" default:"name"` OrderDirection string `json:"order_direction" type:"select" options:"asc,desc" default:"asc"` DownloadAPI string `json:"download_api" type:"select" options:"official,crack,crack_video" default:"official"` + UseOnlineAPI bool `json:"use_online_api" default:"false"` + APIAddress string `json:"api_url_address" default:"https://api.oplist.org/baiduyun/renewapi"` ClientID string `json:"client_id" required:"true" default:"iYCeC9g08h5vuP9UqvPHKKSVrKFXGa1v"` ClientSecret string `json:"client_secret" required:"true" default:"jXiFMOPVPCWlO2M5CwWQzffpNPaGTRBG"` CustomCrackUA string `json:"custom_crack_ua" required:"true" default:"netdisk"` diff --git a/drivers/baidu_netdisk/types.go b/drivers/baidu_netdisk/types.go index ec8ceabc..0e9ee443 100644 --- a/drivers/baidu_netdisk/types.go +++ b/drivers/baidu_netdisk/types.go @@ -189,3 +189,12 @@ type PrecreateResp struct { // return_type=2 File File `json:"info"` } + +type QuotaResp struct { + Errno int `json:"errno"` + RequestId int64 `json:"request_id"` + Total uint64 `json:"total"` + Used uint64 `json:"used"` + //Free uint64 `json:"free"` + //Expire bool `json:"expire"` +} diff --git a/drivers/baidu_netdisk/util.go b/drivers/baidu_netdisk/util.go index 183de141..a7c930b3 100644 --- a/drivers/baidu_netdisk/util.go +++ b/drivers/baidu_netdisk/util.go @@ -1,6 +1,7 @@ package baidu_netdisk import ( + "context" "encoding/hex" "errors" "fmt" @@ -13,6 +14,7 @@ import ( "unicode" "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/driver" "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/op" @@ -36,15 +38,55 @@ func (d *BaiduNetdisk) refreshToken() error { } func (d *BaiduNetdisk) _refreshToken() error { + // 使用在线API刷新Token,无需ClientID和ClientSecret + if d.UseOnlineAPI && len(d.APIAddress) > 0 { + u := d.APIAddress + var resp struct { + RefreshToken string `json:"refresh_token"` + AccessToken string `json:"access_token"` + ErrorMessage string `json:"text"` + } + _, err := base.RestyClient.R(). + SetHeader("User-Agent", "Mozilla/5.0 (Macintosh; Apple macOS 15_5) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/138.0.0.0 Openlist/425.6.30"). + SetResult(&resp). + SetQueryParams(map[string]string{ + "refresh_ui": d.RefreshToken, + "server_use": "true", + "driver_txt": "baiduyun_go", + }). + Get(u) + if err != nil { + return err + } + if resp.RefreshToken == "" || resp.AccessToken == "" { + if resp.ErrorMessage != "" { + return fmt.Errorf("failed to refresh token: %s", resp.ErrorMessage) + } + return fmt.Errorf("empty token returned from official API, a wrong refresh token may have been used") + } + d.AccessToken = resp.AccessToken + d.RefreshToken = resp.RefreshToken + op.MustSaveDriverStorage(d) + return nil + } + // 使用本地客户端的情况下检查是否为空 + if d.ClientID == "" || d.ClientSecret == "" { + return fmt.Errorf("empty ClientID or ClientSecret") + } + // 走原有的刷新逻辑 u := "https://openapi.baidu.com/oauth/2.0/token" var resp base.TokenResp var e TokenErrResp - _, err := base.RestyClient.R().SetResult(&resp).SetError(&e).SetQueryParams(map[string]string{ - "grant_type": "refresh_token", - "refresh_token": d.RefreshToken, - "client_id": d.ClientID, - "client_secret": d.ClientSecret, - }).Get(u) + _, err := base.RestyClient.R(). + SetResult(&resp). + SetError(&e). + SetQueryParams(map[string]string{ + "grant_type": "refresh_token", + "refresh_token": d.RefreshToken, + "client_id": d.ClientID, + "client_secret": d.ClientSecret, + }). + Get(u) if err != nil { return err } @@ -173,7 +215,7 @@ func (d *BaiduNetdisk) linkOfficial(file model.Obj, _ model.LinkArgs) (*model.Li if err != nil { return nil, err } - //if res.StatusCode() == 302 { + // if res.StatusCode() == 302 { u = res.Header().Get("location") //} @@ -351,6 +393,17 @@ func (d *BaiduNetdisk) getSliceSize(filesize int64) int64 { return maxSliceSize } +func (d *BaiduNetdisk) quota(ctx context.Context) (model.DiskUsage, error) { + var resp QuotaResp + _, err := d.request("https://pan.baidu.com/api/quota", http.MethodGet, func(req *resty.Request) { + req.SetContext(ctx) + }, &resp) + if err != nil { + return model.DiskUsage{}, err + } + return driver.DiskUsageFromUsedAndTotal(resp.Used, resp.Total), nil +} + // func encodeURIComponent(str string) string { // r := url.QueryEscape(str) // r = strings.ReplaceAll(r, "+", "%20") diff --git a/drivers/baidu_photo/meta.go b/drivers/baidu_photo/meta.go index 91997c9d..6c6ca5c3 100644 --- a/drivers/baidu_photo/meta.go +++ b/drivers/baidu_photo/meta.go @@ -18,8 +18,9 @@ type Addition struct { } var config = driver.Config{ - Name: "BaiduPhoto", - LocalSort: true, + Name: "BaiduPhoto", + LocalSort: true, + LinkCacheMode: driver.LinkCacheUA, } func init() { diff --git a/drivers/chaoxing/driver.go b/drivers/chaoxing/driver.go index cb12b29f..ac9ff115 100644 --- a/drivers/chaoxing/driver.go +++ b/drivers/chaoxing/driver.go @@ -10,6 +10,7 @@ import ( "mime/multipart" "net/http" "net/url" + "strconv" "strings" "time" @@ -239,7 +240,7 @@ func (d *ChaoXing) Put(ctx context.Context, dstDir model.Obj, file model.FileStr if err != nil { return err } - err = writer.WriteField("puid", fmt.Sprintf("%d", resp.Msg.Puid)) + err = writer.WriteField("puid", strconv.Itoa(resp.Msg.Puid)) if err != nil { fmt.Println("Error writing param2 to request body:", err) return err @@ -260,7 +261,7 @@ func (d *ChaoXing) Put(ctx context.Context, dstDir model.Obj, file model.FileStr return err } req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("Content-Length", fmt.Sprintf("%d", body.Len())) + req.Header.Set("Content-Length", strconv.Itoa(body.Len())) resps, err := http.DefaultClient.Do(req) if err != nil { return err diff --git a/drivers/chaoxing/types.go b/drivers/chaoxing/types.go index 9e6e270d..ca171c4d 100644 --- a/drivers/chaoxing/types.go +++ b/drivers/chaoxing/types.go @@ -258,7 +258,7 @@ type UploadDoneParam struct { func fileToObj(f File) *model.Object { if len(f.Content.FolderName) > 0 { return &model.Object{ - ID: fmt.Sprintf("%d", f.ID), + ID: strconv.Itoa(f.ID), Name: f.Content.FolderName, Size: 0, Modified: time.UnixMilli(f.Inserttime), diff --git a/drivers/chaoxing/util.go b/drivers/chaoxing/util.go index 715c248a..7e3067ce 100644 --- a/drivers/chaoxing/util.go +++ b/drivers/chaoxing/util.go @@ -9,6 +9,7 @@ import ( "fmt" "mime/multipart" "net/http" + "strconv" "strings" "github.com/OpenListTeam/OpenList/v4/drivers/base" @@ -172,7 +173,7 @@ func (d *ChaoXing) Login() (string, error) { return "", err } req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("Content-Length", fmt.Sprintf("%d", body.Len())) + req.Header.Set("Content-Length", strconv.Itoa(body.Len())) resp, err := http.DefaultClient.Do(req) if err != nil { return "", err diff --git a/drivers/chunk/driver.go b/drivers/chunk/driver.go new file mode 100644 index 00000000..572f9936 --- /dev/null +++ b/drivers/chunk/driver.go @@ -0,0 +1,501 @@ +package chunk + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + stdpath "path" + "strconv" + "strings" + + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/fs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/internal/sign" + "github.com/OpenListTeam/OpenList/v4/internal/stream" + "github.com/OpenListTeam/OpenList/v4/pkg/errgroup" + "github.com/OpenListTeam/OpenList/v4/pkg/http_range" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "github.com/OpenListTeam/OpenList/v4/server/common" + "github.com/avast/retry-go" +) + +type Chunk struct { + model.Storage + Addition +} + +func (d *Chunk) Config() driver.Config { + return config +} + +func (d *Chunk) GetAddition() driver.Additional { + return &d.Addition +} + +func (d *Chunk) Init(ctx context.Context) error { + if d.PartSize <= 0 { + return errors.New("part size must be positive") + } + if len(d.ChunkPrefix) <= 0 { + return errors.New("chunk folder prefix must not be empty") + } + d.RemotePath = utils.FixAndCleanPath(d.RemotePath) + return nil +} + +func (d *Chunk) Drop(ctx context.Context) error { + return nil +} + +func (d *Chunk) Get(ctx context.Context, path string) (model.Obj, error) { + if utils.PathEqual(path, "/") { + return &model.Object{ + Name: "Root", + IsFolder: true, + Path: "/", + }, nil + } + remoteStorage, remoteActualPath, err := op.GetStorageAndActualPath(d.RemotePath) + if err != nil { + return nil, err + } + remoteActualPath = stdpath.Join(remoteActualPath, path) + if remoteObj, err := op.Get(ctx, remoteStorage, remoteActualPath); err == nil { + return &model.Object{ + Path: path, + Name: remoteObj.GetName(), + Size: remoteObj.GetSize(), + Modified: remoteObj.ModTime(), + IsFolder: remoteObj.IsDir(), + HashInfo: remoteObj.GetHash(), + }, nil + } + + remoteActualDir, name := stdpath.Split(remoteActualPath) + chunkName := d.ChunkPrefix + name + chunkObjs, err := op.List(ctx, remoteStorage, stdpath.Join(remoteActualDir, chunkName), model.ListArgs{}) + if err != nil { + return nil, err + } + var totalSize int64 = 0 + // 0号块默认为-1 以支持空文件 + chunkSizes := []int64{-1} + h := make(map[*utils.HashType]string) + var first model.Obj + for _, o := range chunkObjs { + if o.IsDir() { + continue + } + if after, ok := strings.CutPrefix(o.GetName(), "hash_"); ok { + hn, value, ok := strings.Cut(strings.TrimSuffix(after, d.CustomExt), "_") + if ok { + ht, ok := utils.GetHashByName(hn) + if ok { + h[ht] = value + } + } + continue + } + idx, err := strconv.Atoi(strings.TrimSuffix(o.GetName(), d.CustomExt)) + if err != nil { + continue + } + totalSize += o.GetSize() + if len(chunkSizes) > idx { + if idx == 0 { + first = o + } + chunkSizes[idx] = o.GetSize() + } else if len(chunkSizes) == idx { + chunkSizes = append(chunkSizes, o.GetSize()) + } else { + newChunkSizes := make([]int64, idx+1) + copy(newChunkSizes, chunkSizes) + chunkSizes = newChunkSizes + chunkSizes[idx] = o.GetSize() + } + } + reqDir, _ := stdpath.Split(path) + objRes := chunkObject{ + Object: model.Object{ + Path: stdpath.Join(reqDir, chunkName), + Name: name, + Size: totalSize, + Modified: first.ModTime(), + Ctime: first.CreateTime(), + }, + chunkSizes: chunkSizes, + } + if len(h) > 0 { + objRes.HashInfo = utils.NewHashInfoByMap(h) + } + return &objRes, nil +} + +func (d *Chunk) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + remoteStorage, remoteActualPath, err := op.GetStorageAndActualPath(d.RemotePath) + if err != nil { + return nil, err + } + remoteActualDir := stdpath.Join(remoteActualPath, dir.GetPath()) + remoteObjs, err := op.List(ctx, remoteStorage, remoteActualDir, model.ListArgs{ + ReqPath: args.ReqPath, + Refresh: args.Refresh, + }) + if err != nil { + return nil, err + } + result := make([]model.Obj, 0, len(remoteObjs)) + listG, listCtx := errgroup.NewGroupWithContext(ctx, d.NumListWorkers, retry.Attempts(3)) + for _, obj := range remoteObjs { + if utils.IsCanceled(listCtx) { + break + } + rawName := obj.GetName() + if obj.IsDir() { + if name, ok := strings.CutPrefix(rawName, d.ChunkPrefix); ok { + resultIdx := len(result) + result = append(result, nil) + listG.Go(func(ctx context.Context) error { + chunkObjs, err := op.List(ctx, remoteStorage, stdpath.Join(remoteActualDir, rawName), model.ListArgs{ + ReqPath: stdpath.Join(args.ReqPath, rawName), + Refresh: args.Refresh, + }) + if err != nil { + return err + } + totalSize := int64(0) + h := make(map[*utils.HashType]string) + first := obj + for _, o := range chunkObjs { + if o.IsDir() { + continue + } + if after, ok := strings.CutPrefix(strings.TrimSuffix(o.GetName(), d.CustomExt), "hash_"); ok { + hn, value, ok := strings.Cut(after, "_") + if ok { + ht, ok := utils.GetHashByName(hn) + if ok { + h[ht] = value + } + continue + } + } + idx, err := strconv.Atoi(strings.TrimSuffix(o.GetName(), d.CustomExt)) + if err != nil { + continue + } + if idx == 0 { + first = o + } + totalSize += o.GetSize() + } + objRes := model.Object{ + Name: name, + Size: totalSize, + Modified: first.ModTime(), + Ctime: first.CreateTime(), + } + if len(h) > 0 { + objRes.HashInfo = utils.NewHashInfoByMap(h) + } + if !d.Thumbnail { + result[resultIdx] = &objRes + } else { + thumbPath := stdpath.Join(args.ReqPath, ".thumbnails", name+".webp") + thumb := fmt.Sprintf("%s/d%s?sign=%s", + common.GetApiUrl(ctx), + utils.EncodePath(thumbPath, true), + sign.Sign(thumbPath)) + result[resultIdx] = &model.ObjThumb{ + Object: objRes, + Thumbnail: model.Thumbnail{ + Thumbnail: thumb, + }, + } + } + return nil + }) + continue + } + } + + if !d.ShowHidden && strings.HasPrefix(rawName, ".") { + continue + } + thumb, ok := model.GetThumb(obj) + objRes := model.Object{ + Name: rawName, + Size: obj.GetSize(), + Modified: obj.ModTime(), + IsFolder: obj.IsDir(), + HashInfo: obj.GetHash(), + } + if !ok { + result = append(result, &objRes) + } else { + result = append(result, &model.ObjThumb{ + Object: objRes, + Thumbnail: model.Thumbnail{ + Thumbnail: thumb, + }, + }) + } + } + if err = listG.Wait(); err != nil { + return nil, err + } + return result, nil +} + +func (d *Chunk) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + remoteStorage, remoteActualPath, err := op.GetStorageAndActualPath(d.RemotePath) + if err != nil { + return nil, err + } + chunkFile, ok := file.(*chunkObject) + remoteActualPath = stdpath.Join(remoteActualPath, file.GetPath()) + if !ok { + l, _, err := op.Link(ctx, remoteStorage, remoteActualPath, args) + if err != nil { + return nil, err + } + resultLink := *l + resultLink.SyncClosers = utils.NewSyncClosers(l) + return &resultLink, nil + } + // 检查0号块不等于-1 以支持空文件 + // 如果块数量大于1 最后一块不可能为0 + // 只检查中间块是否有0 + for i, l := 0, len(chunkFile.chunkSizes)-2; ; i++ { + if i == 0 { + if chunkFile.chunkSizes[i] == -1 { + return nil, fmt.Errorf("chunk part[%d] are missing", i) + } + } else if chunkFile.chunkSizes[i] == 0 { + return nil, fmt.Errorf("chunk part[%d] are missing", i) + } + if i >= l { + break + } + } + fileSize := chunkFile.GetSize() + mergedRrf := func(ctx context.Context, httpRange http_range.Range) (io.ReadCloser, error) { + start := httpRange.Start + length := httpRange.Length + if length < 0 || start+length > fileSize { + length = fileSize - start + } + if length == 0 { + return io.NopCloser(strings.NewReader("")), nil + } + rs := make([]io.Reader, 0) + cs := make(utils.Closers, 0) + var ( + rc io.ReadCloser + readFrom bool + ) + for idx, chunkSize := range chunkFile.chunkSizes { + if readFrom { + l, o, err := op.Link(ctx, remoteStorage, stdpath.Join(remoteActualPath, d.getPartName(idx)), args) + if err != nil { + _ = cs.Close() + return nil, err + } + cs = append(cs, l) + chunkSize2 := l.ContentLength + if chunkSize2 <= 0 { + chunkSize2 = o.GetSize() + } + if chunkSize2 != chunkSize { + _ = cs.Close() + return nil, fmt.Errorf("chunk part[%d] size not match", idx) + } + rrf, err := stream.GetRangeReaderFromLink(chunkSize2, l) + if err != nil { + _ = cs.Close() + return nil, err + } + newLength := length - chunkSize2 + if newLength >= 0 { + length = newLength + rc, err = rrf.RangeRead(ctx, http_range.Range{Length: -1}) + } else { + rc, err = rrf.RangeRead(ctx, http_range.Range{Length: length}) + } + if err != nil { + _ = cs.Close() + return nil, err + } + rs = append(rs, rc) + cs = append(cs, rc) + if newLength <= 0 { + return utils.ReadCloser{ + Reader: io.MultiReader(rs...), + Closer: &cs, + }, nil + } + } else if newStart := start - chunkSize; newStart >= 0 { + start = newStart + } else { + l, o, err := op.Link(ctx, remoteStorage, stdpath.Join(remoteActualPath, d.getPartName(idx)), args) + if err != nil { + _ = cs.Close() + return nil, err + } + cs = append(cs, l) + chunkSize2 := l.ContentLength + if chunkSize2 <= 0 { + chunkSize2 = o.GetSize() + } + if chunkSize2 != chunkSize { + _ = cs.Close() + return nil, fmt.Errorf("chunk part[%d] size not match", idx) + } + rrf, err := stream.GetRangeReaderFromLink(chunkSize2, l) + if err != nil { + _ = cs.Close() + return nil, err + } + rc, err = rrf.RangeRead(ctx, http_range.Range{Start: start, Length: -1}) + if err != nil { + _ = cs.Close() + return nil, err + } + length -= chunkSize2 - start + cs = append(cs, rc) + if length <= 0 { + return utils.ReadCloser{ + Reader: rc, + Closer: &cs, + }, nil + } + rs = append(rs, rc) + readFrom = true + } + } + return nil, fmt.Errorf("invalid range: start=%d,length=%d,fileSize=%d", httpRange.Start, httpRange.Length, fileSize) + } + return &model.Link{ + RangeReader: stream.RangeReaderFunc(mergedRrf), + }, nil +} + +func (d *Chunk) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error { + path := stdpath.Join(d.RemotePath, parentDir.GetPath(), dirName) + return fs.MakeDir(ctx, path) +} + +func (d *Chunk) Move(ctx context.Context, srcObj, dstDir model.Obj) error { + src := stdpath.Join(d.RemotePath, srcObj.GetPath()) + dst := stdpath.Join(d.RemotePath, dstDir.GetPath()) + _, err := fs.Move(ctx, src, dst) + return err +} + +func (d *Chunk) Rename(ctx context.Context, srcObj model.Obj, newName string) error { + if _, ok := srcObj.(*chunkObject); ok { + newName = d.ChunkPrefix + newName + } + return fs.Rename(ctx, stdpath.Join(d.RemotePath, srcObj.GetPath()), newName) +} + +func (d *Chunk) Copy(ctx context.Context, srcObj, dstDir model.Obj) error { + dst := stdpath.Join(d.RemotePath, dstDir.GetPath()) + src := stdpath.Join(d.RemotePath, srcObj.GetPath()) + _, err := fs.Copy(ctx, src, dst) + return err +} + +func (d *Chunk) Remove(ctx context.Context, obj model.Obj) error { + return fs.Remove(ctx, stdpath.Join(d.RemotePath, obj.GetPath())) +} + +func (d *Chunk) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) error { + remoteStorage, remoteActualPath, err := op.GetStorageAndActualPath(d.RemotePath) + if err != nil { + return err + } + if (d.Thumbnail && dstDir.GetName() == ".thumbnails") || (d.ChunkLargeFileOnly && file.GetSize() <= d.PartSize) { + return op.Put(ctx, remoteStorage, stdpath.Join(remoteActualPath, dstDir.GetPath()), file, up) + } + upReader := &driver.ReaderUpdatingProgress{ + Reader: file, + UpdateProgress: up, + } + dst := stdpath.Join(remoteActualPath, dstDir.GetPath(), d.ChunkPrefix+file.GetName()) + if d.StoreHash { + for ht, value := range file.GetHash().All() { + _ = op.Put(ctx, remoteStorage, dst, &stream.FileStream{ + Obj: &model.Object{ + Name: fmt.Sprintf("hash_%s_%s%s", ht.Name, value, d.CustomExt), + Size: 1, + Modified: file.ModTime(), + }, + Mimetype: "application/octet-stream", + Reader: bytes.NewReader([]byte{0}), // 兼容不支持空文件的驱动 + }, nil, true) + } + } + fullPartCount := int(file.GetSize() / d.PartSize) + tailSize := file.GetSize() % d.PartSize + if tailSize == 0 && fullPartCount > 0 { + fullPartCount-- + tailSize = d.PartSize + } + partIndex := 0 + for partIndex < fullPartCount { + err = op.Put(ctx, remoteStorage, dst, &stream.FileStream{ + Obj: &model.Object{ + Name: d.getPartName(partIndex), + Size: d.PartSize, + Modified: file.ModTime(), + }, + Mimetype: file.GetMimetype(), + Reader: io.LimitReader(upReader, d.PartSize), + }, nil, true) + if err != nil { + _ = op.Remove(ctx, remoteStorage, dst) + return err + } + partIndex++ + } + err = op.Put(ctx, remoteStorage, dst, &stream.FileStream{ + Obj: &model.Object{ + Name: d.getPartName(fullPartCount), + Size: tailSize, + Modified: file.ModTime(), + }, + Mimetype: file.GetMimetype(), + Reader: upReader, + }, nil) + if err != nil { + _ = op.Remove(ctx, remoteStorage, dst) + } + return err +} + +func (d *Chunk) getPartName(part int) string { + return fmt.Sprintf("%d%s", part, d.CustomExt) +} + +func (d *Chunk) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + remoteStorage, err := fs.GetStorage(d.RemotePath, &fs.GetStoragesArgs{}) + if err != nil { + return nil, errs.NotImplement + } + remoteDetails, err := op.GetStorageDetails(ctx, remoteStorage) + if err != nil { + return nil, err + } + return &model.StorageDetails{ + DiskUsage: remoteDetails.DiskUsage, + }, nil +} + +var _ driver.Driver = (*Chunk)(nil) diff --git a/drivers/chunk/meta.go b/drivers/chunk/meta.go new file mode 100644 index 00000000..43e24c89 --- /dev/null +++ b/drivers/chunk/meta.go @@ -0,0 +1,39 @@ +package chunk + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type Addition struct { + RemotePath string `json:"remote_path" required:"true"` + PartSize int64 `json:"part_size" required:"true" type:"number" help:"bytes"` + ChunkLargeFileOnly bool `json:"chunk_large_file_only" default:"false" help:"chunk only if file size > part_size"` + ChunkPrefix string `json:"chunk_prefix" type:"string" default:"[openlist_chunk]" help:"the prefix of chunk folder"` + CustomExt string `json:"custom_ext" type:"string"` + StoreHash bool `json:"store_hash" type:"bool" default:"true"` + NumListWorkers int `json:"num_list_workers" required:"true" type:"number" default:"5"` + + Thumbnail bool `json:"thumbnail" required:"true" default:"false" help:"enable thumbnail which pre-generated under .thumbnails folder"` + ShowHidden bool `json:"show_hidden" default:"true" required:"false" help:"show hidden directories and files"` +} + +var config = driver.Config{ + Name: "Chunk", + LocalSort: true, + OnlyProxy: true, + NoCache: true, + DefaultRoot: "/", + NoLinkURL: true, +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &Chunk{ + Addition: Addition{ + ChunkPrefix: "[openlist_chunk]", + NumListWorkers: 5, + }, + } + }) +} diff --git a/drivers/chunk/obj.go b/drivers/chunk/obj.go new file mode 100644 index 00000000..1885a925 --- /dev/null +++ b/drivers/chunk/obj.go @@ -0,0 +1,8 @@ +package chunk + +import "github.com/OpenListTeam/OpenList/v4/internal/model" + +type chunkObject struct { + model.Object + chunkSizes []int64 +} diff --git a/drivers/cloudreve_v4/driver.go b/drivers/cloudreve_v4/driver.go index 11a7e92d..c16309e4 100644 --- a/drivers/cloudreve_v4/driver.go +++ b/drivers/cloudreve_v4/driver.go @@ -20,7 +20,9 @@ import ( type CloudreveV4 struct { model.Storage Addition - ref *CloudreveV4 + ref *CloudreveV4 + AccessExpires string + RefreshExpires string } func (d *CloudreveV4) Config() driver.Config { @@ -44,13 +46,17 @@ func (d *CloudreveV4) Init(ctx context.Context) error { if d.ref != nil { return nil } - if d.AccessToken == "" && d.RefreshToken != "" { + if d.canLogin() { + return d.login() + } + if d.RefreshToken != "" { return d.refreshToken() } - if d.Username != "" { - return d.login() + if d.AccessToken == "" { + return errors.New("no way to authenticate. At least AccessToken is required") } - return nil + // ensure AccessToken is valid + return d.parseJWT(d.AccessToken, &AccessJWT{}) } func (d *CloudreveV4) InitReference(storage driver.Driver) error { @@ -333,6 +339,20 @@ func (d *CloudreveV4) ArchiveDecompress(ctx context.Context, srcObj, dstDir mode return nil, errs.NotImplement } +func (d *CloudreveV4) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + // TODO return storage details (total space, free space, etc.) + var r CapacityResp + err := d.request(http.MethodGet, "/user/capacity", func(req *resty.Request) { + req.SetContext(ctx) + }, &r) + if err != nil { + return nil, err + } + return &model.StorageDetails{ + DiskUsage: driver.DiskUsageFromUsedAndTotal(r.Used, r.Total), + }, nil +} + //func (d *CloudreveV4) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) { // return nil, errs.NotSupport //} diff --git a/drivers/cloudreve_v4/types.go b/drivers/cloudreve_v4/types.go index 455f0a95..a10f9fe1 100644 --- a/drivers/cloudreve_v4/types.go +++ b/drivers/cloudreve_v4/types.go @@ -66,11 +66,27 @@ type CaptchaResp struct { Ticket string `json:"ticket"` } +type AccessJWT struct { + TokenType string `json:"token_type"` + Sub string `json:"sub"` + Exp int64 `json:"exp"` + Nbf int64 `json:"nbf"` +} + +type RefreshJWT struct { + TokenType string `json:"token_type"` + Sub string `json:"sub"` + Exp int `json:"exp"` + Nbf int `json:"nbf"` + StateHash string `json:"state_hash"` + RootTokenID string `json:"root_token_id"` +} + type Token struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - AccessExpires time.Time `json:"access_expires"` - RefreshExpires time.Time `json:"refresh_expires"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + AccessExpires string `json:"access_expires"` + RefreshExpires string `json:"refresh_expires"` } type TokenResponse struct { @@ -188,3 +204,9 @@ type FolderSummaryResp struct { CalculatedAt time.Time `json:"calculated_at"` } `json:"folder_summary"` } + +type CapacityResp struct { + Total uint64 `json:"total"` + Used uint64 `json:"used"` + // StoragePackTotal uint64 `json:"storage_pack_total"` +} diff --git a/drivers/cloudreve_v4/util.go b/drivers/cloudreve_v4/util.go index fc03ee7a..200dfbb6 100644 --- a/drivers/cloudreve_v4/util.go +++ b/drivers/cloudreve_v4/util.go @@ -28,6 +28,15 @@ import ( // do others that not defined in Driver interface +const ( + CodeLoginRequired = http.StatusUnauthorized + CodeCredentialInvalid = 40020 // Failed to issue token +) + +var ( + ErrorIssueToken = errors.New("failed to issue token") +) + func (d *CloudreveV4) getUA() string { if d.CustomUA != "" { return d.CustomUA @@ -39,6 +48,23 @@ func (d *CloudreveV4) request(method string, path string, callback base.ReqCallb if d.ref != nil { return d.ref.request(method, path, callback, out) } + + // ensure token + if d.isTokenExpired() { + err := d.refreshToken() + if err != nil { + return err + } + } + + return d._request(method, path, callback, out) +} + +func (d *CloudreveV4) _request(method string, path string, callback base.ReqCallback, out any) error { + if d.ref != nil { + return d.ref._request(method, path, callback, out) + } + u := d.Address + "/api/v4" + path req := base.RestyClient.R() req.SetHeaders(map[string]string{ @@ -65,15 +91,17 @@ func (d *CloudreveV4) request(method string, path string, callback base.ReqCallb } if r.Code != 0 { - if r.Code == 401 && d.RefreshToken != "" && path != "/session/token/refresh" { - // try to refresh token - err = d.refreshToken() + if r.Code == CodeLoginRequired && d.canLogin() && path != "/session/token/refresh" { + err = d.login() if err != nil { return err } return d.request(method, path, callback, out) } - return errors.New(r.Msg) + if r.Code == CodeCredentialInvalid { + return ErrorIssueToken + } + return fmt.Errorf("%d: %s", r.Code, r.Msg) } if out != nil && r.Data != nil { @@ -91,14 +119,18 @@ func (d *CloudreveV4) request(method string, path string, callback base.ReqCallb return nil } +func (d *CloudreveV4) canLogin() bool { + return d.Username != "" && d.Password != "" +} + func (d *CloudreveV4) login() error { var siteConfig SiteLoginConfigResp - err := d.request(http.MethodGet, "/site/config/login", nil, &siteConfig) + err := d._request(http.MethodGet, "/site/config/login", nil, &siteConfig) if err != nil { return err } var prepareLogin PrepareLoginResp - err = d.request(http.MethodGet, "/session/prepare?email="+d.Addition.Username, nil, &prepareLogin) + err = d._request(http.MethodGet, "/session/prepare?email="+d.Addition.Username, nil, &prepareLogin) if err != nil { return err } @@ -128,7 +160,7 @@ func (d *CloudreveV4) doLogin(needCaptcha bool) error { } if needCaptcha { var config BasicConfigResp - err = d.request(http.MethodGet, "/site/config/basic", nil, &config) + err = d._request(http.MethodGet, "/site/config/basic", nil, &config) if err != nil { return err } @@ -136,7 +168,7 @@ func (d *CloudreveV4) doLogin(needCaptcha bool) error { return fmt.Errorf("captcha type %s not support", config.CaptchaType) } var captcha CaptchaResp - err = d.request(http.MethodGet, "/site/captcha", nil, &captcha) + err = d._request(http.MethodGet, "/site/captcha", nil, &captcha) if err != nil { return err } @@ -162,20 +194,22 @@ func (d *CloudreveV4) doLogin(needCaptcha bool) error { loginBody["captcha"] = captchaCode } var token TokenResponse - err = d.request(http.MethodPost, "/session/token", func(req *resty.Request) { + err = d._request(http.MethodPost, "/session/token", func(req *resty.Request) { req.SetBody(loginBody) }, &token) if err != nil { return err } d.AccessToken, d.RefreshToken = token.Token.AccessToken, token.Token.RefreshToken + d.AccessExpires, d.RefreshExpires = token.Token.AccessExpires, token.Token.RefreshExpires op.MustSaveDriverStorage(d) return nil } func (d *CloudreveV4) refreshToken() error { + // if no refresh token, try to login if possible if d.RefreshToken == "" { - if d.Username != "" { + if d.canLogin() { err := d.login() if err != nil { return fmt.Errorf("cannot login to get refresh token, error: %s", err) @@ -183,20 +217,127 @@ func (d *CloudreveV4) refreshToken() error { } return nil } + + // parse jwt to check if refresh token is valid + var jwt RefreshJWT + err := d.parseJWT(d.RefreshToken, &jwt) + if err != nil { + // if refresh token is invalid, try to login if possible + if d.canLogin() { + return d.login() + } + d.GetStorage().SetStatus(fmt.Sprintf("Invalid RefreshToken: %s", err.Error())) + op.MustSaveDriverStorage(d) + return fmt.Errorf("invalid refresh token: %w", err) + } + + // do refresh token var token Token - err := d.request(http.MethodPost, "/session/token/refresh", func(req *resty.Request) { + err = d._request(http.MethodPost, "/session/token/refresh", func(req *resty.Request) { req.SetBody(base.Json{ "refresh_token": d.RefreshToken, }) }, &token) if err != nil { + if errors.Is(err, ErrorIssueToken) { + if d.canLogin() { + // try to login again + return d.login() + } + d.GetStorage().SetStatus("This session is no longer valid") + op.MustSaveDriverStorage(d) + return ErrorIssueToken + } return err } d.AccessToken, d.RefreshToken = token.AccessToken, token.RefreshToken + d.AccessExpires, d.RefreshExpires = token.AccessExpires, token.RefreshExpires op.MustSaveDriverStorage(d) return nil } +func (d *CloudreveV4) parseJWT(token string, jwt any) error { + split := strings.Split(token, ".") + if len(split) != 3 { + return fmt.Errorf("invalid token length: %d, ensure the token is a valid JWT", len(split)) + } + data, err := base64.RawURLEncoding.DecodeString(split[1]) + if err != nil { + return fmt.Errorf("invalid token encoding: %w, ensure the token is a valid JWT", err) + } + err = json.Unmarshal(data, &jwt) + if err != nil { + return fmt.Errorf("invalid token content: %w, ensure the token is a valid JWT", err) + } + return nil +} + +// check if token is expired +// https://github.com/cloudreve/frontend/blob/ddfacc1c31c49be03beb71de4cc114c8811038d6/src/session/index.ts#L177-L200 +func (d *CloudreveV4) isTokenExpired() bool { + if d.RefreshToken == "" { + // login again if username and password is set + if d.canLogin() { + return true + } + // no refresh token, cannot refresh + return false + } + if d.AccessToken == "" { + return true + } + var ( + err error + expires time.Time + ) + // check if token is expired + if d.AccessExpires != "" { + // use expires field if possible to prevent timezone issue + // only available after login or refresh token + // 2025-08-28T02:43:07.645109985+08:00 + expires, err = time.Parse(time.RFC3339Nano, d.AccessExpires) + if err != nil { + return false + } + } else { + // fallback to parse jwt + // if failed, disable the storage + var jwt AccessJWT + err = d.parseJWT(d.AccessToken, &jwt) + if err != nil { + d.GetStorage().SetStatus(fmt.Sprintf("Invalid AccessToken: %s", err.Error())) + op.MustSaveDriverStorage(d) + return false + } + // may be have timezone issue + expires = time.Unix(jwt.Exp, 0) + } + // add a 10 minutes safe margin + ddl := time.Now().Add(10 * time.Minute) + if expires.Before(ddl) { + // current access token expired, check if refresh token is expired + // warning: cannot parse refresh token from jwt, because the exp field is not standard + if d.RefreshExpires != "" { + refreshExpires, err := time.Parse(time.RFC3339Nano, d.RefreshExpires) + if err != nil { + return false + } + if refreshExpires.Before(time.Now()) { + // This session is no longer valid + if d.canLogin() { + // try to login again + return true + } + d.GetStorage().SetStatus("This session is no longer valid") + op.MustSaveDriverStorage(d) + return false + } + } + return true + } + return false +} + func (d *CloudreveV4) upLocal(ctx context.Context, file model.FileStreamer, u FileUploadResp, up driver.UpdateProgress) error { var finish int64 = 0 var chunk int = 0 diff --git a/drivers/cnb_releases/driver.go b/drivers/cnb_releases/driver.go new file mode 100644 index 00000000..d80e6958 --- /dev/null +++ b/drivers/cnb_releases/driver.go @@ -0,0 +1,230 @@ +package cnb_releases + +import ( + "bytes" + "context" + "fmt" + "io" + "mime/multipart" + "net/http" + "time" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "github.com/go-resty/resty/v2" +) + +type CnbReleases struct { + model.Storage + Addition + ref *CnbReleases +} + +func (d *CnbReleases) Config() driver.Config { + return config +} + +func (d *CnbReleases) GetAddition() driver.Additional { + return &d.Addition +} + +func (d *CnbReleases) Init(ctx context.Context) error { + return nil +} + +func (d *CnbReleases) InitReference(storage driver.Driver) error { + refStorage, ok := storage.(*CnbReleases) + if ok { + d.ref = refStorage + return nil + } + return fmt.Errorf("ref: storage is not CnbReleases") +} + +func (d *CnbReleases) Drop(ctx context.Context) error { + d.ref = nil + return nil +} + +func (d *CnbReleases) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + if dir.GetPath() == "/" { + // get all releases for root dir + var resp ReleaseList + + err := d.Request(http.MethodGet, "/{repo}/-/releases", func(req *resty.Request) { + req.SetPathParam("repo", d.Repo) + }, &resp) + if err != nil { + return nil, err + } + + return utils.SliceConvert(resp, func(src Release) (model.Obj, error) { + name := src.Name + if d.UseTagName { + name = src.TagName + } + return &model.Object{ + ID: src.ID, + Name: name, + Size: d.sumAssetsSize(src.Assets), + Ctime: src.CreatedAt, + Modified: src.UpdatedAt, + IsFolder: true, + }, nil + }) + } else { + // get release info by release id + releaseID := dir.GetID() + if releaseID == "" { + return nil, errs.ObjectNotFound + } + var resp Release + err := d.Request(http.MethodGet, "/{repo}/-/releases/{release_id}", func(req *resty.Request) { + req.SetPathParam("repo", d.Repo) + req.SetPathParam("release_id", releaseID) + }, &resp) + if err != nil { + return nil, err + } + + return utils.SliceConvert(resp.Assets, func(src ReleaseAsset) (model.Obj, error) { + return &Object{ + Object: model.Object{ + ID: src.ID, + Path: src.Path, + Name: src.Name, + Size: src.Size, + Ctime: src.CreatedAt, + Modified: src.UpdatedAt, + IsFolder: false, + }, + ParentID: dir.GetID(), + }, nil + }) + } +} + +func (d *CnbReleases) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + return &model.Link{ + URL: "https://cnb.cool" + file.GetPath(), + }, nil +} + +func (d *CnbReleases) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error { + if parentDir.GetPath() == "/" { + // create a new release + branch := d.DefaultBranch + if branch == "" { + branch = "main" // fallback to "main" if not set + } + return d.Request(http.MethodPost, "/{repo}/-/releases", func(req *resty.Request) { + req.SetPathParam("repo", d.Repo) + req.SetBody(base.Json{ + "name": dirName, + "tag_name": dirName, + "target_commitish": branch, + }) + }, nil) + } + return errs.NotImplement +} + +func (d *CnbReleases) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + return nil, errs.NotImplement +} + +func (d *CnbReleases) Rename(ctx context.Context, srcObj model.Obj, newName string) error { + if srcObj.IsDir() && !d.UseTagName { + return d.Request(http.MethodPatch, "/{repo}/-/releases/{release_id}", func(req *resty.Request) { + req.SetPathParam("repo", d.Repo) + req.SetPathParam("release_id", srcObj.GetID()) + req.SetFormData(map[string]string{ + "name": newName, + }) + }, nil) + } + return errs.NotImplement +} + +func (d *CnbReleases) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + return nil, errs.NotImplement +} + +func (d *CnbReleases) Remove(ctx context.Context, obj model.Obj) error { + if obj.IsDir() { + return d.Request(http.MethodDelete, "/{repo}/-/releases/{release_id}", func(req *resty.Request) { + req.SetPathParam("repo", d.Repo) + req.SetPathParam("release_id", obj.GetID()) + }, nil) + } + if o, ok := obj.(*Object); ok { + return d.Request(http.MethodDelete, "/{repo}/-/releases/{release_id}/assets/{asset_id}", func(req *resty.Request) { + req.SetPathParam("repo", d.Repo) + req.SetPathParam("release_id", o.ParentID) + req.SetPathParam("asset_id", obj.GetID()) + }, nil) + } else { + return fmt.Errorf("unable to get release ID") + } +} + +func (d *CnbReleases) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) error { + // 1. get upload info + var resp ReleaseAssetUploadURL + err := d.Request(http.MethodPost, "/{repo}/-/releases/{release_id}/asset-upload-url", func(req *resty.Request) { + req.SetPathParam("repo", d.Repo) + req.SetPathParam("release_id", dstDir.GetID()) + req.SetBody(base.Json{ + "asset_name": file.GetName(), + "overwrite": true, + "size": file.GetSize(), + }) + }, &resp) + if err != nil { + return err + } + + // 2. upload file + // use multipart to create form file + var b bytes.Buffer + w := multipart.NewWriter(&b) + _, err = w.CreateFormFile("file", file.GetName()) + if err != nil { + return err + } + headSize := b.Len() + err = w.Close() + if err != nil { + return err + } + + head := bytes.NewReader(b.Bytes()[:headSize]) + tail := bytes.NewReader(b.Bytes()[headSize:]) + rateLimitedRd := driver.NewLimitedUploadStream(ctx, io.MultiReader(head, file, tail)) + + // use net/http to upload file + ctxWithTimeout, cancel := context.WithTimeout(ctx, time.Duration(resp.ExpiresInSec+1)*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctxWithTimeout, http.MethodPost, resp.UploadURL, rateLimitedRd) + if err != nil { + return err + } + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("User-Agent", base.UserAgent) + httpResp, err := base.HttpClient.Do(req) + if err != nil { + return err + } + defer httpResp.Body.Close() + if httpResp.StatusCode != http.StatusNoContent { + return fmt.Errorf("upload file failed: %s", httpResp.Status) + } + + // 3. verify upload + return d.Request(http.MethodPost, resp.VerifyURL, nil, nil) +} + +var _ driver.Driver = (*CnbReleases)(nil) diff --git a/drivers/cnb_releases/meta.go b/drivers/cnb_releases/meta.go new file mode 100644 index 00000000..2894d8a2 --- /dev/null +++ b/drivers/cnb_releases/meta.go @@ -0,0 +1,26 @@ +package cnb_releases + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type Addition struct { + driver.RootPath + Repo string `json:"repo" type:"string" required:"true"` + Token string `json:"token" type:"string" required:"true"` + UseTagName bool `json:"use_tag_name" type:"bool" default:"false" help:"Use tag name instead of release name"` + DefaultBranch string `json:"default_branch" type:"string" default:"main" help:"Default branch for new releases"` +} + +var config = driver.Config{ + Name: "CNB Releases", + LocalSort: true, + DefaultRoot: "/", +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &CnbReleases{} + }) +} diff --git a/drivers/cnb_releases/types.go b/drivers/cnb_releases/types.go new file mode 100644 index 00000000..a89ddbf6 --- /dev/null +++ b/drivers/cnb_releases/types.go @@ -0,0 +1,100 @@ +package cnb_releases + +import ( + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +type Object struct { + model.Object + ParentID string +} + +type TagList []Tag + +type Tag struct { + Commit struct { + Author UserInfo `json:"author"` + Commit CommitObject `json:"commit"` + Committer UserInfo `json:"committer"` + Parents []CommitParent `json:"parents"` + Sha string `json:"sha"` + } `json:"commit"` + Name string `json:"name"` + Target string `json:"target"` + TargetType string `json:"target_type"` + Verification TagObjectVerification `json:"verification"` +} + +type UserInfo struct { + Freeze bool `json:"freeze"` + Nickname string `json:"nickname"` + Username string `json:"username"` +} + +type CommitObject struct { + Author Signature `json:"author"` + CommentCount int `json:"comment_count"` + Committer Signature `json:"committer"` + Message string `json:"message"` + Tree CommitObjectTree `json:"tree"` + Verification CommitObjectVerification `json:"verification"` +} + +type Signature struct { + Date time.Time `json:"date"` + Email string `json:"email"` + Name string `json:"name"` +} + +type CommitObjectTree struct { + Sha string `json:"sha"` +} + +type CommitObjectVerification struct { + Payload string `json:"payload"` + Reason string `json:"reason"` + Signature string `json:"signature"` + Verified bool `json:"verified"` + VerifiedAt string `json:"verified_at"` +} + +type CommitParent = CommitObjectTree + +type TagObjectVerification = CommitObjectVerification + +type ReleaseList []Release + +type Release struct { + Assets []ReleaseAsset `json:"assets"` + Author UserInfo `json:"author"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` + Draft bool `json:"draft"` + ID string `json:"id"` + IsLatest bool `json:"is_latest"` + Name string `json:"name"` + Prerelease bool `json:"prerelease"` + PublishedAt time.Time `json:"published_at"` + TagCommitish string `json:"tag_commitish"` + TagName string `json:"tag_name"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ReleaseAsset struct { + ContentType string `json:"content_type"` + CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + UpdatedAt time.Time `json:"updated_at"` + Uploader UserInfo `json:"uploader"` +} + +type ReleaseAssetUploadURL struct { + UploadURL string `json:"upload_url"` + ExpiresInSec int `json:"expires_in_sec"` + VerifyURL string `json:"verify_url"` +} diff --git a/drivers/cnb_releases/util.go b/drivers/cnb_releases/util.go new file mode 100644 index 00000000..83f857a4 --- /dev/null +++ b/drivers/cnb_releases/util.go @@ -0,0 +1,58 @@ +package cnb_releases + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + log "github.com/sirupsen/logrus" +) + +// do others that not defined in Driver interface + +func (d *CnbReleases) Request(method string, path string, callback base.ReqCallback, resp any) error { + if d.ref != nil { + return d.ref.Request(method, path, callback, resp) + } + var url string + if strings.HasPrefix(path, "http") { + url = path + } else { + url = "https://api.cnb.cool" + path + } + req := base.RestyClient.R() + req.SetHeader("Accept", "application/json") + req.SetAuthScheme("Bearer") + req.SetAuthToken(d.Token) + + if callback != nil { + callback(req) + } + res, err := req.Execute(method, url) + log.Debugln(res.String()) + if err != nil { + return err + } + if res.StatusCode() != http.StatusOK && res.StatusCode() != http.StatusCreated && res.StatusCode() != http.StatusNoContent { + return fmt.Errorf("failed to request %s, status code: %d, message: %s", url, res.StatusCode(), res.String()) + } + + if resp != nil { + err = json.Unmarshal(res.Body(), resp) + if err != nil { + return err + } + } + + return nil +} + +func (d *CnbReleases) sumAssetsSize(assets []ReleaseAsset) int64 { + var size int64 + for _, asset := range assets { + size += asset.Size + } + return size +} diff --git a/drivers/crypt/driver.go b/drivers/crypt/driver.go index 704c70cb..1398ff1c 100644 --- a/drivers/crypt/driver.go +++ b/drivers/crypt/driver.go @@ -44,7 +44,7 @@ func (d *Crypt) GetAddition() driver.Additional { } func (d *Crypt) Init(ctx context.Context) error { - //obfuscate credentials if it's updated or just created + // obfuscate credentials if it's updated or just created err := d.updateObfusParm(&d.Password) if err != nil { return fmt.Errorf("failed to obfuscate password: %w", err) @@ -63,7 +63,7 @@ func (d *Crypt) Init(ctx context.Context) error { op.MustSaveDriverStorage(d) - //need remote storage exist + // need remote storage exist storage, err := fs.GetStorage(d.RemotePath, &fs.GetStoragesArgs{}) if err != nil { return fmt.Errorf("can't find remote storage: %w", err) @@ -109,8 +109,8 @@ func (d *Crypt) Drop(ctx context.Context) error { func (d *Crypt) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { path := dir.GetPath() - //return d.list(ctx, d.RemotePath, path) - //remoteFull + // return d.list(ctx, d.RemotePath, path) + // remoteFull objs, err := fs.List(ctx, d.getPathForRemote(path, true), &fs.ListArgs{NoLog: true, Refresh: args.Refresh}) // the obj must implement the model.SetPath interface @@ -124,7 +124,7 @@ func (d *Crypt) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([ if obj.IsDir() { name, err := d.cipher.DecryptDirName(obj.GetName()) if err != nil { - //filter illegal files + // filter illegal files continue } if !d.ShowHidden && strings.HasPrefix(name, ".") { @@ -143,12 +143,12 @@ func (d *Crypt) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([ thumb, ok := model.GetThumb(obj) size, err := d.cipher.DecryptedSize(obj.GetSize()) if err != nil { - //filter illegal files + // filter illegal files continue } name, err := d.cipher.DecryptFileName(obj.GetName()) if err != nil { - //filter illegal files + // filter illegal files continue } if !d.ShowHidden && strings.HasPrefix(name, ".") { @@ -202,7 +202,7 @@ func (d *Crypt) Get(ctx context.Context, path string) (model.Obj, error) { remoteObj, err = fs.Get(ctx, remoteFullPath, &fs.GetArgs{NoLog: true}) if err != nil { if errs.IsObjectNotFound(err) && secondTry { - //try the opposite + // try the opposite remoteFullPath = d.getPathForRemote(path, !firstTryIsFolder) remoteObj, err2 = fs.Get(ctx, remoteFullPath, &fs.GetArgs{NoLog: true}) if err2 != nil { @@ -240,7 +240,7 @@ func (d *Crypt) Get(ctx context.Context, path string) (model.Obj, error) { IsFolder: remoteObj.IsDir(), } return obj, nil - //return nil, errs.ObjectNotFound + // return nil, errs.ObjectNotFound } // https://github.com/rclone/rclone/blob/v1.67.0/backend/crypt/cipher.go#L37 @@ -317,7 +317,8 @@ func (d *Crypt) Link(ctx context.Context, file model.Obj, args model.LinkArgs) ( } return readSeeker, nil }), - SyncClosers: utils.NewSyncClosers(remoteLink), + SyncClosers: utils.NewSyncClosers(remoteLink), + RequireReference: remoteLink.RequireReference, }, nil } @@ -366,7 +367,6 @@ func (d *Crypt) Copy(ctx context.Context, srcObj, dstDir model.Obj) error { return fmt.Errorf("failed to convert path to remote path: %w", err) } return op.Copy(ctx, d.remoteStorage, srcRemoteActualPath, dstRemoteActualPath) - } func (d *Crypt) Remove(ctx context.Context, obj model.Obj) error { @@ -411,6 +411,16 @@ func (d *Crypt) Put(ctx context.Context, dstDir model.Obj, streamer model.FileSt return nil } +func (d *Crypt) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + remoteDetails, err := op.GetStorageDetails(ctx, d.remoteStorage) + if err != nil { + return nil, err + } + return &model.StorageDetails{ + DiskUsage: remoteDetails.DiskUsage, + }, nil +} + //func (d *Safe) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) { // return nil, errs.NotSupport //} diff --git a/drivers/doubao/util.go b/drivers/doubao/util.go index 325c16c5..68660897 100644 --- a/drivers/doubao/util.go +++ b/drivers/doubao/util.go @@ -486,7 +486,7 @@ func (d *Doubao) Upload(ctx context.Context, config *UploadConfig, dstDir model. "Authorization": {storeInfo.Auth}, "Content-Type": {"application/octet-stream"}, "Content-Crc32": {crc32Value}, - "Content-Length": {fmt.Sprintf("%d", file.GetSize())}, + "Content-Length": {strconv.FormatInt(file.GetSize(), 10)}, "Content-Disposition": {fmt.Sprintf("attachment; filename=%s", url.QueryEscape(storeInfo.StoreURI))}, } res, err := base.HttpClient.Do(req) @@ -577,7 +577,7 @@ func (d *Doubao) UploadByMultipart(ctx context.Context, config *UploadConfig, fi if partIndex == totalParts-1 { size = fileSize - offset } - var reader *stream.SectionReader + var reader io.ReadSeeker var rateLimitedRd io.Reader crc32Value := "" threadG.GoWithLifecycle(errgroup.Lifecycle{ @@ -612,7 +612,7 @@ func (d *Doubao) UploadByMultipart(ctx context.Context, config *UploadConfig, fi "Authorization": {storeInfo.Auth}, "Content-Type": {"application/octet-stream"}, "Content-Crc32": {crc32Value}, - "Content-Length": {fmt.Sprintf("%d", size)}, + "Content-Length": {strconv.FormatInt(size, 10)}, "Content-Disposition": {fmt.Sprintf("attachment; filename=%s", url.QueryEscape(storeInfo.StoreURI))}, } res, err := base.HttpClient.Do(req) diff --git a/drivers/febbox/meta.go b/drivers/febbox/meta.go index e449ad00..cb6ff8fb 100644 --- a/drivers/febbox/meta.go +++ b/drivers/febbox/meta.go @@ -16,9 +16,10 @@ type Addition struct { } var config = driver.Config{ - Name: "FebBox", - NoUpload: true, - DefaultRoot: "0", + Name: "FebBox", + NoUpload: true, + DefaultRoot: "0", + LinkCacheMode: driver.LinkCacheIP, } func init() { diff --git a/drivers/ftp/meta.go b/drivers/ftp/meta.go index 8f30776c..0ec0e735 100644 --- a/drivers/ftp/meta.go +++ b/drivers/ftp/meta.go @@ -31,11 +31,11 @@ type Addition struct { } var config = driver.Config{ - Name: "FTP", - LocalSort: true, - OnlyLinkMFile: false, - DefaultRoot: "/", - NoLinkURL: true, + Name: "FTP", + LocalSort: true, + OnlyProxy: true, + DefaultRoot: "/", + NoLinkURL: true, } func init() { diff --git a/drivers/google_drive/driver.go b/drivers/google_drive/driver.go index c4dd01af..b4336312 100644 --- a/drivers/google_drive/driver.go +++ b/drivers/google_drive/driver.go @@ -167,4 +167,30 @@ func (d *GoogleDrive) Put(ctx context.Context, dstDir model.Obj, stream model.Fi return err } +func (d *GoogleDrive) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + if d.DisableDiskUsage { + return nil, errs.NotImplement + } + about, err := d.getAbout(ctx) + if err != nil { + return nil, err + } + var total, used uint64 + if about.StorageQuota.Limit == nil { + total = 0 + } else { + total, err = strconv.ParseUint(*about.StorageQuota.Limit, 10, 64) + if err != nil { + return nil, err + } + } + used, err = strconv.ParseUint(about.StorageQuota.Usage, 10, 64) + if err != nil { + return nil, err + } + return &model.StorageDetails{ + DiskUsage: driver.DiskUsageFromUsedAndTotal(used, total), + }, nil +} + var _ driver.Driver = (*GoogleDrive)(nil) diff --git a/drivers/google_drive/meta.go b/drivers/google_drive/meta.go index 34eeac28..75263a12 100644 --- a/drivers/google_drive/meta.go +++ b/drivers/google_drive/meta.go @@ -7,14 +7,15 @@ import ( type Addition struct { driver.RootID - RefreshToken string `json:"refresh_token" required:"true"` - OrderBy string `json:"order_by" type:"string" help:"such as: folder,name,modifiedTime"` - OrderDirection string `json:"order_direction" type:"select" options:"asc,desc"` - UseOnlineAPI bool `json:"use_online_api" default:"true"` - APIAddress string `json:"api_url_address" default:"https://api.oplist.org/googleui/renewapi"` - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - ChunkSize int64 `json:"chunk_size" type:"number" default:"5" help:"chunk size while uploading (unit: MB)"` + RefreshToken string `json:"refresh_token" required:"true"` + OrderBy string `json:"order_by" type:"string" help:"such as: folder,name,modifiedTime"` + OrderDirection string `json:"order_direction" type:"select" options:"asc,desc"` + UseOnlineAPI bool `json:"use_online_api" default:"true"` + APIAddress string `json:"api_url_address" default:"https://api.oplist.org/googleui/renewapi"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + ChunkSize int64 `json:"chunk_size" type:"number" default:"5" help:"chunk size while uploading (unit: MB)"` + DisableDiskUsage bool `json:"disable_disk_usage" default:"false"` } var config = driver.Config{ diff --git a/drivers/google_drive/types.go b/drivers/google_drive/types.go index df8bddb9..5e71f305 100644 --- a/drivers/google_drive/types.go +++ b/drivers/google_drive/types.go @@ -78,3 +78,12 @@ type Error struct { Message string `json:"message"` } `json:"error"` } + +type AboutResp struct { + StorageQuota struct { + Limit *string `json:"limit"` + Usage string `json:"usage"` + UsageInDrive string `json:"usageInDrive"` + UsageInDriveTrash string `json:"usageInDriveTrash"` + } +} diff --git a/drivers/google_drive/util.go b/drivers/google_drive/util.go index ff4bb7b9..372b6254 100644 --- a/drivers/google_drive/util.go +++ b/drivers/google_drive/util.go @@ -28,16 +28,16 @@ import ( // do others that not defined in Driver interface type googleDriveServiceAccount struct { - //Type string `json:"type"` - //ProjectID string `json:"project_id"` - //PrivateKeyID string `json:"private_key_id"` + // Type string `json:"type"` + // ProjectID string `json:"project_id"` + // PrivateKeyID string `json:"private_key_id"` PrivateKey string `json:"private_key"` ClientEMail string `json:"client_email"` - //ClientID string `json:"client_id"` - //AuthURI string `json:"auth_uri"` + // ClientID string `json:"client_id"` + // AuthURI string `json:"auth_uri"` TokenURI string `json:"token_uri"` - //AuthProviderX509CertURL string `json:"auth_provider_x509_cert_url"` - //ClientX509CertURL string `json:"client_x509_cert_url"` + // AuthProviderX509CertURL string `json:"auth_provider_x509_cert_url"` + // ClientX509CertURL string `json:"client_x509_cert_url"` } func (d *GoogleDrive) refreshToken() error { @@ -255,7 +255,7 @@ func (d *GoogleDrive) getFiles(id string) ([]File, error) { } func (d *GoogleDrive) chunkUpload(ctx context.Context, file model.FileStreamer, url string, up driver.UpdateProgress) error { - var defaultChunkSize = d.ChunkSize * 1024 * 1024 + defaultChunkSize := d.ChunkSize * 1024 * 1024 ss, err := stream.NewStreamSectionReader(file, int(defaultChunkSize), &up) if err != nil { return err @@ -315,3 +315,18 @@ func (d *GoogleDrive) chunkUpload(ctx context.Context, file model.FileStreamer, } return nil } + +func (d *GoogleDrive) getAbout(ctx context.Context) (*AboutResp, error) { + query := map[string]string{ + "fields": "storageQuota", + } + var resp AboutResp + _, err := d.request("https://www.googleapis.com/drive/v3/about", http.MethodGet, func(req *resty.Request) { + req.SetQueryParams(query) + req.SetContext(ctx) + }, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} diff --git a/drivers/halalcloud_open/common.go b/drivers/halalcloud_open/common.go new file mode 100644 index 00000000..575ccff3 --- /dev/null +++ b/drivers/halalcloud_open/common.go @@ -0,0 +1,111 @@ +package halalcloudopen + +import ( + "sync" + "time" + + sdkUser "github.com/halalcloud/golang-sdk-lite/halalcloud/services/user" +) + +var ( + slicePostErrorRetryInterval = time.Second * 120 + retryTimes = 5 +) + +type halalCommon struct { + // *AuthService // 登录信息 + UserInfo *sdkUser.User // 用户信息 + refreshTokenFunc func(token string) error + // serv *AuthService + configs sync.Map +} + +func (m *halalCommon) GetAccessToken() (string, error) { + value, exists := m.configs.Load("access_token") + if !exists { + return "", nil // 如果不存在,返回空字符串 + } + return value.(string), nil // 返回配置项的值 +} + +// GetRefreshToken implements ConfigStore. +func (m *halalCommon) GetRefreshToken() (string, error) { + value, exists := m.configs.Load("refresh_token") + if !exists { + return "", nil // 如果不存在,返回空字符串 + } + return value.(string), nil // 返回配置项的值 +} + +// SetAccessToken implements ConfigStore. +func (m *halalCommon) SetAccessToken(token string) error { + m.configs.Store("access_token", token) + return nil +} + +// SetRefreshToken implements ConfigStore. +func (m *halalCommon) SetRefreshToken(token string) error { + m.configs.Store("refresh_token", token) + if m.refreshTokenFunc != nil { + return m.refreshTokenFunc(token) + } + return nil +} + +// SetToken implements ConfigStore. +func (m *halalCommon) SetToken(accessToken string, refreshToken string, expiresIn int64) error { + m.configs.Store("access_token", accessToken) + m.configs.Store("refresh_token", refreshToken) + m.configs.Store("expires_in", expiresIn) + if m.refreshTokenFunc != nil { + return m.refreshTokenFunc(refreshToken) + } + return nil +} + +// ClearConfigs implements ConfigStore. +func (m *halalCommon) ClearConfigs() error { + m.configs = sync.Map{} // 清空map + return nil +} + +// DeleteConfig implements ConfigStore. +func (m *halalCommon) DeleteConfig(key string) error { + _, exists := m.configs.Load(key) + if !exists { + return nil // 如果不存在,直接返回 + } + m.configs.Delete(key) // 删除指定的配置项 + return nil +} + +// GetConfig implements ConfigStore. +func (m *halalCommon) GetConfig(key string) (string, error) { + value, exists := m.configs.Load(key) + if !exists { + return "", nil // 如果不存在,返回空字符串 + } + return value.(string), nil // 返回配置项的值 +} + +// ListConfigs implements ConfigStore. +func (m *halalCommon) ListConfigs() (map[string]string, error) { + configs := make(map[string]string) + m.configs.Range(func(key, value interface{}) bool { + configs[key.(string)] = value.(string) // 将每个配置项添加到map中 + return true // 继续遍历 + }) + return configs, nil // 返回所有配置项 +} + +// SetConfig implements ConfigStore. +func (m *halalCommon) SetConfig(key string, value string) error { + m.configs.Store(key, value) // 使用Store方法设置或更新配置项 + return nil // 成功设置配置项后返回nil +} + +func NewHalalCommon() *halalCommon { + return &halalCommon{ + configs: sync.Map{}, + } +} diff --git a/drivers/halalcloud_open/driver.go b/drivers/halalcloud_open/driver.go new file mode 100644 index 00000000..6a74538d --- /dev/null +++ b/drivers/halalcloud_open/driver.go @@ -0,0 +1,29 @@ +package halalcloudopen + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/model" + sdkClient "github.com/halalcloud/golang-sdk-lite/halalcloud/apiclient" + sdkUser "github.com/halalcloud/golang-sdk-lite/halalcloud/services/user" + sdkUserFile "github.com/halalcloud/golang-sdk-lite/halalcloud/services/userfile" +) + +type HalalCloudOpen struct { + *halalCommon + model.Storage + Addition + sdkClient *sdkClient.Client + sdkUserFileService *sdkUserFile.UserFileService + sdkUserService *sdkUser.UserService + uploadThread int +} + +func (d *HalalCloudOpen) Config() driver.Config { + return config +} + +func (d *HalalCloudOpen) GetAddition() driver.Additional { + return &d.Addition +} + +var _ driver.Driver = (*HalalCloudOpen)(nil) diff --git a/drivers/halalcloud_open/driver_curd_impl.go b/drivers/halalcloud_open/driver_curd_impl.go new file mode 100644 index 00000000..48dfaf50 --- /dev/null +++ b/drivers/halalcloud_open/driver_curd_impl.go @@ -0,0 +1,131 @@ +package halalcloudopen + +import ( + "context" + "strconv" + + "github.com/OpenListTeam/OpenList/v4/internal/model" + sdkModel "github.com/halalcloud/golang-sdk-lite/halalcloud/model" + sdkUserFile "github.com/halalcloud/golang-sdk-lite/halalcloud/services/userfile" +) + +func (d *HalalCloudOpen) getFiles(ctx context.Context, dir model.Obj) ([]model.Obj, error) { + + files := make([]model.Obj, 0) + limit := int64(100) + token := "" + + for { + result, err := d.sdkUserFileService.List(ctx, &sdkUserFile.FileListRequest{ + Parent: &sdkUserFile.File{Path: dir.GetPath()}, + ListInfo: &sdkModel.ScanListRequest{ + Limit: strconv.FormatInt(limit, 10), + Token: token, + }, + }) + if err != nil { + return nil, err + } + + for i := 0; len(result.Files) > i; i++ { + files = append(files, NewObjFile(result.Files[i])) + } + + if result.ListInfo == nil || result.ListInfo.Token == "" { + break + } + token = result.ListInfo.Token + + } + return files, nil +} + +func (d *HalalCloudOpen) makeDir(ctx context.Context, dir model.Obj, name string) (model.Obj, error) { + _, err := d.sdkUserFileService.Create(ctx, &sdkUserFile.File{ + Path: dir.GetPath(), + Name: name, + }) + return nil, err +} + +func (d *HalalCloudOpen) move(ctx context.Context, obj model.Obj, dir model.Obj) (model.Obj, error) { + oldDir := obj.GetPath() + newDir := dir.GetPath() + _, err := d.sdkUserFileService.Move(ctx, &sdkUserFile.BatchOperationRequest{ + Source: []*sdkUserFile.File{ + { + Path: oldDir, + }, + }, + Dest: &sdkUserFile.File{ + Path: newDir, + }, + }) + return nil, err +} + +func (d *HalalCloudOpen) rename(ctx context.Context, obj model.Obj, name string) (model.Obj, error) { + + _, err := d.sdkUserFileService.Rename(ctx, &sdkUserFile.File{ + Path: obj.GetPath(), + Name: name, + }) + return nil, err +} + +func (d *HalalCloudOpen) copy(ctx context.Context, obj model.Obj, dir model.Obj) (model.Obj, error) { + id := obj.GetID() + sourcePath := obj.GetPath() + if len(id) > 0 { + sourcePath = "" + } + + destID := dir.GetID() + destPath := dir.GetPath() + if len(destID) > 0 { + destPath = "" + } + dest := &sdkUserFile.File{ + Path: destPath, + Identity: destID, + } + _, err := d.sdkUserFileService.Copy(ctx, &sdkUserFile.BatchOperationRequest{ + Source: []*sdkUserFile.File{ + { + Path: sourcePath, + Identity: id, + }, + }, + Dest: dest, + }) + return nil, err +} + +func (d *HalalCloudOpen) remove(ctx context.Context, obj model.Obj) error { + id := obj.GetID() + _, err := d.sdkUserFileService.Delete(ctx, &sdkUserFile.BatchOperationRequest{ + Source: []*sdkUserFile.File{ + { + Identity: id, + Path: obj.GetPath(), + }, + }, + }) + return err +} + +func (d *HalalCloudOpen) details(ctx context.Context) (*model.StorageDetails, error) { + ret, err := d.sdkUserService.GetStatisticsAndQuota(ctx) + if err != nil { + return nil, err + } + total := uint64(ret.DiskStatisticsQuota.BytesQuota) + + free := uint64(ret.DiskStatisticsQuota.BytesFree) + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: total, + FreeSpace: free, + }, + }, nil +} diff --git a/drivers/halalcloud_open/driver_get_link.go b/drivers/halalcloud_open/driver_get_link.go new file mode 100644 index 00000000..cecc46b3 --- /dev/null +++ b/drivers/halalcloud_open/driver_get_link.go @@ -0,0 +1,108 @@ +package halalcloudopen + +import ( + "context" + "crypto/sha1" + "io" + "strconv" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/stream" + "github.com/OpenListTeam/OpenList/v4/pkg/http_range" + sdkUserFile "github.com/halalcloud/golang-sdk-lite/halalcloud/services/userfile" + "github.com/rclone/rclone/lib/readers" +) + +func (d *HalalCloudOpen) getLink(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + if args.Redirect { + // return nil, model.ErrUnsupported + fid := file.GetID() + fpath := file.GetPath() + if fid != "" { + fpath = "" + } + fi, err := d.sdkUserFileService.GetDirectDownloadAddress(ctx, &sdkUserFile.DirectDownloadRequest{ + Identity: fid, + Path: fpath, + }) + if err != nil { + return nil, err + } + expireAt := fi.ExpireAt + duration := time.Until(time.UnixMilli(expireAt)) + return &model.Link{ + URL: fi.DownloadAddress, + Expiration: &duration, + }, nil + } + result, err := d.sdkUserFileService.ParseFileSlice(ctx, &sdkUserFile.File{ + Identity: file.GetID(), + Path: file.GetPath(), + }) + if err != nil { + return nil, err + } + fileAddrs := []*sdkUserFile.SliceDownloadInfo{} + var addressDuration int64 + + nodesNumber := len(result.RawNodes) + nodesIndex := nodesNumber - 1 + startIndex, endIndex := 0, nodesIndex + for nodesIndex >= 0 { + if nodesIndex >= 200 { + endIndex = 200 + } else { + endIndex = nodesNumber + } + for ; endIndex <= nodesNumber; endIndex += 200 { + if endIndex == 0 { + endIndex = 1 + } + sliceAddress, err := d.sdkUserFileService.GetSliceDownloadAddress(ctx, &sdkUserFile.SliceDownloadAddressRequest{ + Identity: result.RawNodes[startIndex:endIndex], + Version: 1, + }) + if err != nil { + return nil, err + } + addressDuration, _ = strconv.ParseInt(sliceAddress.ExpireAt, 10, 64) + fileAddrs = append(fileAddrs, sliceAddress.Addresses...) + startIndex = endIndex + nodesIndex -= 200 + } + + } + + size, _ := strconv.ParseInt(result.FileSize, 10, 64) + chunks := getChunkSizes(result.Sizes) + resultRangeReader := func(ctx context.Context, httpRange http_range.Range) (io.ReadCloser, error) { + length := httpRange.Length + if httpRange.Length < 0 || httpRange.Start+httpRange.Length >= size { + length = size - httpRange.Start + } + oo := &openObject{ + ctx: ctx, + d: fileAddrs, + chunk: []byte{}, + chunks: chunks, + skip: httpRange.Start, + sha: result.Sha1, + shaTemp: sha1.New(), + } + + return readers.NewLimitedReadCloser(oo, length), nil + } + + var duration time.Duration + if addressDuration != 0 { + duration = time.Until(time.UnixMilli(addressDuration)) + } else { + duration = time.Until(time.Now().Add(time.Hour)) + } + + return &model.Link{ + RangeReader: stream.RateLimitRangeReaderFunc(resultRangeReader), + Expiration: &duration, + }, nil +} diff --git a/drivers/halalcloud_open/driver_init.go b/drivers/halalcloud_open/driver_init.go new file mode 100644 index 00000000..9f702638 --- /dev/null +++ b/drivers/halalcloud_open/driver_init.go @@ -0,0 +1,50 @@ +package halalcloudopen + +import ( + "context" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/halalcloud/golang-sdk-lite/halalcloud/apiclient" + sdkUser "github.com/halalcloud/golang-sdk-lite/halalcloud/services/user" + sdkUserFile "github.com/halalcloud/golang-sdk-lite/halalcloud/services/userfile" +) + +func (d *HalalCloudOpen) Init(ctx context.Context) error { + if d.uploadThread < 1 || d.uploadThread > 32 { + d.uploadThread, d.UploadThread = 3, 3 + } + if d.halalCommon == nil { + d.halalCommon = &halalCommon{ + UserInfo: &sdkUser.User{}, + refreshTokenFunc: func(token string) error { + d.Addition.RefreshToken = token + op.MustSaveDriverStorage(d) + return nil + }, + } + } + if d.Addition.RefreshToken != "" { + d.halalCommon.SetRefreshToken(d.Addition.RefreshToken) + } + timeout := d.Addition.TimeOut + if timeout <= 0 { + timeout = 60 + } + host := d.Addition.Host + if host == "" { + host = "openapi.2dland.cn" + } + + client := apiclient.NewClient(nil, host, d.Addition.ClientID, d.Addition.ClientSecret, d.halalCommon, apiclient.WithTimeout(time.Second*time.Duration(timeout))) + d.sdkClient = client + d.sdkUserFileService = sdkUserFile.NewUserFileService(client) + d.sdkUserService = sdkUser.NewUserService(client) + userInfo, err := d.sdkUserService.Get(ctx, &sdkUser.User{}) + if err != nil { + return err + } + d.halalCommon.UserInfo = userInfo + // 能够获取到用户信息,已经检查了 RefreshToken 的有效性,无需再次检查 + return nil +} diff --git a/drivers/halalcloud_open/driver_interface.go b/drivers/halalcloud_open/driver_interface.go new file mode 100644 index 00000000..0ddf248b --- /dev/null +++ b/drivers/halalcloud_open/driver_interface.go @@ -0,0 +1,48 @@ +package halalcloudopen + +import ( + "context" + + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +func (d *HalalCloudOpen) Drop(ctx context.Context) error { + return nil +} + +func (d *HalalCloudOpen) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + return d.getFiles(ctx, dir) +} + +func (d *HalalCloudOpen) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + return d.getLink(ctx, file, args) +} + +func (d *HalalCloudOpen) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { + return d.makeDir(ctx, parentDir, dirName) +} + +func (d *HalalCloudOpen) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + return d.move(ctx, srcObj, dstDir) +} + +func (d *HalalCloudOpen) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { + return d.rename(ctx, srcObj, newName) +} + +func (d *HalalCloudOpen) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + return d.copy(ctx, srcObj, dstDir) +} + +func (d *HalalCloudOpen) Remove(ctx context.Context, obj model.Obj) error { + return d.remove(ctx, obj) +} + +func (d *HalalCloudOpen) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + return d.put(ctx, dstDir, stream, up) +} + +func (d *HalalCloudOpen) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + return d.details(ctx) +} diff --git a/drivers/halalcloud_open/halalcloud_upload.go b/drivers/halalcloud_open/halalcloud_upload.go new file mode 100644 index 00000000..f5d173f1 --- /dev/null +++ b/drivers/halalcloud_open/halalcloud_upload.go @@ -0,0 +1,258 @@ +package halalcloudopen + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/model" + sdkUserFile "github.com/halalcloud/golang-sdk-lite/halalcloud/services/userfile" + "github.com/ipfs/go-cid" +) + +func (d *HalalCloudOpen) put(ctx context.Context, dstDir model.Obj, fileStream model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + + newPath := path.Join(dstDir.GetPath(), fileStream.GetName()) + + uploadTask, err := d.sdkUserFileService.CreateUploadTask(ctx, &sdkUserFile.File{ + Path: newPath, + Size: fileStream.GetSize(), + }) + if err != nil { + return nil, err + } + + if uploadTask.Created { + return nil, nil + } + + slicesList := make([]string, 0) + codec := uint64(0x55) + if uploadTask.BlockCodec > 0 { + codec = uint64(uploadTask.BlockCodec) + } + blockHashType := uploadTask.BlockHashType + mhType := uint64(0x12) + if blockHashType > 0 { + mhType = uint64(blockHashType) + } + prefix := cid.Prefix{ + Codec: codec, + MhLength: -1, + MhType: mhType, + Version: 1, + } + blockSize := uploadTask.BlockSize + useSingleUpload := true + // + if fileStream.GetSize() <= int64(blockSize) || d.uploadThread <= 1 { + useSingleUpload = true + } + // Not sure whether FileStream supports concurrent read and write operations, so currently using single-threaded upload to ensure safety. + // read file + if useSingleUpload { + bufferSize := int(blockSize) + buffer := make([]byte, bufferSize) + reader := driver.NewLimitedUploadStream(ctx, fileStream) + teeReader := io.TeeReader(reader, driver.NewProgress(fileStream.GetSize(), up)) + // fileStream.Seek(0, os.SEEK_SET) + for { + n, err := teeReader.Read(buffer) + if n > 0 { + data := buffer[:n] + uploadCid, err := postFileSlice(ctx, data, uploadTask.Task, uploadTask.UploadAddress, prefix, retryTimes) + if err != nil { + return nil, err + } + slicesList = append(slicesList, uploadCid.String()) + } + if err == io.EOF || n == 0 { + break + } + } + } else { + // TODO: implement multipart upload, currently using single-threaded upload to ensure safety. + bufferSize := int(blockSize) + buffer := make([]byte, bufferSize) + reader := driver.NewLimitedUploadStream(ctx, fileStream) + teeReader := io.TeeReader(reader, driver.NewProgress(fileStream.GetSize(), up)) + for { + n, err := teeReader.Read(buffer) + if n > 0 { + data := buffer[:n] + uploadCid, err := postFileSlice(ctx, data, uploadTask.Task, uploadTask.UploadAddress, prefix, retryTimes) + if err != nil { + return nil, err + } + slicesList = append(slicesList, uploadCid.String()) + } + if err == io.EOF || n == 0 { + break + } + } + } + newFile, err := makeFile(ctx, slicesList, uploadTask.Task, uploadTask.UploadAddress, retryTimes) + if err != nil { + return nil, err + } + + return NewObjFile(newFile), nil + +} + +func makeFile(ctx context.Context, fileSlice []string, taskID string, uploadAddress string, retry int) (*sdkUserFile.File, error) { + var lastError error = nil + for range retry { + newFile, err := doMakeFile(fileSlice, taskID, uploadAddress) + if err == nil { + return newFile, nil + } + if ctx.Err() != nil { + return nil, err + } + if strings.Contains(err.Error(), "not found") { + return nil, err + } + lastError = err + time.Sleep(slicePostErrorRetryInterval) + } + return nil, fmt.Errorf("mk file slice failed after %d times, error: %s", retry, lastError.Error()) +} + +func doMakeFile(fileSlice []string, taskID string, uploadAddress string) (*sdkUserFile.File, error) { + accessUrl := uploadAddress + "/" + taskID + getTimeOut := time.Minute * 2 + u, err := url.Parse(accessUrl) + if err != nil { + return nil, err + } + n, _ := json.Marshal(fileSlice) + httpRequest := http.Request{ + Method: http.MethodPost, + URL: u, + Header: map[string][]string{ + "Accept": {"application/json"}, + "Content-Type": {"application/json"}, + //"Content-Length": {strconv.Itoa(len(n))}, + }, + Body: io.NopCloser(bytes.NewReader(n)), + } + httpClient := http.Client{ + Timeout: getTimeOut, + } + httpResponse, err := httpClient.Do(&httpRequest) + if err != nil { + return nil, err + } + defer httpResponse.Body.Close() + if httpResponse.StatusCode != http.StatusOK && httpResponse.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(httpResponse.Body) + message := string(b) + return nil, fmt.Errorf("mk file slice failed, status code: %d, message: %s", httpResponse.StatusCode, message) + } + b, _ := io.ReadAll(httpResponse.Body) + var result *sdkUserFile.File + err = json.Unmarshal(b, &result) + if err != nil { + return nil, err + } + return result, nil +} +func postFileSlice(ctx context.Context, fileSlice []byte, taskID string, uploadAddress string, preix cid.Prefix, retry int) (cid.Cid, error) { + var lastError error = nil + for range retry { + newCid, err := doPostFileSlice(fileSlice, taskID, uploadAddress, preix) + if err == nil { + return newCid, nil + } + if ctx.Err() != nil { + return cid.Undef, err + } + time.Sleep(slicePostErrorRetryInterval) + lastError = err + } + return cid.Undef, fmt.Errorf("upload file slice failed after %d times, error: %s", retry, lastError.Error()) +} +func doPostFileSlice(fileSlice []byte, taskID string, uploadAddress string, preix cid.Prefix) (cid.Cid, error) { + // 1. sum file slice + newCid, err := preix.Sum(fileSlice) + if err != nil { + return cid.Undef, err + } + // 2. post file slice + sliceCidString := newCid.String() + // /{taskID}/{sliceID} + accessUrl := uploadAddress + "/" + taskID + "/" + sliceCidString + getTimeOut := time.Second * 30 + // get {accessUrl} in {getTimeOut} + u, err := url.Parse(accessUrl) + if err != nil { + return cid.Undef, err + } + // header: accept: application/json + // header: content-type: application/octet-stream + // header: content-length: {fileSlice.length} + // header: x-content-cid: {sliceCidString} + // header: x-task-id: {taskID} + httpRequest := http.Request{ + Method: http.MethodGet, + URL: u, + Header: map[string][]string{ + "Accept": {"application/json"}, + }, + } + httpClient := http.Client{ + Timeout: getTimeOut, + } + httpResponse, err := httpClient.Do(&httpRequest) + if err != nil { + return cid.Undef, err + } + if httpResponse.StatusCode != http.StatusOK { + return cid.Undef, fmt.Errorf("upload file slice failed, status code: %d", httpResponse.StatusCode) + } + var result bool + b, err := io.ReadAll(httpResponse.Body) + if err != nil { + return cid.Undef, err + } + err = json.Unmarshal(b, &result) + if err != nil { + return cid.Undef, err + } + if result { + return newCid, nil + } + + httpRequest = http.Request{ + Method: http.MethodPost, + URL: u, + Header: map[string][]string{ + "Accept": {"application/json"}, + "Content-Type": {"application/octet-stream"}, + // "Content-Length": {strconv.Itoa(len(fileSlice))}, + }, + Body: io.NopCloser(bytes.NewReader(fileSlice)), + } + httpResponse, err = httpClient.Do(&httpRequest) + if err != nil { + return cid.Undef, err + } + defer httpResponse.Body.Close() + if httpResponse.StatusCode != http.StatusOK && httpResponse.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(httpResponse.Body) + message := string(b) + return cid.Undef, fmt.Errorf("upload file slice failed, status code: %d, message: %s", httpResponse.StatusCode, message) + } + // + + return newCid, nil +} diff --git a/drivers/halalcloud_open/meta.go b/drivers/halalcloud_open/meta.go new file mode 100644 index 00000000..9d5b16e7 --- /dev/null +++ b/drivers/halalcloud_open/meta.go @@ -0,0 +1,32 @@ +package halalcloudopen + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type Addition struct { + // Usually one of two + driver.RootPath + // define other + RefreshToken string `json:"refresh_token" required:"false" help:"If using a personal API approach, the RefreshToken is not required."` + UploadThread int `json:"upload_thread" type:"number" default:"3" help:"1 <= thread <= 32"` + + ClientID string `json:"client_id" required:"true" default:""` + ClientSecret string `json:"client_secret" required:"true" default:""` + Host string `json:"host" required:"false" default:"openapi.2dland.cn"` + TimeOut int `json:"timeout" type:"number" default:"60" help:"timeout in seconds"` +} + +var config = driver.Config{ + Name: "HalalCloudOpen", + OnlyProxy: false, + DefaultRoot: "/", + NoLinkURL: false, +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &HalalCloudOpen{} + }) +} diff --git a/drivers/halalcloud_open/obj_file.go b/drivers/halalcloud_open/obj_file.go new file mode 100644 index 00000000..839e9f4c --- /dev/null +++ b/drivers/halalcloud_open/obj_file.go @@ -0,0 +1,60 @@ +package halalcloudopen + +import ( + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + sdkUserFile "github.com/halalcloud/golang-sdk-lite/halalcloud/services/userfile" +) + +type ObjFile struct { + sdkFile *sdkUserFile.File + fileSize int64 + modTime time.Time + createTime time.Time +} + +func NewObjFile(f *sdkUserFile.File) model.Obj { + ofile := &ObjFile{sdkFile: f} + ofile.fileSize = f.Size + modTimeTs := f.UpdateTs + ofile.modTime = time.UnixMilli(modTimeTs) + createTimeTs := f.CreateTs + ofile.createTime = time.UnixMilli(createTimeTs) + return ofile +} + +func (f *ObjFile) GetSize() int64 { + return f.fileSize +} + +func (f *ObjFile) GetName() string { + return f.sdkFile.Name +} + +func (f *ObjFile) ModTime() time.Time { + return f.modTime +} + +func (f *ObjFile) IsDir() bool { + return f.sdkFile.Dir +} + +func (f *ObjFile) GetHash() utils.HashInfo { + return utils.HashInfo{ + // TODO: support more hash types + } +} + +func (f *ObjFile) GetID() string { + return f.sdkFile.Identity +} + +func (f *ObjFile) GetPath() string { + return f.sdkFile.Path +} + +func (f *ObjFile) CreateTime() time.Time { + return f.createTime +} diff --git a/drivers/halalcloud_open/utils.go b/drivers/halalcloud_open/utils.go new file mode 100644 index 00000000..2cb6706a --- /dev/null +++ b/drivers/halalcloud_open/utils.go @@ -0,0 +1,185 @@ +package halalcloudopen + +import ( + "context" + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "hash" + "io" + "net/http" + "sync" + "time" + + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + sdkUserFile "github.com/halalcloud/golang-sdk-lite/halalcloud/services/userfile" + "github.com/ipfs/go-cid" +) + +// get the next chunk +func (oo *openObject) getChunk(_ context.Context) (err error) { + if oo.id >= len(oo.chunks) { + return io.EOF + } + var chunk []byte + err = utils.Retry(3, time.Second, func() (err error) { + chunk, err = getRawFiles(oo.d[oo.id]) + return err + }) + if err != nil { + return err + } + oo.id++ + oo.chunk = chunk + return nil +} + +// Read reads up to len(p) bytes into p. +func (oo *openObject) Read(p []byte) (n int, err error) { + oo.mu.Lock() + defer oo.mu.Unlock() + if oo.closed { + return 0, fmt.Errorf("read on closed file") + } + // Skip data at the start if requested + for oo.skip > 0 { + //size := 1024 * 1024 + _, size, err := oo.ChunkLocation(oo.id) + if err != nil { + return 0, err + } + if oo.skip < int64(size) { + break + } + oo.id++ + oo.skip -= int64(size) + } + if len(oo.chunk) == 0 { + err = oo.getChunk(oo.ctx) + if err != nil { + return 0, err + } + if oo.skip > 0 { + oo.chunk = (oo.chunk)[oo.skip:] + oo.skip = 0 + } + } + n = copy(p, oo.chunk) + oo.shaTemp.Write(p[:n]) + oo.chunk = (oo.chunk)[n:] + return n, nil +} + +// Close closed the file - MAC errors are reported here +func (oo *openObject) Close() (err error) { + oo.mu.Lock() + defer oo.mu.Unlock() + if oo.closed { + return nil + } + // 校验Sha1 + if string(oo.shaTemp.Sum(nil)) != oo.sha { + return fmt.Errorf("failed to finish download: SHA mismatch") + } + + oo.closed = true + return nil +} + +func GetMD5Hash(text string) string { + tHash := md5.Sum([]byte(text)) + return hex.EncodeToString(tHash[:]) +} + +type chunkSize struct { + position int64 + size int +} + +type openObject struct { + ctx context.Context + mu sync.Mutex + d []*sdkUserFile.SliceDownloadInfo + id int + skip int64 + chunk []byte + chunks []chunkSize + closed bool + sha string + shaTemp hash.Hash +} + +func getChunkSizes(sliceSize []*sdkUserFile.SliceSize) (chunks []chunkSize) { + chunks = make([]chunkSize, 0) + for _, s := range sliceSize { + // 对最后一个做特殊处理 + endIndex := s.EndIndex + startIndex := s.StartIndex + if endIndex == 0 { + endIndex = startIndex + } + for j := startIndex; j <= endIndex; j++ { + size := s.Size + chunks = append(chunks, chunkSize{position: j, size: int(size)}) + } + } + return chunks +} + +func (oo *openObject) ChunkLocation(id int) (position int64, size int, err error) { + if id < 0 || id >= len(oo.chunks) { + return 0, 0, errors.New("invalid arguments") + } + + return (oo.chunks)[id].position, (oo.chunks)[id].size, nil +} + +func getRawFiles(addr *sdkUserFile.SliceDownloadInfo) ([]byte, error) { + + if addr == nil { + return nil, errors.New("addr is nil") + } + + client := http.Client{ + Timeout: time.Duration(60 * time.Second), // Set timeout to 60 seconds + } + resp, err := client.Get(addr.DownloadAddress) + if err != nil { + + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bad status: %s, body: %s", resp.Status, body) + } + + if addr.Encrypt > 0 { + cd := uint8(addr.Encrypt) + for idx := 0; idx < len(body); idx++ { + body[idx] = body[idx] ^ cd + } + } + storeType := addr.StoreType + if storeType != 10 { + + sourceCid, err := cid.Decode(addr.Identity) + if err != nil { + return nil, err + } + checkCid, err := sourceCid.Prefix().Sum(body) + if err != nil { + return nil, err + } + if !checkCid.Equals(sourceCid) { + return nil, fmt.Errorf("bad cid: %s, body: %s", checkCid.String(), body) + } + } + + return body, nil + +} diff --git a/drivers/ilanzou/driver.go b/drivers/ilanzou/driver.go index 881c59ed..2335cecd 100644 --- a/drivers/ilanzou/driver.go +++ b/drivers/ilanzou/driver.go @@ -97,13 +97,13 @@ func (d *ILanZou) List(ctx context.Context, dir model.Obj, args model.ListArgs) } obj := model.Object{ ID: strconv.FormatInt(f.FileId, 10), - //Path: "", + // Path: "", Name: f.FileName, Size: f.FileSize * 1024, Modified: updTime, Ctime: updTime, IsFolder: false, - //HashInfo: utils.HashInfo{}, + // HashInfo: utils.HashInfo{}, } if f.FileType == 2 { obj.IsFolder = true @@ -185,13 +185,13 @@ func (d *ILanZou) MakeDir(ctx context.Context, parentDir model.Obj, dirName stri } return &model.Object{ ID: utils.Json.Get(res, "list", 0, "id").ToString(), - //Path: "", + // Path: "", Name: dirName, Size: 0, Modified: time.Now(), Ctime: time.Now(), IsFolder: true, - //HashInfo: utils.HashInfo{}, + // HashInfo: utils.HashInfo{}, }, nil } @@ -239,7 +239,7 @@ func (d *ILanZou) Rename(ctx context.Context, srcObj model.Obj, newName string) } return &model.Object{ ID: srcObj.GetID(), - //Path: "", + // Path: "", Name: newName, Size: srcObj.GetSize(), Modified: time.Now(), @@ -392,7 +392,7 @@ func (d *ILanZou) Put(ctx context.Context, dstDir model.Obj, s model.FileStreame } return &model.Object{ ID: strconv.FormatInt(file.FileId, 10), - //Path: , + // Path: , Name: file.FileName, Size: s.GetSize(), Modified: s.ModTime(), @@ -402,6 +402,22 @@ func (d *ILanZou) Put(ctx context.Context, dstDir model.Obj, s model.FileStreame }, nil } +func (d *ILanZou) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + res, err := d.proved("/user/account/map", http.MethodGet, func(req *resty.Request) { + req.SetContext(ctx) + }) + if err != nil { + return nil, err + } + totalSize := utils.Json.Get(res, "map", "totalSize").ToUint64() * 1024 + rewardSize := utils.Json.Get(res, "map", "rewardSize").ToUint64() * 1024 + total := totalSize + rewardSize + used := utils.Json.Get(res, "map", "usedSize").ToUint64() * 1024 + return &model.StorageDetails{ + DiskUsage: driver.DiskUsageFromUsedAndTotal(used, total), + }, nil +} + //func (d *ILanZou) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) { // return nil, errs.NotSupport //} diff --git a/drivers/lanzou/help.go b/drivers/lanzou/help.go index 418868bf..930c7248 100644 --- a/drivers/lanzou/help.go +++ b/drivers/lanzou/help.go @@ -1,7 +1,8 @@ package lanzou import ( - "bytes" + "encoding/hex" + "errors" "fmt" "net/http" "regexp" @@ -9,8 +10,6 @@ import ( "strings" "time" "unicode" - - log "github.com/sirupsen/logrus" ) const DAY time.Duration = 84600000000000 @@ -122,20 +121,26 @@ var findAcwScV2Reg = regexp.MustCompile(`arg1='([0-9A-Z]+)'`) // 在页面被过多访问或其他情况下,有时候会先返回一个加密的页面,其执行计算出一个acw_sc__v2后放入页面后再重新访问页面才能获得正常页面 // 若该页面进行了js加密,则进行解密,计算acw_sc__v2,并加入cookie -func CalcAcwScV2(html string) (string, error) { - log.Debugln("acw_sc__v2", html) - acwScV2s := findAcwScV2Reg.FindStringSubmatch(html) - if len(acwScV2s) != 2 { - return "", fmt.Errorf("无法匹配acw_sc__v2") +func CalcAcwScV2(htmlContent string) (string, error) { + matches := findAcwScV2Reg.FindStringSubmatch(htmlContent) + if len(matches) != 2 { + return "", errors.New("无法匹配到 arg1 参数") } - return HexXor(Unbox(acwScV2s[1]), "3000176000856006061501533003690027800375"), nil + arg1 := matches[1] + + mask := "3000176000856006061501533003690027800375" + result, err := hexXor(unbox(arg1), mask) + if err != nil { + return "", fmt.Errorf("hexXor 操作失败: %w", err) + } + + return result, nil } -func Unbox(hex string) string { +func unbox(hex string) string { var box = []int{6, 28, 34, 31, 33, 18, 30, 23, 9, 8, 19, 38, 17, 24, 0, 5, 32, 21, 10, 22, 25, 14, 15, 3, 16, 27, 13, 35, 2, 29, 11, 26, 4, 36, 1, 39, 37, 7, 20, 12} var newBox = make([]byte, len(hex)) - for i := 0; i < len(box); i++ { - j := box[i] + for i, j := range box { if len(newBox) > j { newBox[j] = hex[i] } @@ -143,14 +148,21 @@ func Unbox(hex string) string { return string(newBox) } -func HexXor(hex1, hex2 string) string { - out := bytes.NewBuffer(make([]byte, len(hex1))) - for i := 0; i < len(hex1) && i < len(hex2); i += 2 { - v1, _ := strconv.ParseInt(hex1[i:i+2], 16, 64) - v2, _ := strconv.ParseInt(hex2[i:i+2], 16, 64) - out.WriteString(strconv.FormatInt(v1^v2, 16)) +func hexXor(hex1, hex2 string) (string, error) { + bytes1, err := hex.DecodeString(hex1) + if err != nil { + return "", fmt.Errorf("解码 hex1 失败: %w", err) + } + bytes2, err := hex.DecodeString(hex2) + if err != nil { + return "", fmt.Errorf("解码 hex2 失败: %w", err) + } + minLength := min(len(bytes2), len(bytes1)) + resultBytes := make([]byte, minLength) + for i := range minLength { + resultBytes[i] = bytes1[i] ^ bytes2[i] } - return out.String() + return hex.EncodeToString(resultBytes), nil } var findDataReg = regexp.MustCompile(`data[:\s]+({[^}]+})`) // 查找json diff --git a/drivers/lanzou/util.go b/drivers/lanzou/util.go index 018df604..9a15d428 100644 --- a/drivers/lanzou/util.go +++ b/drivers/lanzou/util.go @@ -3,6 +3,7 @@ package lanzou import ( "errors" "fmt" + "io" "net/http" "regexp" "runtime" @@ -94,36 +95,66 @@ func (d *LanZou) _post(url string, callback base.ReqCallback, resp interface{}, } } +// 修复点:所有请求都自动处理 acw_sc__v2 验证和 down_ip=1 func (d *LanZou) request(url string, method string, callback base.ReqCallback, up bool) ([]byte, error) { var req *resty.Request - if up { - once.Do(func() { - upClient = base.NewRestyClient().SetTimeout(120 * time.Second) - }) - req = upClient.R() - } else { - req = base.RestyClient.R() - } + var vs string + for retry := 0; retry < 3; retry++ { + if up { + once.Do(func() { + upClient = base.NewRestyClient().SetTimeout(120 * time.Second) + }) + req = upClient.R() + } else { + req = base.RestyClient.R() + } - req.SetHeaders(map[string]string{ - "Referer": "https://pc.woozooo.com", - "User-Agent": d.UserAgent, - }) + req.SetHeaders(map[string]string{ + "Referer": "https://pc.woozooo.com", + "User-Agent": d.UserAgent, + }) - if d.Cookie != "" { - req.SetHeader("cookie", d.Cookie) - } + // 下载直链时需要加 down_ip=1 + if strings.Contains(url, "/file/") { + cookie := d.Cookie + if cookie != "" { + cookie += "; " + } + cookie += "down_ip=1" + if vs != "" { + cookie += "; acw_sc__v2=" + vs + } + req.SetHeader("cookie", cookie) + } else if d.Cookie != "" { + cookie := d.Cookie + if vs != "" { + cookie += "; acw_sc__v2=" + vs + } + req.SetHeader("cookie", cookie) + } else if vs != "" { + req.SetHeader("cookie", "acw_sc__v2="+vs) + } - if callback != nil { - callback(req) - } + if callback != nil { + callback(req) + } - res, err := req.Execute(method, url) - if err != nil { - return nil, err + res, err := req.Execute(method, url) + if err != nil { + return nil, err + } + bodyStr := res.String() + log.Debugf("lanzou request: url=>%s ,stats=>%d ,body => %s\n", res.Request.URL, res.StatusCode(), bodyStr) + if strings.Contains(bodyStr, "acw_sc__v2") { + vs, err = CalcAcwScV2(bodyStr) + if err != nil { + return nil, err + } + continue + } + return res.Body(), err } - log.Debugf("lanzou request: url=>%s ,stats=>%d ,body => %s\n", res.Request.URL, res.StatusCode(), res.String()) - return res.Body(), err + return nil, errors.New("acw_sc__v2 validation error") } func (d *LanZou) Login() ([]*http.Cookie, error) { @@ -430,27 +461,91 @@ func (d *LanZou) getFilesByShareUrl(shareID, pwd string, sharePageData string) ( file.Time = timeFindReg.FindString(sharePageData) // 重定向获取真实链接 - res, err := base.NoRedirectClient.R().SetHeaders(map[string]string{ - "accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", - }).Get(downloadUrl) + var ( + res *resty.Response + err error + ) + var vs string + var bodyStr string + for i := 0; i < 3; i++ { + res, err = base.NoRedirectClient.R().SetHeaders(map[string]string{ + "accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", + "Referer": baseUrl, + }).SetDoNotParseResponse(true). + SetCookie(&http.Cookie{ + Name: "acw_sc__v2", + Value: vs, + }).SetHeader("cookie", "down_ip=1").Get(downloadUrl) + if err != nil { + return nil, err + } + + if res.StatusCode() == 302 { + if res.RawBody() != nil { + res.RawBody().Close() + } + break + } + bodyBytes, err := io.ReadAll(res.RawBody()) + if res.RawBody() != nil { + res.RawBody().Close() + } + if err != nil { + return nil, fmt.Errorf("读取响应体失败: %w", err) + } + bodyStr = string(bodyBytes) + if strings.Contains(bodyStr, "acw_sc__v2") { + if vs, err = CalcAcwScV2(bodyStr); err != nil { + log.Errorf("lanzou: err => acw_sc__v2 validation error ,data => %s\n", bodyStr) + return nil, err + } + continue + } + break + } + if err != nil { return nil, err } file.Url = res.Header().Get("location") - // 触发验证 - rPageData := res.String() + // 触发二次验证,也需要处理一下触发acw_sc__v2的情况 if res.StatusCode() != 302 { - param, err = htmlJsonToMap(rPageData) + param, err = htmlJsonToMap(bodyStr) if err != nil { return nil, err } param["el"] = "2" time.Sleep(time.Second * 2) - // 通过验证获取直连 - data, err := d.post(fmt.Sprint(baseUrl, "/ajax.php"), func(req *resty.Request) { req.SetFormData(param) }, nil) + // 通过验证获取直链 + var data []byte + for i := 0; i < 3; i++ { + data, err = d.post(fmt.Sprint(baseUrl, "/ajax.php"), func(req *resty.Request) { + req.SetFormData(param) + req.SetHeader("cookie", "down_ip=1") + if vs != "" { + req.SetCookie(&http.Cookie{ + Name: "acw_sc__v2", + Value: vs, + }) + } + }, nil) + if err != nil { + return nil, err + } + ajaxBodyStr := string(data) + if strings.Contains(ajaxBodyStr, "acw_sc__v2") { + if vs, err = CalcAcwScV2(ajaxBodyStr); err != nil { + log.Errorf("lanzou: err => acw_sc__v2 validation error ,data => %s\n", ajaxBodyStr) + return nil, err + } + time.Sleep(time.Second * 2) + continue + } + break + } if err != nil { return nil, err } diff --git a/drivers/local/driver.go b/drivers/local/driver.go index df357536..45badb2e 100644 --- a/drivers/local/driver.go +++ b/drivers/local/driver.go @@ -51,7 +51,7 @@ func (d *Local) Config() driver.Config { func (d *Local) Init(ctx context.Context) error { if d.MkdirPerm == "" { - d.mkdirPerm = 0777 + d.mkdirPerm = 0o777 } else { v, err := strconv.ParseUint(d.MkdirPerm, 8, 32) if err != nil { @@ -150,6 +150,7 @@ func (d *Local) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([ } return files, nil } + func (d *Local) FileInfoToObj(ctx context.Context, f fs.FileInfo, reqPath string, fullPath string) model.Obj { thumb := "" if d.Thumbnail { @@ -198,7 +199,7 @@ func (d *Local) Get(ctx context.Context, path string) (model.Obj, error) { path = filepath.Join(d.GetRootPath(), path) f, err := os.Stat(path) if err != nil { - if strings.Contains(err.Error(), "cannot find the file") { + if os.IsNotExist(err) { return nil, errs.ObjectNotFound } return nil, err @@ -234,6 +235,7 @@ func (d *Local) Get(ctx context.Context, path string) (model.Obj, error) { func (d *Local) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { fullPath := file.GetPath() link := &model.Link{} + var MFile model.File if args.Type == "thumb" && utils.Ext(file.GetName()) != "svg" { var buf *bytes.Buffer var thumbPath *string @@ -260,9 +262,9 @@ func (d *Local) Link(ctx context.Context, file model.Obj, args model.LinkArgs) ( return nil, err } link.ContentLength = int64(stat.Size()) - link.MFile = open + MFile = open } else { - link.MFile = bytes.NewReader(buf.Bytes()) + MFile = bytes.NewReader(buf.Bytes()) link.ContentLength = int64(buf.Len()) } } else { @@ -271,13 +273,11 @@ func (d *Local) Link(ctx context.Context, file model.Obj, args model.LinkArgs) ( return nil, err } link.ContentLength = file.GetSize() - link.MFile = open - } - link.AddIfCloser(link.MFile) - if !d.Config().OnlyLinkMFile { - link.RangeReader = stream.GetRangeReaderFromMFile(link.ContentLength, link.MFile) - link.MFile = nil + MFile = open } + link.SyncClosers.AddIfCloser(MFile) + link.RangeReader = stream.GetRangeReaderFromMFile(link.ContentLength, MFile) + link.RequireReference = link.SyncClosers.Length() > 0 return link, nil } @@ -374,11 +374,26 @@ func (d *Local) Remove(ctx context.Context, obj model.Obj) error { err = os.Remove(obj.GetPath()) } } else { - dstPath := filepath.Join(d.RecycleBinPath, obj.GetName()) + objPath := obj.GetPath() + objName := obj.GetName() + var relPath string + relPath, err = filepath.Rel(d.GetRootPath(), filepath.Dir(objPath)) + if err != nil { + return err + } + recycleBinPath := filepath.Join(d.RecycleBinPath, relPath) + if !utils.Exists(recycleBinPath) { + err = os.MkdirAll(recycleBinPath, 0o755) + if err != nil { + return err + } + } + + dstPath := filepath.Join(recycleBinPath, objName) if utils.Exists(dstPath) { - dstPath = filepath.Join(d.RecycleBinPath, obj.GetName()+"_"+time.Now().Format("20060102150405")) + dstPath = filepath.Join(recycleBinPath, objName+"_"+time.Now().Format("20060102150405")) } - err = os.Rename(obj.GetPath(), dstPath) + err = os.Rename(objPath, dstPath) } if err != nil { return err @@ -427,4 +442,14 @@ func (d *Local) Put(ctx context.Context, dstDir model.Obj, stream model.FileStre return nil } +func (d *Local) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + du, err := getDiskUsage(d.RootFolderPath) + if err != nil { + return nil, err + } + return &model.StorageDetails{ + DiskUsage: du, + }, nil +} + var _ driver.Driver = (*Local)(nil) diff --git a/drivers/local/meta.go b/drivers/local/meta.go index b16f3ca5..a27e9eec 100644 --- a/drivers/local/meta.go +++ b/drivers/local/meta.go @@ -18,12 +18,12 @@ type Addition struct { } var config = driver.Config{ - Name: "Local", - OnlyLinkMFile: false, - LocalSort: true, - NoCache: true, - DefaultRoot: "/", - NoLinkURL: true, + Name: "Local", + LocalSort: true, + OnlyProxy: true, + NoCache: true, + DefaultRoot: "/", + NoLinkURL: true, } func init() { diff --git a/drivers/local/util_unix.go b/drivers/local/util_unix.go index ddb4879f..3362df34 100644 --- a/drivers/local/util_unix.go +++ b/drivers/local/util_unix.go @@ -5,8 +5,25 @@ package local import ( "io/fs" "strings" + "syscall" + + "github.com/OpenListTeam/OpenList/v4/internal/model" ) func isHidden(f fs.FileInfo, _ string) bool { return strings.HasPrefix(f.Name(), ".") } + +func getDiskUsage(path string) (model.DiskUsage, error) { + var stat syscall.Statfs_t + err := syscall.Statfs(path, &stat) + if err != nil { + return model.DiskUsage{}, err + } + total := stat.Blocks * uint64(stat.Bsize) + free := stat.Bfree * uint64(stat.Bsize) + return model.DiskUsage{ + TotalSpace: total, + FreeSpace: free, + }, nil +} diff --git a/drivers/local/util_windows.go b/drivers/local/util_windows.go index 8df191cb..37064009 100644 --- a/drivers/local/util_windows.go +++ b/drivers/local/util_windows.go @@ -1,22 +1,51 @@ -//go:build windows - -package local - -import ( - "io/fs" - "path/filepath" - "syscall" -) - -func isHidden(f fs.FileInfo, fullPath string) bool { - filePath := filepath.Join(fullPath, f.Name()) - namePtr, err := syscall.UTF16PtrFromString(filePath) - if err != nil { - return false - } - attrs, err := syscall.GetFileAttributes(namePtr) - if err != nil { - return false - } - return attrs&syscall.FILE_ATTRIBUTE_HIDDEN != 0 -} +//go:build windows + +package local + +import ( + "errors" + "io/fs" + "path/filepath" + "syscall" + + "github.com/OpenListTeam/OpenList/v4/internal/model" + "golang.org/x/sys/windows" +) + +func isHidden(f fs.FileInfo, fullPath string) bool { + filePath := filepath.Join(fullPath, f.Name()) + namePtr, err := syscall.UTF16PtrFromString(filePath) + if err != nil { + return false + } + attrs, err := syscall.GetFileAttributes(namePtr) + if err != nil { + return false + } + return attrs&syscall.FILE_ATTRIBUTE_HIDDEN != 0 +} + +func getDiskUsage(path string) (model.DiskUsage, error) { + abs, err := filepath.Abs(path) + if err != nil { + return model.DiskUsage{}, err + } + root := filepath.VolumeName(abs) + if len(root) != 2 || root[1] != ':' { + return model.DiskUsage{}, errors.New("cannot get disk label") + } + var freeBytes, totalBytes, totalFreeBytes uint64 + err = windows.GetDiskFreeSpaceEx( + windows.StringToUTF16Ptr(root), + &freeBytes, + &totalBytes, + &totalFreeBytes, + ) + if err != nil { + return model.DiskUsage{}, err + } + return model.DiskUsage{ + TotalSpace: totalBytes, + FreeSpace: freeBytes, + }, nil +} diff --git a/drivers/mediafire/driver.go b/drivers/mediafire/driver.go new file mode 100644 index 00000000..bd250259 --- /dev/null +++ b/drivers/mediafire/driver.go @@ -0,0 +1,431 @@ +package mediafire + +/* +Package mediafire +Author: Da3zKi7 +Date: 2025-09-11 + +D@' 3z K!7 - The King Of Cracking + +Modifications by ILoveScratch2 +Date: 2025-09-21 + +Date: 2025-09-26 +Final opts by @Suyunjing @j2rong4cn @KirCute @Da3zKi7 +*/ + +import ( + "context" + "fmt" + "math/rand" + "net/http" + "strconv" + "time" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/stream" + "github.com/OpenListTeam/OpenList/v4/pkg/cron" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "golang.org/x/time/rate" +) + +type Mediafire struct { + model.Storage + Addition + + cron *cron.Cron + + actionToken string + limiter *rate.Limiter + + appBase string + apiBase string + hostBase string + maxRetries int + + secChUa string + secChUaPlatform string + userAgent string +} + +func (d *Mediafire) Config() driver.Config { + return config +} + +func (d *Mediafire) GetAddition() driver.Additional { + return &d.Addition +} + +// Init initializes the MediaFire driver with session token and cookie validation +func (d *Mediafire) Init(ctx context.Context) error { + if d.SessionToken == "" { + return fmt.Errorf("Init :: [MediaFire] {critical} missing sessionToken") + } + + if d.Cookie == "" { + return fmt.Errorf("Init :: [MediaFire] {critical} missing Cookie") + } + // Setup rate limiter if rate limit is configured + if d.LimitRate > 0 { + d.limiter = rate.NewLimiter(rate.Limit(d.LimitRate), 1) + } + // Validate and refresh session token if needed + if _, err := d.getSessionToken(ctx); err != nil { + + d.renewToken(ctx) + + // Avoids 10 mins token expiry (6- 9) + num := rand.Intn(4) + 6 + + d.cron = cron.NewCron(time.Minute * time.Duration(num)) + d.cron.Do(func() { + // Crazy, but working way to refresh session token + d.renewToken(ctx) + }) + + } + + return nil +} + +// Drop cleans up driver resources +func (d *Mediafire) Drop(ctx context.Context) error { + // Clear cached resources + d.actionToken = "" + if d.cron != nil { + d.cron.Stop() + d.cron = nil + } + return nil +} + +// List retrieves files and folders from the specified directory +func (d *Mediafire) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + files, err := d.getFiles(ctx, dir.GetID()) + if err != nil { + return nil, err + } + return utils.SliceConvert(files, func(src File) (model.Obj, error) { + return d.fileToObj(src), nil + }) +} + +// Link generates a direct download link for the specified file +func (d *Mediafire) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + downloadUrl, err := d.getDirectDownloadLink(ctx, file.GetID()) + if err != nil { + return nil, err + } + + res, err := base.NoRedirectClient.R().SetDoNotParseResponse(true).SetContext(ctx).Head(downloadUrl) + if err != nil { + return nil, err + } + defer func() { + _ = res.RawBody().Close() + }() + + if res.StatusCode() == 302 { + downloadUrl = res.Header().Get("location") + } + + return &model.Link{ + URL: downloadUrl, + Header: http.Header{ + "Origin": []string{d.appBase}, + "Referer": []string{d.appBase + "/"}, + "sec-ch-ua": []string{d.secChUa}, + "sec-ch-ua-platform": []string{d.secChUaPlatform}, + "User-Agent": []string{d.userAgent}, + }, + }, nil +} + +// MakeDir creates a new folder in the specified parent directory +func (d *Mediafire) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { + data := map[string]string{ + "session_token": d.SessionToken, + "response_format": "json", + "parent_key": parentDir.GetID(), + "foldername": dirName, + } + + var resp MediafireFolderCreateResponse + _, err := d.postForm(ctx, "/folder/create.php", data, &resp) + if err != nil { + return nil, err + } + + if err := checkAPIResult(resp.Response.Result); err != nil { + return nil, err + } + + created, _ := time.Parse("2006-01-02T15:04:05Z", resp.Response.CreatedUTC) + + return &model.Object{ + ID: resp.Response.FolderKey, + Name: resp.Response.Name, + Size: 0, + Modified: created, + Ctime: created, + IsFolder: true, + }, nil +} + +// Move relocates a file or folder to a different parent directory +func (d *Mediafire) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + var data map[string]string + var endpoint string + + if srcObj.IsDir() { + + endpoint = "/folder/move.php" + data = map[string]string{ + "session_token": d.SessionToken, + "response_format": "json", + "folder_key_src": srcObj.GetID(), + "folder_key_dst": dstDir.GetID(), + } + } else { + + endpoint = "/file/move.php" + data = map[string]string{ + "session_token": d.SessionToken, + "response_format": "json", + "quick_key": srcObj.GetID(), + "folder_key": dstDir.GetID(), + } + } + + var resp MediafireMoveResponse + _, err := d.postForm(ctx, endpoint, data, &resp) + if err != nil { + return nil, err + } + + if err := checkAPIResult(resp.Response.Result); err != nil { + return nil, err + } + + return srcObj, nil +} + +// Rename changes the name of a file or folder +func (d *Mediafire) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { + var data map[string]string + var endpoint string + + if srcObj.IsDir() { + + endpoint = "/folder/update.php" + data = map[string]string{ + "session_token": d.SessionToken, + "response_format": "json", + "folder_key": srcObj.GetID(), + "foldername": newName, + } + } else { + + endpoint = "/file/update.php" + data = map[string]string{ + "session_token": d.SessionToken, + "response_format": "json", + "quick_key": srcObj.GetID(), + "filename": newName, + } + } + + var resp MediafireRenameResponse + _, err := d.postForm(ctx, endpoint, data, &resp) + if err != nil { + return nil, err + } + + if err := checkAPIResult(resp.Response.Result); err != nil { + return nil, err + } + + return &model.Object{ + ID: srcObj.GetID(), + Name: newName, + Size: srcObj.GetSize(), + Modified: srcObj.ModTime(), + Ctime: srcObj.CreateTime(), + IsFolder: srcObj.IsDir(), + }, nil +} + +// Copy creates a duplicate of a file or folder in the specified destination directory +func (d *Mediafire) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + var data map[string]string + var endpoint string + + if srcObj.IsDir() { + + endpoint = "/folder/copy.php" + data = map[string]string{ + "session_token": d.SessionToken, + "response_format": "json", + "folder_key_src": srcObj.GetID(), + "folder_key_dst": dstDir.GetID(), + } + } else { + + endpoint = "/file/copy.php" + data = map[string]string{ + "session_token": d.SessionToken, + "response_format": "json", + "quick_key": srcObj.GetID(), + "folder_key": dstDir.GetID(), + } + } + + var resp MediafireCopyResponse + _, err := d.postForm(ctx, endpoint, data, &resp) + if err != nil { + return nil, err + } + + if err := checkAPIResult(resp.Response.Result); err != nil { + return nil, err + } + + var newID string + if srcObj.IsDir() { + if len(resp.Response.NewFolderKeys) > 0 { + newID = resp.Response.NewFolderKeys[0] + } + } else { + if len(resp.Response.NewQuickKeys) > 0 { + newID = resp.Response.NewQuickKeys[0] + } + } + + return &model.Object{ + ID: newID, + Name: srcObj.GetName(), + Size: srcObj.GetSize(), + Modified: srcObj.ModTime(), + Ctime: srcObj.CreateTime(), + IsFolder: srcObj.IsDir(), + }, nil +} + +// Remove deletes a file or folder permanently +func (d *Mediafire) Remove(ctx context.Context, obj model.Obj) error { + var data map[string]string + var endpoint string + + if obj.IsDir() { + + endpoint = "/folder/delete.php" + data = map[string]string{ + "session_token": d.SessionToken, + "response_format": "json", + "folder_key": obj.GetID(), + } + } else { + + endpoint = "/file/delete.php" + data = map[string]string{ + "session_token": d.SessionToken, + "response_format": "json", + "quick_key": obj.GetID(), + } + } + + var resp MediafireRemoveResponse + _, err := d.postForm(ctx, endpoint, data, &resp) + if err != nil { + return err + } + + return checkAPIResult(resp.Response.Result) +} + +// Put uploads a file to the specified directory with support for resumable upload and quick upload +func (d *Mediafire) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + fileHash := file.GetHash().GetHash(utils.SHA256) + var err error + + // Try to use existing hash first, cache only if necessary + if len(fileHash) != utils.SHA256.Width { + _, fileHash, err = stream.CacheFullAndHash(file, &up, utils.SHA256) + if err != nil { + return nil, err + } + } + + checkResp, err := d.uploadCheck(ctx, file.GetName(), file.GetSize(), fileHash, dstDir.GetID()) + if err != nil { + return nil, err + } + + if checkResp.Response.HashExists == "yes" && checkResp.Response.InAccount == "yes" { + up(100.0) + existingFile, err := d.getExistingFileInfo(ctx, fileHash, file.GetName(), dstDir.GetID()) + if err == nil && existingFile != nil { + // File exists, return existing file info + return &model.Object{ + ID: existingFile.GetID(), + Name: file.GetName(), + Size: file.GetSize(), + }, nil + } + // If getExistingFileInfo fails, log and continue with normal upload + // This ensures upload doesn't fail due to search issues + } + + var pollKey string + + if checkResp.Response.ResumableUpload.AllUnitsReady != "yes" { + pollKey, err = d.uploadUnits(ctx, file, checkResp, file.GetName(), fileHash, dstDir.GetID(), up) + if err != nil { + return nil, err + } + } else { + pollKey = checkResp.Response.ResumableUpload.UploadKey + up(100.0) + } + + pollResp, err := d.pollUpload(ctx, pollKey) + if err != nil { + return nil, err + } + + return &model.Object{ + ID: pollResp.Response.Doupload.QuickKey, + Name: file.GetName(), + Size: file.GetSize(), + }, nil +} + +func (d *Mediafire) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + data := map[string]string{ + "session_token": d.SessionToken, + "response_format": "json", + } + var resp MediafireUserInfoResponse + _, err := d.postForm(ctx, "/user/get_info.php", data, &resp) + if err != nil { + return nil, err + } + used, err := strconv.ParseUint(resp.Response.UserInfo.UsedStorageSize, 10, 64) + if err != nil { + return nil, err + } + total, err := strconv.ParseUint(resp.Response.UserInfo.StorageLimit, 10, 64) + if err != nil { + return nil, err + } + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: total, + FreeSpace: total - used, + }, + }, nil +} + +var _ driver.Driver = (*Mediafire)(nil) diff --git a/drivers/mediafire/meta.go b/drivers/mediafire/meta.go new file mode 100644 index 00000000..78a5b9b1 --- /dev/null +++ b/drivers/mediafire/meta.go @@ -0,0 +1,61 @@ +package mediafire + +/* +Package mediafire +Author: Da3zKi7 +Date: 2025-09-11 + +D@' 3z K!7 - The King Of Cracking + +Modifications by ILoveScratch2 +Date: 2025-09-21 + +Date: 2025-09-26 +Final opts by @Suyunjing @j2rong4cn @KirCute @Da3zKi7 +*/ + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type Addition struct { + driver.RootPath + //driver.RootID + + SessionToken string `json:"session_token" required:"true" type:"string" help:"Required for MediaFire API"` + Cookie string `json:"cookie" required:"true" type:"string" help:"Required for navigation"` + + OrderBy string `json:"order_by" type:"select" options:"name,time,size" default:"name"` + OrderDirection string `json:"order_direction" type:"select" options:"asc,desc" default:"asc"` + ChunkSize int64 `json:"chunk_size" type:"number" default:"100"` + UploadThreads int `json:"upload_threads" type:"number" default:"3" help:"concurrent upload threads"` + LimitRate float64 `json:"limit_rate" type:"float" default:"2" help:"limit all api request rate ([limit]r/1s)"` +} + +var config = driver.Config{ + Name: "MediaFire", + LocalSort: false, + OnlyProxy: false, + NoCache: false, + NoUpload: false, + NeedMs: false, + DefaultRoot: "/", + CheckStatus: false, + Alert: "", + NoOverwriteUpload: true, +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &Mediafire{ + appBase: "https://app.mediafire.com", + apiBase: "https://www.mediafire.com/api/1.5", + hostBase: "https://www.mediafire.com", + maxRetries: 3, + secChUa: "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"139\", \"Google Chrome\";v=\"139\"", + secChUaPlatform: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36", + } + }) +} diff --git a/drivers/mediafire/types.go b/drivers/mediafire/types.go new file mode 100644 index 00000000..4a59ae56 --- /dev/null +++ b/drivers/mediafire/types.go @@ -0,0 +1,246 @@ +package mediafire + +/* +Package mediafire +Author: Da3zKi7 +Date: 2025-09-11 + +D@' 3z K!7 - The King Of Cracking +*/ + +type MediafireRenewTokenResponse struct { + Response struct { + Action string `json:"action"` + SessionToken string `json:"session_token"` + Result string `json:"result"` + CurrentAPIVersion string `json:"current_api_version"` + } `json:"response"` +} + +type MediafireResponse struct { + Response struct { + Action string `json:"action"` + FolderContent struct { + ChunkSize string `json:"chunk_size"` + ContentType string `json:"content_type"` + ChunkNumber string `json:"chunk_number"` + FolderKey string `json:"folderkey"` + Folders []MediafireFolder `json:"folders,omitempty"` + Files []MediafireFile `json:"files,omitempty"` + MoreChunks string `json:"more_chunks"` + } `json:"folder_content"` + Result string `json:"result"` + } `json:"response"` +} + +type MediafireFolder struct { + FolderKey string `json:"folderkey"` + Name string `json:"name"` + Created string `json:"created"` + CreatedUTC string `json:"created_utc"` +} + +type MediafireFile struct { + QuickKey string `json:"quickkey"` + Filename string `json:"filename"` + Size string `json:"size"` + Created string `json:"created"` + CreatedUTC string `json:"created_utc"` + MimeType string `json:"mimetype"` +} + +type File struct { + ID string + Name string + Size int64 + CreatedUTC string + IsFolder bool +} + +type FolderContentResponse struct { + Folders []MediafireFolder + Files []MediafireFile + MoreChunks bool +} + +type MediafireLinksResponse struct { + Response struct { + Action string `json:"action"` + Links []struct { + QuickKey string `json:"quickkey"` + View string `json:"view"` + NormalDownload string `json:"normal_download"` + OneTime struct { + Download string `json:"download"` + View string `json:"view"` + } `json:"one_time"` + } `json:"links"` + OneTimeKeyRequestCount string `json:"one_time_key_request_count"` + OneTimeKeyRequestMaxCount string `json:"one_time_key_request_max_count"` + Result string `json:"result"` + CurrentAPIVersion string `json:"current_api_version"` + } `json:"response"` +} + +type MediafireDirectDownloadResponse struct { + Response struct { + Action string `json:"action"` + Links []struct { + QuickKey string `json:"quickkey"` + DirectDownload string `json:"direct_download"` + } `json:"links"` + DirectDownloadFreeBandwidth string `json:"direct_download_free_bandwidth"` + Result string `json:"result"` + CurrentAPIVersion string `json:"current_api_version"` + } `json:"response"` +} + +type MediafireFolderCreateResponse struct { + Response struct { + Action string `json:"action"` + FolderKey string `json:"folder_key"` + UploadKey string `json:"upload_key"` + ParentFolderKey string `json:"parent_folderkey"` + Name string `json:"name"` + Description string `json:"description"` + Created string `json:"created"` + CreatedUTC string `json:"created_utc"` + Privacy string `json:"privacy"` + FileCount string `json:"file_count"` + FolderCount string `json:"folder_count"` + Revision string `json:"revision"` + DropboxEnabled string `json:"dropbox_enabled"` + Flag string `json:"flag"` + Result string `json:"result"` + CurrentAPIVersion string `json:"current_api_version"` + NewDeviceRevision int `json:"new_device_revision"` + } `json:"response"` +} + +type MediafireMoveResponse struct { + Response struct { + Action string `json:"action"` + Asynchronous string `json:"asynchronous,omitempty"` + NewNames []string `json:"new_names"` + Result string `json:"result"` + CurrentAPIVersion string `json:"current_api_version"` + NewDeviceRevision int `json:"new_device_revision"` + } `json:"response"` +} + +type MediafireRenameResponse struct { + Response struct { + Action string `json:"action"` + Asynchronous string `json:"asynchronous,omitempty"` + Result string `json:"result"` + CurrentAPIVersion string `json:"current_api_version"` + NewDeviceRevision int `json:"new_device_revision"` + } `json:"response"` +} + +type MediafireCopyResponse struct { + Response struct { + Action string `json:"action"` + Asynchronous string `json:"asynchronous,omitempty"` + NewQuickKeys []string `json:"new_quickkeys,omitempty"` + NewFolderKeys []string `json:"new_folderkeys,omitempty"` + SkippedCount string `json:"skipped_count,omitempty"` + OtherCount string `json:"other_count,omitempty"` + Result string `json:"result"` + CurrentAPIVersion string `json:"current_api_version"` + NewDeviceRevision int `json:"new_device_revision"` + } `json:"response"` +} + +type MediafireRemoveResponse struct { + Response struct { + Action string `json:"action"` + Asynchronous string `json:"asynchronous,omitempty"` + Result string `json:"result"` + CurrentAPIVersion string `json:"current_api_version"` + NewDeviceRevision int `json:"new_device_revision"` + } `json:"response"` +} + +type MediafireCheckResponse struct { + Response struct { + Action string `json:"action"` + HashExists string `json:"hash_exists"` + InAccount string `json:"in_account"` + InFolder string `json:"in_folder"` + FileExists string `json:"file_exists"` + ResumableUpload struct { + AllUnitsReady string `json:"all_units_ready"` + NumberOfUnits string `json:"number_of_units"` + UnitSize string `json:"unit_size"` + Bitmap struct { + Count string `json:"count"` + Words []string `json:"words"` + } `json:"bitmap"` + UploadKey string `json:"upload_key"` + } `json:"resumable_upload"` + AvailableSpace string `json:"available_space"` + UsedStorageSize string `json:"used_storage_size"` + StorageLimit string `json:"storage_limit"` + StorageLimitExceeded string `json:"storage_limit_exceeded"` + UploadURL struct { + Simple string `json:"simple"` + SimpleFallback string `json:"simple_fallback"` + Resumable string `json:"resumable"` + ResumableFallback string `json:"resumable_fallback"` + } `json:"upload_url"` + Result string `json:"result"` + CurrentAPIVersion string `json:"current_api_version"` + } `json:"response"` +} +type MediafireActionTokenResponse struct { + Response struct { + Action string `json:"action"` + ActionToken string `json:"action_token"` + Result string `json:"result"` + CurrentAPIVersion string `json:"current_api_version"` + } `json:"response"` +} + +type MediafirePollResponse struct { + Response struct { + Action string `json:"action"` + Doupload struct { + Result string `json:"result"` + Status string `json:"status"` + Description string `json:"description"` + QuickKey string `json:"quickkey"` + Hash string `json:"hash"` + Filename string `json:"filename"` + Size string `json:"size"` + Created string `json:"created"` + CreatedUTC string `json:"created_utc"` + Revision string `json:"revision"` + } `json:"doupload"` + Result string `json:"result"` + CurrentAPIVersion string `json:"current_api_version"` + } `json:"response"` +} + +type MediafireFileSearchResponse struct { + Response struct { + Action string `json:"action"` + FileInfo []File `json:"file_info"` + Result string `json:"result"` + CurrentAPIVersion string `json:"current_api_version"` + } `json:"response"` +} + +type MediafireUserInfoResponse struct { + Response struct { + Action string `json:"action"` + UserInfo struct { + Email string `json:"string"` + DisplayName string `json:"display_name"` + UsedStorageSize string `json:"used_storage_size"` + StorageLimit string `json:"storage_limit"` + } `json:"user_info"` + Result string `json:"result"` + CurrentAPIVersion string `json:"current_api_version"` + } `json:"response"` +} diff --git a/drivers/mediafire/util.go b/drivers/mediafire/util.go new file mode 100644 index 00000000..6ded6bdf --- /dev/null +++ b/drivers/mediafire/util.go @@ -0,0 +1,729 @@ +package mediafire + +/* +Package mediafire +Author: Da3zKi7 +Date: 2025-09-11 + +D@' 3z K!7 - The King Of Cracking + +Modifications by ILoveScratch2 +Date: 2025-09-21 + +Date: 2025-09-26 +Final opts by @Suyunjing @j2rong4cn @KirCute @Da3zKi7 +*/ + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/internal/stream" + "github.com/OpenListTeam/OpenList/v4/pkg/errgroup" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "github.com/avast/retry-go" + "github.com/go-resty/resty/v2" +) + +// checkAPIResult validates MediaFire API response result and returns error if not successful +func checkAPIResult(result string) error { + if result != "Success" { + return fmt.Errorf("MediaFire API error: %s", result) + } + return nil +} + +// getSessionToken retrieves and validates session token from MediaFire +func (d *Mediafire) getSessionToken(ctx context.Context) (string, error) { + if d.limiter != nil { + if err := d.limiter.Wait(ctx); err != nil { + return "", fmt.Errorf("rate limit wait failed: %w", err) + } + } + + tokenURL := d.hostBase + "/application/get_session_token.php" + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, nil) + if err != nil { + return "", err + } + + req.Header.Set("Accept", "*/*") + req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("Content-Length", "0") + req.Header.Set("Cookie", d.Cookie) + req.Header.Set("DNT", "1") + req.Header.Set("Origin", d.hostBase) + req.Header.Set("Priority", "u=1, i") + req.Header.Set("Referer", (d.hostBase + "/")) + req.Header.Set("Sec-Ch-Ua", d.secChUa) + req.Header.Set("Sec-Ch-Ua-Mobile", "?0") + req.Header.Set("Sec-Ch-Ua-Platform", d.secChUaPlatform) + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Site", "same-site") + req.Header.Set("User-Agent", d.userAgent) + // req.Header.Set("Connection", "keep-alive") + + resp, err := base.HttpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + // fmt.Printf("getSessionToken :: Raw response: %s\n", string(body)) + // fmt.Printf("getSessionToken :: Parsed response: %+v\n", resp) + + var tokenResp struct { + Response struct { + SessionToken string `json:"session_token"` + } `json:"response"` + } + + if resp.StatusCode == 200 { + if err := json.Unmarshal(body, &tokenResp); err != nil { + return "", err + } + + if tokenResp.Response.SessionToken == "" { + return "", fmt.Errorf("empty session token received") + } + + cookieMap := make(map[string]string) + for _, cookie := range resp.Cookies() { + cookieMap[cookie.Name] = cookie.Value + } + + if len(cookieMap) > 0 { + + var cookies []string + for name, value := range cookieMap { + cookies = append(cookies, fmt.Sprintf("%s=%s", name, value)) + } + d.Cookie = strings.Join(cookies, "; ") + op.MustSaveDriverStorage(d) + + // fmt.Printf("getSessionToken :: Captured cookies: %s\n", d.Cookie) + } + + } else { + return "", fmt.Errorf("getSessionToken :: failed to get session token, status code: %d", resp.StatusCode) + } + + d.SessionToken = tokenResp.Response.SessionToken + + // fmt.Printf("Init :: Obtain Session Token %v", d.SessionToken) + + op.MustSaveDriverStorage(d) + + return d.SessionToken, nil +} + +// renewToken refreshes the current session token when expired +func (d *Mediafire) renewToken(ctx context.Context) error { + query := map[string]string{ + "session_token": d.SessionToken, + "response_format": "json", + } + + var resp MediafireRenewTokenResponse + _, err := d.postForm(ctx, "/user/renew_session_token.php", query, &resp) + if err != nil { + return fmt.Errorf("failed to renew token: %w", err) + } + + // fmt.Printf("getInfo :: Raw response: %s\n", string(body)) + // fmt.Printf("getInfo :: Parsed response: %+v\n", resp) + + if resp.Response.Result != "Success" { + return fmt.Errorf("MediaFire token renewal failed: %s", resp.Response.Result) + } + + d.SessionToken = resp.Response.SessionToken + + // fmt.Printf("Init :: Renew Session Token: %s", resp.Response.Result) + + op.MustSaveDriverStorage(d) + + return nil +} + +func (d *Mediafire) getFiles(ctx context.Context, folderKey string) ([]File, error) { + // Pre-allocate slice with reasonable capacity to reduce memory allocations + files := make([]File, 0, d.ChunkSize*2) // Estimate: ChunkSize for files + folders + hasMore := true + chunkNumber := 1 + + for hasMore { + resp, err := d.getFolderContent(ctx, folderKey, chunkNumber) + if err != nil { + return nil, err + } + + // Process folders and files in single loop to improve cache locality + totalItems := len(resp.Folders) + len(resp.Files) + if cap(files)-len(files) < totalItems { + // Grow slice if needed + newFiles := make([]File, len(files), len(files)+totalItems+int(d.ChunkSize)) + copy(newFiles, files) + files = newFiles + } + + for _, folder := range resp.Folders { + files = append(files, File{ + ID: folder.FolderKey, + Name: folder.Name, + Size: 0, + CreatedUTC: folder.CreatedUTC, + IsFolder: true, + }) + } + + for _, file := range resp.Files { + size, _ := strconv.ParseInt(file.Size, 10, 64) + files = append(files, File{ + ID: file.QuickKey, + Name: file.Filename, + Size: size, + CreatedUTC: file.CreatedUTC, + IsFolder: false, + }) + } + + hasMore = resp.MoreChunks + chunkNumber++ + } + + return files, nil +} + +func (d *Mediafire) getFolderContent(ctx context.Context, folderKey string, chunkNumber int) (*FolderContentResponse, error) { + foldersResp, err := d.getFolderContentByType(ctx, folderKey, "folders", chunkNumber) + if err != nil { + return nil, err + } + + filesResp, err := d.getFolderContentByType(ctx, folderKey, "files", chunkNumber) + if err != nil { + return nil, err + } + + return &FolderContentResponse{ + Folders: foldersResp.Response.FolderContent.Folders, + Files: filesResp.Response.FolderContent.Files, + MoreChunks: foldersResp.Response.FolderContent.MoreChunks == "yes" || filesResp.Response.FolderContent.MoreChunks == "yes", + }, nil +} + +func (d *Mediafire) getFolderContentByType(ctx context.Context, folderKey, contentType string, chunkNumber int) (*MediafireResponse, error) { + data := map[string]string{ + "session_token": d.SessionToken, + "response_format": "json", + "folder_key": folderKey, + "content_type": contentType, + "chunk": strconv.Itoa(chunkNumber), + "chunk_size": strconv.FormatInt(d.ChunkSize, 10), + "details": "yes", + "order_direction": d.OrderDirection, + "order_by": d.OrderBy, + "filter": "", + } + + var resp MediafireResponse + _, err := d.postForm(ctx, "/folder/get_content.php", data, &resp) + if err != nil { + return nil, err + } + + if err := checkAPIResult(resp.Response.Result); err != nil { + return nil, err + } + + return &resp, nil +} + +// fileToObj converts MediaFire file data to model.ObjThumb with thumbnail support +func (d *Mediafire) fileToObj(f File) *model.ObjThumb { + created, _ := time.Parse("2006-01-02T15:04:05Z", f.CreatedUTC) + + var thumbnailURL string + if !f.IsFolder && f.ID != "" { + thumbnailURL = d.hostBase + "/convkey/acaa/" + f.ID + "3g.jpg" + } + + return &model.ObjThumb{ + Object: model.Object{ + ID: f.ID, + // Path: "", + Name: f.Name, + Size: f.Size, + Modified: created, + Ctime: created, + IsFolder: f.IsFolder, + }, + Thumbnail: model.Thumbnail{ + Thumbnail: thumbnailURL, + }, + } +} + +func (d *Mediafire) setCommonHeaders(req *resty.Request) { + req.SetHeaders(map[string]string{ + "Cookie": d.Cookie, + "User-Agent": d.userAgent, + "Origin": d.appBase, + "Referer": d.appBase + "/", + }) +} + +// apiRequest performs HTTP request to MediaFire API with rate limiting and common headers +func (d *Mediafire) apiRequest(ctx context.Context, method, endpoint string, queryParams, formData map[string]string, resp interface{}) ([]byte, error) { + if d.limiter != nil { + if err := d.limiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("rate limit wait failed: %w", err) + } + } + + req := base.RestyClient.R() + req.SetContext(ctx) + d.setCommonHeaders(req) + + // Set query parameters for GET requests + if queryParams != nil { + req.SetQueryParams(queryParams) + } + + // Set form data for POST requests + if formData != nil { + req.SetFormData(formData) + req.SetHeader("Content-Type", "application/x-www-form-urlencoded") + } + + // Set response object if provided + if resp != nil { + req.SetResult(resp) + } + + var res *resty.Response + var err error + + // Execute request based on method + switch method { + case "GET": + res, err = req.Get(d.apiBase + endpoint) + case "POST": + res, err = req.Post(d.apiBase + endpoint) + default: + return nil, fmt.Errorf("unsupported HTTP method: %s", method) + } + + if err != nil { + return nil, err + } + + return res.Body(), nil +} + +func (d *Mediafire) getForm(ctx context.Context, endpoint string, query map[string]string, resp interface{}) ([]byte, error) { + return d.apiRequest(ctx, "GET", endpoint, query, nil, resp) +} + +func (d *Mediafire) postForm(ctx context.Context, endpoint string, data map[string]string, resp interface{}) ([]byte, error) { + return d.apiRequest(ctx, "POST", endpoint, nil, data, resp) +} + +func (d *Mediafire) getDirectDownloadLink(ctx context.Context, fileID string) (string, error) { + data := map[string]string{ + "session_token": d.SessionToken, + "quick_key": fileID, + "link_type": "direct_download", + "response_format": "json", + } + + var resp MediafireDirectDownloadResponse + _, err := d.getForm(ctx, "/file/get_links.php", data, &resp) + if err != nil { + return "", err + } + + if err := checkAPIResult(resp.Response.Result); err != nil { + return "", err + } + + if len(resp.Response.Links) == 0 { + return "", fmt.Errorf("no download links found") + } + + return resp.Response.Links[0].DirectDownload, nil +} + +func (d *Mediafire) uploadCheck(ctx context.Context, filename string, filesize int64, filehash, folderKey string) (*MediafireCheckResponse, error) { + actionToken, err := d.getActionToken(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get action token: %w", err) + } + + query := map[string]string{ + "session_token": actionToken, /* d.SessionToken */ + "filename": filename, + "size": strconv.FormatInt(filesize, 10), + "hash": filehash, + "folder_key": folderKey, + "resumable": "yes", + "response_format": "json", + } + + var resp MediafireCheckResponse + _, err = d.postForm(ctx, "/upload/check.php", query, &resp) + if err != nil { + return nil, err + } + + // fmt.Printf("uploadCheck :: Raw response: %s\n", string(body)) + // fmt.Printf("uploadCheck :: Parsed response: %+v\n", resp) + + // fmt.Printf("uploadCheck :: ResumableUpload section: %+v\n", resp.Response.ResumableUpload) + // fmt.Printf("uploadCheck :: Upload key specifically: '%s'\n", resp.Response.ResumableUpload.UploadKey) + + if err := checkAPIResult(resp.Response.Result); err != nil { + return nil, err + } + + return &resp, nil +} + +func (d *Mediafire) uploadUnits(ctx context.Context, file model.FileStreamer, checkResp *MediafireCheckResponse, filename, fileHash, folderKey string, up driver.UpdateProgress) (string, error) { + unitSize, _ := strconv.ParseInt(checkResp.Response.ResumableUpload.UnitSize, 10, 64) + numUnits, _ := strconv.Atoi(checkResp.Response.ResumableUpload.NumberOfUnits) + uploadKey := checkResp.Response.ResumableUpload.UploadKey + + stringWords := checkResp.Response.ResumableUpload.Bitmap.Words + intWords := make([]int, 0, len(stringWords)) + for _, word := range stringWords { + if intWord, err := strconv.Atoi(word); err == nil { + intWords = append(intWords, intWord) + } + } + + // Intelligent buffer sizing for large files + bufferSize := int(unitSize) + fileSize := file.GetSize() + + // Split in chunks + if fileSize > d.ChunkSize*1024*1024 { + + // Large, use ChunkSize (default = 100MB) + bufferSize = min(int(fileSize), int(d.ChunkSize)*1024*1024) + } else if fileSize > 10*1024*1024 { + // Medium, use full file size for concurrent access + bufferSize = int(fileSize) + } + + // Create stream section reader for efficient chunking + ss, err := stream.NewStreamSectionReader(file, bufferSize, &up) + if err != nil { + return "", err + } + + // Cal minimal parallel upload threads, allows MediaFire resumable upload to rule it over custom value + // If file is big, likely will respect d.UploadThreads instead of MediaFire's suggestion i.e. 5 threads + thread := min(numUnits, d.UploadThreads) + + // Create ordered group for sequential upload processing with retry logic + threadG, uploadCtx := errgroup.NewOrderedGroupWithContext(ctx, thread, + retry.Attempts(3), + retry.Delay(time.Second), + retry.DelayType(retry.BackOffDelay)) + + var finalUploadKey string + var keyMutex sync.Mutex + + fileSize = file.GetSize() + for unitID := range numUnits { + if utils.IsCanceled(uploadCtx) { + break + } + + start := int64(unitID) * unitSize + size := unitSize + if start+size > fileSize { + size = fileSize - start + } + + var reader io.ReadSeeker + var rateLimitedRd io.Reader + var unitHash string + + // Use lifecycle pattern for proper resource management + threadG.GoWithLifecycle(errgroup.Lifecycle{ + Before: func(ctx context.Context) error { + // Skip already uploaded units + if d.isUnitUploaded(intWords, unitID) { + return ss.DiscardSection(start, size) + } + + var err error + reader, err = ss.GetSectionReader(start, size) + if err != nil { + return err + } + rateLimitedRd = driver.NewLimitedUploadStream(ctx, reader) + return nil + }, + Do: func(ctx context.Context) error { + if reader == nil { + return nil // Skip if reader is not initialized (already uploaded) + } + + if unitHash == "" { + reader.Seek(0, io.SeekStart) + var err error + unitHash, err = utils.HashReader(utils.SHA256, reader) + if err != nil { + return err + } + } + reader.Seek(0, io.SeekStart) + + // Perform upload + + actionToken, err := d.getActionToken(ctx) + if err != nil { + return err + } + if d.limiter != nil { + if err := d.limiter.Wait(ctx); err != nil { + return fmt.Errorf("rate limit wait failed: %w", err) + } + } + + url := d.apiBase + "/upload/resumable.php" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, rateLimitedRd) + if err != nil { + return err + } + + q := req.URL.Query() + q.Add("folder_key", folderKey) + q.Add("response_format", "json") + q.Add("session_token", actionToken) + q.Add("key", uploadKey) + req.URL.RawQuery = q.Encode() + + req.Header.Set("x-filehash", fileHash) + req.Header.Set("x-filesize", strconv.FormatInt(fileSize, 10)) + req.Header.Set("x-unit-id", strconv.Itoa(unitID)) + req.Header.Set("x-unit-size", strconv.FormatInt(size, 10)) + req.Header.Set("x-unit-hash", unitHash) + req.Header.Set("x-filename", filename) + req.Header.Set("Content-Type", "application/octet-stream") + req.ContentLength = size + + /* fmt.Printf("Debug resumable upload request:\n") + fmt.Printf(" URL: %s\n", req.URL.String()) + fmt.Printf(" Headers: %+v\n", req.Header) + fmt.Printf(" Unit ID: %d\n", unitID) + fmt.Printf(" Unit Size: %d\n", len(unitData)) + fmt.Printf(" Upload Key: %s\n", uploadKey) + fmt.Printf(" Action Token: %s\n", actionToken) */ + + res, err := base.HttpClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %v", err) + } + + // fmt.Printf("MediaFire resumable upload response (status %d): %s\n", res.StatusCode, string(body)) + + var uploadResp struct { + Response struct { + Doupload struct { + Key string `json:"key"` + } `json:"doupload"` + Result string `json:"result"` + } `json:"response"` + } + + if err := json.Unmarshal(body, &uploadResp); err != nil { + return fmt.Errorf("failed to parse response: %v", err) + } + + if res.StatusCode != 200 { + return fmt.Errorf("resumable upload failed with status %d", res.StatusCode) + } + + // Thread-safe update of final upload key + keyMutex.Lock() + finalUploadKey = uploadResp.Response.Doupload.Key + keyMutex.Unlock() + + return nil + }, + After: func(err error) { + up(float64(threadG.Success()) * 100 / float64(numUnits)) + if reader != nil { + // Cleanup resources + ss.FreeSectionReader(reader) + } + }, + }) + } + + if err := threadG.Wait(); err != nil { + return "", err + } + + return finalUploadKey, nil +} + +/*func (d *Mediafire) uploadSingleUnit(ctx context.Context, file model.FileStreamer, unitID int, unitSize int64, fileHash, filename, uploadKey, folderKey string, fileSize int64) (string, error) { + start := int64(unitID) * unitSize + size := unitSize + + if start+size > fileSize { + size = fileSize - start + } + + unitData := make([]byte, size) + _, err := file.Read(unitData) + if err != nil { + return "", err + } + + return d.resumableUpload(ctx, folderKey, uploadKey, unitData, unitID, fileHash, filename, fileSize) +}*/ + +func (d *Mediafire) getActionToken(ctx context.Context) (string, error) { + if d.actionToken != "" { + return d.actionToken, nil + } + + data := map[string]string{ + "type": "upload", + "lifespan": "1440", + "response_format": "json", + "session_token": d.SessionToken, + } + + var resp MediafireActionTokenResponse + _, err := d.postForm(ctx, "/user/get_action_token.php", data, &resp) + if err != nil { + return "", err + } + + if resp.Response.Result != "Success" { + return "", fmt.Errorf("MediaFire action token failed: %s", resp.Response.Result) + } + + return resp.Response.ActionToken, nil +} + +func (d *Mediafire) pollUpload(ctx context.Context, key string) (*MediafirePollResponse, error) { + actionToken, err := d.getActionToken(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get action token: %w", err) + } + + // fmt.Printf("Debug Key: %+v\n", key) + + query := map[string]string{ + "key": key, + "response_format": "json", + "session_token": actionToken, /* d.SessionToken */ + } + + var resp MediafirePollResponse + _, err = d.postForm(ctx, "/upload/poll_upload.php", query, &resp) + if err != nil { + return nil, err + } + + // fmt.Printf("pollUpload :: Raw response: %s\n", string(body)) + // fmt.Printf("pollUpload :: Parsed response: %+v\n", resp) + + // fmt.Printf("pollUpload :: Debug Result: %+v\n", resp.Response.Result) + + if err := checkAPIResult(resp.Response.Result); err != nil { + return nil, err + } + + return &resp, nil +} + +func (d *Mediafire) isUnitUploaded(words []int, unitID int) bool { + wordIndex := unitID / 16 + bitIndex := unitID % 16 + if wordIndex >= len(words) { + return false + } + return (words[wordIndex]>>bitIndex)&1 == 1 +} + +func (d *Mediafire) getExistingFileInfo(ctx context.Context, fileHash, filename, folderKey string) (*model.ObjThumb, error) { + // First try to find by hash directly (most efficient) + if fileInfo, err := d.getFileByHash(ctx, fileHash); err == nil && fileInfo != nil { + return fileInfo, nil + } + + // If hash search fails, search in the target folder + // This is a fallback method in case the file exists but hash search doesn't work + files, err := d.getFiles(ctx, folderKey) + if err != nil { + return nil, err + } + + for _, file := range files { + if file.Name == filename && !file.IsFolder { + return d.fileToObj(file), nil + } + } + + return nil, fmt.Errorf("existing file not found") +} + +func (d *Mediafire) getFileByHash(ctx context.Context, hash string) (*model.ObjThumb, error) { + query := map[string]string{ + "session_token": d.SessionToken, + "response_format": "json", + "hash": hash, + } + + var resp MediafireFileSearchResponse + _, err := d.postForm(ctx, "/file/get_info.php", query, &resp) + if err != nil { + return nil, err + } + + if resp.Response.Result != "Success" { + return nil, fmt.Errorf("MediaFire file search failed: %s", resp.Response.Result) + } + + if len(resp.Response.FileInfo) == 0 { + return nil, fmt.Errorf("file not found by hash") + } + + file := resp.Response.FileInfo[0] + return d.fileToObj(file), nil +} diff --git a/drivers/onedrive/driver.go b/drivers/onedrive/driver.go index 40adf27c..c3a3020b 100644 --- a/drivers/onedrive/driver.go +++ b/drivers/onedrive/driver.go @@ -22,6 +22,7 @@ type Onedrive struct { AccessToken string root *Object mutex sync.Mutex + ref *Onedrive } func (d *Onedrive) Config() driver.Config { @@ -36,10 +37,22 @@ func (d *Onedrive) Init(ctx context.Context) error { if d.ChunkSize < 1 { d.ChunkSize = 5 } + if d.ref != nil { + return nil + } return d.refreshToken() } +func (d *Onedrive) InitReference(refStorage driver.Driver) error { + if ref, ok := refStorage.(*Onedrive); ok { + d.ref = ref + return nil + } + return errs.NotSupport +} + func (d *Onedrive) Drop(ctx context.Context) error { + d.ref = nil return nil } @@ -207,4 +220,20 @@ func (d *Onedrive) Put(ctx context.Context, dstDir model.Obj, stream model.FileS return err } +func (d *Onedrive) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + if d.DisableDiskUsage { + return nil, errs.NotImplement + } + drive, err := d.getDrive(ctx) + if err != nil { + return nil, err + } + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: drive.Quota.Total, + FreeSpace: drive.Quota.Remaining, + }, + }, nil +} + var _ driver.Driver = (*Onedrive)(nil) diff --git a/drivers/onedrive/meta.go b/drivers/onedrive/meta.go index 91ce16b9..239911f5 100644 --- a/drivers/onedrive/meta.go +++ b/drivers/onedrive/meta.go @@ -7,17 +7,18 @@ import ( type Addition struct { driver.RootPath - Region string `json:"region" type:"select" required:"true" options:"global,cn,us,de" default:"global"` - IsSharepoint bool `json:"is_sharepoint"` - UseOnlineAPI bool `json:"use_online_api" default:"true"` - APIAddress string `json:"api_url_address" default:"https://api.oplist.org/onedrive/renewapi"` - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - RedirectUri string `json:"redirect_uri" required:"true" default:"https://api.oplist.org/onedrive/callback"` - RefreshToken string `json:"refresh_token" required:"true"` - SiteId string `json:"site_id"` - ChunkSize int64 `json:"chunk_size" type:"number" default:"5"` - CustomHost string `json:"custom_host" help:"Custom host for onedrive download link"` + Region string `json:"region" type:"select" required:"true" options:"global,cn,us,de" default:"global"` + IsSharepoint bool `json:"is_sharepoint"` + UseOnlineAPI bool `json:"use_online_api" default:"true"` + APIAddress string `json:"api_url_address" default:"https://api.oplist.org/onedrive/renewapi"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + RedirectUri string `json:"redirect_uri" required:"true" default:"https://api.oplist.org/onedrive/callback"` + RefreshToken string `json:"refresh_token" required:"true"` + SiteId string `json:"site_id"` + ChunkSize int64 `json:"chunk_size" type:"number" default:"5"` + CustomHost string `json:"custom_host" help:"Custom host for onedrive download link"` + DisableDiskUsage bool `json:"disable_disk_usage" default:"false"` } var config = driver.Config{ diff --git a/drivers/onedrive/types.go b/drivers/onedrive/types.go index 2a328ec4..a00241eb 100644 --- a/drivers/onedrive/types.go +++ b/drivers/onedrive/types.go @@ -89,3 +89,15 @@ type FileSystemInfoFacet struct { CreatedDateTime time.Time `json:"createdDateTime,omitempty"` // The UTC date and time the file was created on a client. LastModifiedDateTime time.Time `json:"lastModifiedDateTime,omitempty"` // The UTC date and time the file was last modified on a client. } + +type DriveResp struct { + ID string `json:"id"` + DriveType string `json:"driveType"` + Quota struct { + Deleted uint64 `json:"deleted"` + Remaining uint64 `json:"remaining"` + State string `json:"state"` + Total uint64 `json:"total"` + Used uint64 `json:"used"` + } `json:"quota"` +} diff --git a/drivers/onedrive/util.go b/drivers/onedrive/util.go index 3e853cd1..919e0308 100644 --- a/drivers/onedrive/util.go +++ b/drivers/onedrive/util.go @@ -134,6 +134,9 @@ func (d *Onedrive) _refreshToken() error { } func (d *Onedrive) Request(url string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) { + if d.ref != nil { + return d.ref.Request(url, method, callback, resp) + } req := base.RestyClient.R() req.SetHeader("Authorization", "Bearer "+d.AccessToken) if callback != nil { @@ -295,3 +298,21 @@ func (d *Onedrive) upBig(ctx context.Context, dstDir model.Obj, stream model.Fil } return nil } + +func (d *Onedrive) getDrive(ctx context.Context) (*DriveResp, error) { + var api string + host, _ := onedriveHostMap[d.Region] + if d.IsSharepoint { + api = fmt.Sprintf("%s/v1.0/sites/%s/drive", host.Api, d.SiteId) + } else { + api = fmt.Sprintf("%s/v1.0/me/drive", host.Api) + } + var resp DriveResp + _, err := d.Request(api, http.MethodGet, func(req *resty.Request) { + req.SetContext(ctx) + }, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} diff --git a/drivers/onedrive_app/driver.go b/drivers/onedrive_app/driver.go index 19d0f4cd..f28adde0 100644 --- a/drivers/onedrive_app/driver.go +++ b/drivers/onedrive_app/driver.go @@ -206,4 +206,20 @@ func (d *OnedriveAPP) Put(ctx context.Context, dstDir model.Obj, stream model.Fi return err } +func (d *OnedriveAPP) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + if d.DisableDiskUsage { + return nil, errs.NotImplement + } + drive, err := d.getDrive(ctx) + if err != nil { + return nil, err + } + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: drive.Quota.Total, + FreeSpace: drive.Quota.Remaining, + }, + }, nil +} + var _ driver.Driver = (*OnedriveAPP)(nil) diff --git a/drivers/onedrive_app/meta.go b/drivers/onedrive_app/meta.go index 2e47f6d9..32694e6d 100644 --- a/drivers/onedrive_app/meta.go +++ b/drivers/onedrive_app/meta.go @@ -7,13 +7,14 @@ import ( type Addition struct { driver.RootPath - Region string `json:"region" type:"select" required:"true" options:"global,cn,us,de" default:"global"` - ClientID string `json:"client_id" required:"true"` - ClientSecret string `json:"client_secret" required:"true"` - TenantID string `json:"tenant_id"` - Email string `json:"email"` - ChunkSize int64 `json:"chunk_size" type:"number" default:"5"` - CustomHost string `json:"custom_host" help:"Custom host for onedrive download link"` + Region string `json:"region" type:"select" required:"true" options:"global,cn,us,de" default:"global"` + ClientID string `json:"client_id" required:"true"` + ClientSecret string `json:"client_secret" required:"true"` + TenantID string `json:"tenant_id"` + Email string `json:"email"` + ChunkSize int64 `json:"chunk_size" type:"number" default:"5"` + CustomHost string `json:"custom_host" help:"Custom host for onedrive download link"` + DisableDiskUsage bool `json:"disable_disk_usage" default:"false"` } var config = driver.Config{ diff --git a/drivers/onedrive_app/types.go b/drivers/onedrive_app/types.go index 38e07f32..438eddfb 100644 --- a/drivers/onedrive_app/types.go +++ b/drivers/onedrive_app/types.go @@ -72,3 +72,15 @@ type Files struct { Value []File `json:"value"` NextLink string `json:"@odata.nextLink"` } + +type DriveResp struct { + ID string `json:"id"` + DriveType string `json:"driveType"` + Quota struct { + Deleted uint64 `json:"deleted"` + Remaining uint64 `json:"remaining"` + State string `json:"state"` + Total uint64 `json:"total"` + Used uint64 `json:"used"` + } `json:"quota"` +} diff --git a/drivers/onedrive_app/util.go b/drivers/onedrive_app/util.go index 783760ff..4cc6b9c3 100644 --- a/drivers/onedrive_app/util.go +++ b/drivers/onedrive_app/util.go @@ -209,3 +209,16 @@ func (d *OnedriveAPP) upBig(ctx context.Context, dstDir model.Obj, stream model. } return nil } + +func (d *OnedriveAPP) getDrive(ctx context.Context) (*DriveResp, error) { + host, _ := onedriveHostMap[d.Region] + api := fmt.Sprintf("%s/v1.0/users/%s/drive", host.Api, d.Email) + var resp DriveResp + _, err := d.Request(api, http.MethodGet, func(req *resty.Request) { + req.SetContext(ctx) + }, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} diff --git a/drivers/openlist/driver.go b/drivers/openlist/driver.go index b539bb5a..a5368309 100644 --- a/drivers/openlist/driver.go +++ b/drivers/openlist/driver.go @@ -35,6 +35,9 @@ func (d *OpenList) GetAddition() driver.Additional { func (d *OpenList) Init(ctx context.Context) error { d.Addition.Address = strings.TrimSuffix(d.Addition.Address, "/") + if d.Addition.Address == "https://alist.xiaoya.pro" { + return nil + } var resp common.Resp[MeResp] _, _, err := d.request("/me", http.MethodGet, func(req *resty.Request) { req.SetResult(&resp) @@ -110,19 +113,29 @@ func (d *OpenList) List(ctx context.Context, dir model.Obj, args model.ListArgs) func (d *OpenList) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { var resp common.Resp[FsGetResp] + headers := map[string]string{ + "User-Agent": base.UserAgent, + } // if PassUAToUpsteam is true, then pass the user-agent to the upstream - userAgent := base.UserAgent if d.PassUAToUpsteam { - userAgent = args.Header.Get("user-agent") - if userAgent == "" { - userAgent = base.UserAgent + userAgent := args.Header.Get("user-agent") + if userAgent != "" { + headers["User-Agent"] = base.UserAgent + } + } + // if PassIPToUpsteam is true, then pass the ip address to the upstream + if d.PassIPToUpsteam { + ip := args.IP + if ip != "" { + headers["X-Forwarded-For"] = ip + headers["X-Real-Ip"] = ip } } _, _, err := d.request("/fs/get", http.MethodPost, func(req *resty.Request) { req.SetResult(&resp).SetBody(FsGetReq{ Path: file.GetPath(), Password: d.MetaPassword, - }).SetHeader("user-agent", userAgent) + }).SetHeaders(headers) }) if err != nil { return nil, err @@ -355,8 +368,15 @@ func (d *OpenList) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.O return err } -//func (d *OpenList) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) { -// return nil, errs.NotSupport -//} +func (d *OpenList) ResolveLinkCacheMode(_ string) driver.LinkCacheMode { + var mode driver.LinkCacheMode + if d.PassIPToUpsteam { + mode |= driver.LinkCacheIP + } + if d.PassUAToUpsteam { + mode |= driver.LinkCacheUA + } + return mode +} var _ driver.Driver = (*OpenList)(nil) diff --git a/drivers/openlist/meta.go b/drivers/openlist/meta.go index fc76c142..10c950a3 100644 --- a/drivers/openlist/meta.go +++ b/drivers/openlist/meta.go @@ -12,6 +12,7 @@ type Addition struct { Username string `json:"username"` Password string `json:"password"` Token string `json:"token"` + PassIPToUpsteam bool `json:"pass_ip_to_upsteam" default:"true"` PassUAToUpsteam bool `json:"pass_ua_to_upsteam" default:"true"` ForwardArchiveReq bool `json:"forward_archive_requests" default:"true"` } @@ -22,6 +23,7 @@ var config = driver.Config{ DefaultRoot: "/", CheckStatus: true, ProxyRangeOption: true, + LinkCacheMode: driver.LinkCacheAuto, } func init() { diff --git a/drivers/pikpak/driver.go b/drivers/pikpak/driver.go index 015a8a9d..df2cce3c 100644 --- a/drivers/pikpak/driver.go +++ b/drivers/pikpak/driver.go @@ -36,7 +36,6 @@ func (d *PikPak) GetAddition() driver.Additional { } func (d *PikPak) Init(ctx context.Context) (err error) { - if d.Common == nil { d.Common = &Common{ client: base.NewRestyClient(), @@ -251,7 +250,7 @@ func (d *PikPak) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr } params := resp.Resumable.Params - //endpoint := strings.Join(strings.Split(params.Endpoint, ".")[1:], ".") + // endpoint := strings.Join(strings.Split(params.Endpoint, ".")[1:], ".") // web 端上传 返回的endpoint 为 `mypikpak.net` | android 端上传 返回的endpoint 为 `vip-lixian-07.mypikpak.net`· if d.Addition.Platform == "android" { params.Endpoint = "mypikpak.net" @@ -264,6 +263,27 @@ func (d *PikPak) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr return d.UploadByMultipart(ctx, ¶ms, stream.GetSize(), stream, up) } +func (d *PikPak) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + var about AboutResponse + _, err := d.request("https://api-drive.mypikpak.com/drive/v1/about", http.MethodGet, func(req *resty.Request) { + req.SetContext(ctx) + }, &about) + if err != nil { + return nil, err + } + total, err := strconv.ParseUint(about.Quota.Limit, 10, 64) + if err != nil { + return nil, err + } + used, err := strconv.ParseUint(about.Quota.Usage, 10, 64) + if err != nil { + return nil, err + } + return &model.StorageDetails{ + DiskUsage: driver.DiskUsageFromUsedAndTotal(used, total), + }, nil +} + // 离线下载文件 func (d *PikPak) OfflineDownload(ctx context.Context, fileUrl string, parentDir model.Obj, fileName string) (*OfflineTask, error) { requestBody := base.Json{ @@ -282,7 +302,6 @@ func (d *PikPak) OfflineDownload(ctx context.Context, fileUrl string, parentDir req.SetContext(ctx). SetBody(requestBody) }, &resp) - if err != nil { return nil, err } @@ -329,7 +348,6 @@ func (d *PikPak) OfflineList(ctx context.Context, nextPageToken string, phase [] req.SetContext(ctx). SetQueryParams(params) }, &resp) - if err != nil { return nil, fmt.Errorf("failed to get offline list: %w", err) } diff --git a/drivers/pikpak/types.go b/drivers/pikpak/types.go index bea766fe..6ae78a45 100644 --- a/drivers/pikpak/types.go +++ b/drivers/pikpak/types.go @@ -78,7 +78,7 @@ type Media struct { type UploadTaskData struct { UploadType string `json:"upload_type"` - //UPLOAD_TYPE_RESUMABLE + // UPLOAD_TYPE_RESUMABLE Resumable *struct { Kind string `json:"kind"` Params S3Params `json:"params"` @@ -195,3 +195,15 @@ type CaptchaTokenResponse struct { ExpiresIn int64 `json:"expires_in"` Url string `json:"url"` } + +type AboutResponse struct { + Quota struct { + Limit string `json:"limit"` + Usage string `json:"usage"` + UsageInTrash string `json:"usage_in_trash"` + IsUnlimited bool `json:"is_unlimited"` + Complimentary string `json:"complimentary"` + } `json:"quota"` + ExpiresAt string `json:"expires_at"` + UserType int `json:"user_type"` +} diff --git a/drivers/proton_drive/driver.go b/drivers/proton_drive/driver.go new file mode 100644 index 00000000..06aa645d --- /dev/null +++ b/drivers/proton_drive/driver.go @@ -0,0 +1,290 @@ +package protondrive + +/* +Package protondrive +Author: Da3zKi7 +Date: 2025-09-18 + +Thanks to @henrybear327 for modded go-proton-api & Proton-API-Bridge + +The power of open-source, the force of teamwork and the magic of reverse engineering! + + +D@' 3z K!7 - The King Of Cracking + +Да здравствует Родина)) +*/ + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/internal/setting" + "github.com/OpenListTeam/OpenList/v4/internal/stream" + "github.com/OpenListTeam/OpenList/v4/pkg/http_range" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "github.com/ProtonMail/gopenpgp/v2/crypto" + proton_api_bridge "github.com/henrybear327/Proton-API-Bridge" + "github.com/henrybear327/Proton-API-Bridge/common" + "github.com/henrybear327/go-proton-api" +) + +type ProtonDrive struct { + model.Storage + Addition + + protonDrive *proton_api_bridge.ProtonDrive + + apiBase string + appVersion string + protonJson string + userAgent string + sdkVersion string + webDriveAV string + + c *proton.Client + + // userKR *crypto.KeyRing + addrKRs map[string]*crypto.KeyRing + addrData map[string]proton.Address + + MainShare *proton.Share + + DefaultAddrKR *crypto.KeyRing + MainShareKR *crypto.KeyRing +} + +func (d *ProtonDrive) Config() driver.Config { + return config +} + +func (d *ProtonDrive) GetAddition() driver.Additional { + return &d.Addition +} + +func (d *ProtonDrive) Init(ctx context.Context) (err error) { + defer func() { + if r := recover(); err == nil && r != nil { + err = fmt.Errorf("ProtonDrive initialization panic: %v", r) + } + }() + + if d.Email == "" { + return fmt.Errorf("email is required") + } + if d.Password == "" { + return fmt.Errorf("password is required") + } + + config := &common.Config{ + AppVersion: d.appVersion, + UserAgent: d.userAgent, + FirstLoginCredential: &common.FirstLoginCredentialData{ + Username: d.Email, + Password: d.Password, + TwoFA: d.TwoFACode, + }, + EnableCaching: true, + ConcurrentBlockUploadCount: setting.GetInt(conf.TaskUploadThreadsNum, conf.Conf.Tasks.Upload.Workers), + //ConcurrentFileCryptoCount: 2, + UseReusableLogin: d.UseReusableLogin && d.ReusableCredential != (common.ReusableCredentialData{}), + ReplaceExistingDraft: true, + ReusableCredential: &d.ReusableCredential, + } + + protonDrive, _, err := proton_api_bridge.NewProtonDrive( + ctx, + config, + d.authHandler, + func() {}, + ) + + if err != nil && config.UseReusableLogin { + config.UseReusableLogin = false + protonDrive, _, err = proton_api_bridge.NewProtonDrive(ctx, + config, + d.authHandler, + func() {}, + ) + if err == nil { + op.MustSaveDriverStorage(d) + } + } + + if err != nil { + return fmt.Errorf("failed to initialize ProtonDrive: %w", err) + } + + if err := d.initClient(ctx); err != nil { + return err + } + + d.protonDrive = protonDrive + d.MainShare = protonDrive.MainShare + if d.RootFolderID == "root" || d.RootFolderID == "" { + d.RootFolderID = protonDrive.RootLink.LinkID + } + d.MainShareKR = protonDrive.MainShareKR + d.DefaultAddrKR = protonDrive.DefaultAddrKR + + return nil +} + +func (d *ProtonDrive) Drop(ctx context.Context) error { + return nil +} + +func (d *ProtonDrive) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + entries, err := d.protonDrive.ListDirectory(ctx, dir.GetID()) + if err != nil { + return nil, fmt.Errorf("failed to list directory: %w", err) + } + + objects := make([]model.Obj, 0, len(entries)) + for _, entry := range entries { + obj := &model.Object{ + ID: entry.Link.LinkID, + Name: entry.Name, + Size: entry.Link.Size, + Modified: time.Unix(entry.Link.ModifyTime, 0), + IsFolder: entry.IsFolder, + } + objects = append(objects, obj) + } + + return objects, nil +} + +func (d *ProtonDrive) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + link, err := d.getLink(ctx, file.GetID()) + if err != nil { + return nil, fmt.Errorf("failed get file link: %+v", err) + } + fileSystemAttrs, err := d.protonDrive.GetActiveRevisionAttrs(ctx, link) + if err != nil { + return nil, fmt.Errorf("failed get file revision: %+v", err) + } + // 解密后的文件大小 + size := fileSystemAttrs.Size + + rangeReaderFunc := func(rangeCtx context.Context, httpRange http_range.Range) (io.ReadCloser, error) { + length := httpRange.Length + if length < 0 || httpRange.Start+length > size { + length = size - httpRange.Start + } + reader, _, _, err := d.protonDrive.DownloadFile(rangeCtx, link, httpRange.Start) + if err != nil { + return nil, fmt.Errorf("failed start download: %+v", err) + } + return utils.ReadCloser{ + Reader: io.LimitReader(reader, length), + Closer: reader, + }, nil + } + + expiration := time.Minute + return &model.Link{ + RangeReader: &model.FileRangeReader{ + RangeReaderIF: stream.RateLimitRangeReaderFunc(rangeReaderFunc), + }, + ContentLength: size, + Expiration: &expiration, + }, nil +} + +func (d *ProtonDrive) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { + id, err := d.protonDrive.CreateNewFolderByID(ctx, parentDir.GetID(), dirName) + if err != nil { + return nil, fmt.Errorf("failed to create directory: %w", err) + } + + newDir := &model.Object{ + ID: id, + Name: dirName, + IsFolder: true, + Modified: time.Now(), + } + return newDir, nil +} + +func (d *ProtonDrive) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + return d.DirectMove(ctx, srcObj, dstDir) +} + +func (d *ProtonDrive) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { + if d.protonDrive == nil { + return nil, fmt.Errorf("protonDrive bridge is nil") + } + + return d.DirectRename(ctx, srcObj, newName) +} + +func (d *ProtonDrive) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + if srcObj.IsDir() { + return nil, fmt.Errorf("directory copy not supported") + } + + srcLink, err := d.getLink(ctx, srcObj.GetID()) + if err != nil { + return nil, err + } + + reader, linkSize, fileSystemAttrs, err := d.protonDrive.DownloadFile(ctx, srcLink, 0) + if err != nil { + return nil, fmt.Errorf("failed to download source file: %w", err) + } + defer reader.Close() + + actualSize := linkSize + if fileSystemAttrs != nil && fileSystemAttrs.Size > 0 { + actualSize = fileSystemAttrs.Size + } + + file := &stream.FileStream{ + Ctx: ctx, + Obj: &model.Object{ + Name: srcObj.GetName(), + // Use the accurate and real size + Size: actualSize, + Modified: srcObj.ModTime(), + }, + Reader: reader, + } + defer file.Close() + return d.Put(ctx, dstDir, file, func(percentage float64) {}) +} + +func (d *ProtonDrive) Remove(ctx context.Context, obj model.Obj) error { + if obj.IsDir() { + return d.protonDrive.MoveFolderToTrashByID(ctx, obj.GetID(), false) + } else { + return d.protonDrive.MoveFileToTrashByID(ctx, obj.GetID()) + } +} + +func (d *ProtonDrive) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + return d.uploadFile(ctx, dstDir.GetID(), file, up) +} + +func (d *ProtonDrive) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + about, err := d.protonDrive.About(ctx) + if err != nil { + return nil, err + } + total := uint64(about.MaxSpace) + free := total - uint64(about.UsedSpace) + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: total, + FreeSpace: free, + }, + }, nil +} + +var _ driver.Driver = (*ProtonDrive)(nil) diff --git a/drivers/proton_drive/meta.go b/drivers/proton_drive/meta.go new file mode 100644 index 00000000..acf65677 --- /dev/null +++ b/drivers/proton_drive/meta.go @@ -0,0 +1,56 @@ +package protondrive + +/* +Package protondrive +Author: Da3zKi7 +Date: 2025-09-18 + +Thanks to @henrybear327 for modded go-proton-api & Proton-API-Bridge + +The power of open-source, the force of teamwork and the magic of reverse engineering! + + +D@' 3z K!7 - The King Of Cracking + +Да здравствует Родина)) +*/ + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/henrybear327/Proton-API-Bridge/common" +) + +type Addition struct { + driver.RootID + Email string `json:"email" required:"true" type:"string"` + Password string `json:"password" required:"true" type:"string"` + TwoFACode string `json:"two_fa_code" type:"string"` + ChunkSize int64 `json:"chunk_size" type:"number" default:"100"` + UseReusableLogin bool `json:"use_reusable_login" type:"bool" default:"true" help:"Use reusable login credentials instead of username/password"` + ReusableCredential common.ReusableCredentialData +} + +var config = driver.Config{ + Name: "ProtonDrive", + LocalSort: true, + OnlyProxy: true, + DefaultRoot: "root", + NoLinkURL: true, +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &ProtonDrive{ + Addition: Addition{ + UseReusableLogin: true, + }, + apiBase: "https://drive.proton.me/api", + appVersion: "windows-drive@1.11.3+rclone+proton", + protonJson: "application/vnd.protonmail.v1+json", + sdkVersion: "js@0.3.0", + userAgent: "ProtonDrive/v1.70.0 (Windows NT 10.0.22000; Win64; x64)", + webDriveAV: "web-drive@5.2.0+0f69f7a8", + } + }) +} diff --git a/drivers/proton_drive/types.go b/drivers/proton_drive/types.go new file mode 100644 index 00000000..ed6dcd19 --- /dev/null +++ b/drivers/proton_drive/types.go @@ -0,0 +1,38 @@ +package protondrive + +/* +Package protondrive +Author: Da3zKi7 +Date: 2025-09-18 + +Thanks to @henrybear327 for modded go-proton-api & Proton-API-Bridge + +The power of open-source, the force of teamwork and the magic of reverse engineering! + + +D@' 3z K!7 - The King Of Cracking + +Да здравствует Родина)) +*/ + +type MoveRequest struct { + ParentLinkID string `json:"ParentLinkID"` + NodePassphrase string `json:"NodePassphrase"` + NodePassphraseSignature *string `json:"NodePassphraseSignature"` + Name string `json:"Name"` + NameSignatureEmail string `json:"NameSignatureEmail"` + Hash string `json:"Hash"` + OriginalHash string `json:"OriginalHash"` + ContentHash *string `json:"ContentHash"` // Maybe null +} + +type RenameRequest struct { + Name string `json:"Name"` // PGP encrypted name + NameSignatureEmail string `json:"NameSignatureEmail"` // User's signature email + Hash string `json:"Hash"` // New name hash + OriginalHash string `json:"OriginalHash"` // Current name hash +} + +type RenameResponse struct { + Code int `json:"Code"` +} diff --git a/drivers/proton_drive/util.go b/drivers/proton_drive/util.go new file mode 100644 index 00000000..a59bb6cb --- /dev/null +++ b/drivers/proton_drive/util.go @@ -0,0 +1,670 @@ +package protondrive + +/* +Package protondrive +Author: Da3zKi7 +Date: 2025-09-18 + +Thanks to @henrybear327 for modded go-proton-api & Proton-API-Bridge + +The power of open-source, the force of teamwork and the magic of reverse engineering! + + +D@' 3z K!7 - The King Of Cracking + +Да здравствует Родина)) +*/ + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/internal/stream" + "github.com/ProtonMail/gopenpgp/v2/crypto" + "github.com/henrybear327/go-proton-api" +) + +func (d *ProtonDrive) uploadFile(ctx context.Context, parentLinkID string, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + _, err := d.getLink(ctx, parentLinkID) + if err != nil { + return nil, fmt.Errorf("failed to get parent link: %w", err) + } + + var reader io.Reader + // Use buffered reader with larger buffer for better performance + var bufferSize int + + // File > 100MB (default) + if file.GetSize() > d.ChunkSize*1024*1024 { + // 256KB for large files + bufferSize = 256 * 1024 + // File > 10MB + } else if file.GetSize() > 10*1024*1024 { + // 128KB for medium files + bufferSize = 128 * 1024 + } else { + // 64KB for small files + bufferSize = 64 * 1024 + } + + // reader = bufio.NewReader(file) + reader = bufio.NewReaderSize(file, bufferSize) + reader = &driver.ReaderUpdatingProgress{ + Reader: &stream.SimpleReaderWithSize{ + Reader: reader, + Size: file.GetSize(), + }, + UpdateProgress: up, + } + reader = driver.NewLimitedUploadStream(ctx, reader) + + id, _, err := d.protonDrive.UploadFileByReader(ctx, parentLinkID, file.GetName(), file.ModTime(), reader, 0) + if err != nil { + return nil, fmt.Errorf("failed to upload file: %w", err) + } + + return &model.Object{ + ID: id, + Name: file.GetName(), + Size: file.GetSize(), + Modified: file.ModTime(), + IsFolder: false, + }, nil +} + +func (d *ProtonDrive) encryptFileName(ctx context.Context, name string, parentLinkID string) (string, error) { + parentLink, err := d.getLink(ctx, parentLinkID) + if err != nil { + return "", fmt.Errorf("failed to get parent link: %w", err) + } + + // Get parent node keyring + parentNodeKR, err := d.getLinkKR(ctx, parentLink) + if err != nil { + return "", fmt.Errorf("failed to get parent keyring: %w", err) + } + + // Temporary file (request) + tempReq := proton.CreateFileReq{ + SignatureAddress: d.MainShare.Creator, + } + + // Encrypt the filename + err = tempReq.SetName(name, d.DefaultAddrKR, parentNodeKR) + if err != nil { + return "", fmt.Errorf("failed to encrypt filename: %w", err) + } + + return tempReq.Name, nil +} + +func (d *ProtonDrive) generateFileNameHash(ctx context.Context, name string, parentLinkID string) (string, error) { + parentLink, err := d.getLink(ctx, parentLinkID) + if err != nil { + return "", fmt.Errorf("failed to get parent link: %w", err) + } + + // Get parent node keyring + parentNodeKR, err := d.getLinkKR(ctx, parentLink) + if err != nil { + return "", fmt.Errorf("failed to get parent keyring: %w", err) + } + + signatureVerificationKR, err := d.getSignatureVerificationKeyring([]string{parentLink.SignatureEmail}, parentNodeKR) + if err != nil { + return "", fmt.Errorf("failed to get signature verification keyring: %w", err) + } + + parentHashKey, err := parentLink.GetHashKey(parentNodeKR, signatureVerificationKR) + if err != nil { + return "", fmt.Errorf("failed to get parent hash key: %w", err) + } + + nameHash, err := proton.GetNameHash(name, parentHashKey) + if err != nil { + return "", fmt.Errorf("failed to generate name hash: %w", err) + } + + return nameHash, nil +} + +func (d *ProtonDrive) getOriginalNameHash(link *proton.Link) (string, error) { + if link == nil { + return "", fmt.Errorf("link cannot be nil") + } + + if link.Hash == "" { + return "", fmt.Errorf("link hash is empty") + } + + return link.Hash, nil +} + +func (d *ProtonDrive) getLink(ctx context.Context, linkID string) (*proton.Link, error) { + if linkID == "" { + return nil, fmt.Errorf("linkID cannot be empty") + } + + link, err := d.c.GetLink(ctx, d.MainShare.ShareID, linkID) + if err != nil { + return nil, err + } + + return &link, nil +} + +func (d *ProtonDrive) getLinkKR(ctx context.Context, link *proton.Link) (*crypto.KeyRing, error) { + if link == nil { + return nil, fmt.Errorf("link cannot be nil") + } + + // Root Link or Root Dir + if link.ParentLinkID == "" { + signatureVerificationKR, err := d.getSignatureVerificationKeyring([]string{link.SignatureEmail}) + if err != nil { + return nil, err + } + return link.GetKeyRing(d.MainShareKR, signatureVerificationKR) + } + + // Get parent keyring recursively + parentLink, err := d.getLink(ctx, link.ParentLinkID) + if err != nil { + return nil, err + } + + parentNodeKR, err := d.getLinkKR(ctx, parentLink) + if err != nil { + return nil, err + } + + signatureVerificationKR, err := d.getSignatureVerificationKeyring([]string{link.SignatureEmail}) + if err != nil { + return nil, err + } + + return link.GetKeyRing(parentNodeKR, signatureVerificationKR) +} + +var ( + ErrKeyPassOrSaltedKeyPassMustBeNotNil = errors.New("either keyPass or saltedKeyPass must be not nil") + ErrFailedToUnlockUserKeys = errors.New("failed to unlock user keys") +) + +func getAccountKRs(ctx context.Context, c *proton.Client, keyPass, saltedKeyPass []byte) (*crypto.KeyRing, map[string]*crypto.KeyRing, map[string]proton.Address, []byte, error) { + user, err := c.GetUser(ctx) + if err != nil { + return nil, nil, nil, nil, err + } + // fmt.Printf("user %#v", user) + + addrsArr, err := c.GetAddresses(ctx) + if err != nil { + return nil, nil, nil, nil, err + } + // fmt.Printf("addr %#v", addr) + + if saltedKeyPass == nil { + if keyPass == nil { + return nil, nil, nil, nil, ErrKeyPassOrSaltedKeyPassMustBeNotNil + } + + // Due to limitations, salts are stored using cacheCredentialToFile + salts, err := c.GetSalts(ctx) + if err != nil { + return nil, nil, nil, nil, err + } + // fmt.Printf("salts %#v", salts) + + saltedKeyPass, err = salts.SaltForKey(keyPass, user.Keys.Primary().ID) + if err != nil { + return nil, nil, nil, nil, err + } + // fmt.Printf("saltedKeyPass ok") + } + + userKR, addrKRs, err := proton.Unlock(user, addrsArr, saltedKeyPass, nil) + if err != nil { + return nil, nil, nil, nil, err + } else if userKR.CountDecryptionEntities() == 0 { + return nil, nil, nil, nil, ErrFailedToUnlockUserKeys + } + + addrs := make(map[string]proton.Address) + for _, addr := range addrsArr { + addrs[addr.Email] = addr + } + + return userKR, addrKRs, addrs, saltedKeyPass, nil +} + +func (d *ProtonDrive) getSignatureVerificationKeyring(emailAddresses []string, verificationAddrKRs ...*crypto.KeyRing) (*crypto.KeyRing, error) { + ret, err := crypto.NewKeyRing(nil) + if err != nil { + return nil, err + } + + for _, emailAddress := range emailAddresses { + if addr, ok := d.addrData[emailAddress]; ok { + if addrKR, exists := d.addrKRs[addr.ID]; exists { + err = d.addKeysFromKR(ret, addrKR) + if err != nil { + return nil, err + } + } + } + } + + for _, kr := range verificationAddrKRs { + err = d.addKeysFromKR(ret, kr) + if err != nil { + return nil, err + } + } + + if ret.CountEntities() == 0 { + return nil, fmt.Errorf("no keyring for signature verification") + } + + return ret, nil +} + +func (d *ProtonDrive) addKeysFromKR(kr *crypto.KeyRing, newKRs ...*crypto.KeyRing) error { + for i := range newKRs { + for _, key := range newKRs[i].GetKeys() { + err := kr.AddKey(key) + if err != nil { + return err + } + } + } + return nil +} + +func (d *ProtonDrive) DirectRename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { + // fmt.Printf("DEBUG DirectRename: path=%s, newName=%s", srcObj.GetPath(), newName) + + if d.MainShare == nil || d.DefaultAddrKR == nil { + return nil, fmt.Errorf("missing required fields: MainShare=%v, DefaultAddrKR=%v", + d.MainShare != nil, d.DefaultAddrKR != nil) + } + + if d.protonDrive == nil { + return nil, fmt.Errorf("protonDrive bridge is nil") + } + + srcLink, err := d.getLink(ctx, srcObj.GetID()) + if err != nil { + return nil, fmt.Errorf("failed to find source: %w", err) + } + + parentLinkID := srcLink.ParentLinkID + if parentLinkID == "" { + return nil, fmt.Errorf("cannot rename root folder") + } + + encryptedName, err := d.encryptFileName(ctx, newName, parentLinkID) + if err != nil { + return nil, fmt.Errorf("failed to encrypt filename: %w", err) + } + + newHash, err := d.generateFileNameHash(ctx, newName, parentLinkID) + if err != nil { + return nil, fmt.Errorf("failed to generate new hash: %w", err) + } + + originalHash, err := d.getOriginalNameHash(srcLink) + if err != nil { + return nil, fmt.Errorf("failed to get original hash: %w", err) + } + + renameReq := RenameRequest{ + Name: encryptedName, + NameSignatureEmail: d.MainShare.Creator, + Hash: newHash, + OriginalHash: originalHash, + } + + err = d.executeRenameAPI(ctx, srcLink.LinkID, renameReq) + if err != nil { + return nil, fmt.Errorf("rename API call failed: %w", err) + } + + return &model.Object{ + ID: srcLink.LinkID, + Name: newName, + Size: srcObj.GetSize(), + Modified: srcObj.ModTime(), + IsFolder: srcObj.IsDir(), + }, nil +} + +func (d *ProtonDrive) executeRenameAPI(ctx context.Context, linkID string, req RenameRequest) error { + renameURL := fmt.Sprintf(d.apiBase+"/drive/v2/volumes/%s/links/%s/rename", + d.MainShare.VolumeID, linkID) + + reqBody, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal rename request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "PUT", renameURL, bytes.NewReader(reqBody)) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", d.protonJson) + httpReq.Header.Set("X-Pm-Appversion", d.webDriveAV) + httpReq.Header.Set("X-Pm-Drive-Sdk-Version", d.sdkVersion) + httpReq.Header.Set("X-Pm-Uid", d.ReusableCredential.UID) + httpReq.Header.Set("Authorization", "Bearer "+d.ReusableCredential.AccessToken) + + client := &http.Client{} + resp, err := client.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to execute rename request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("rename failed with status %d", resp.StatusCode) + } + + var renameResp RenameResponse + if err := json.NewDecoder(resp.Body).Decode(&renameResp); err != nil { + return fmt.Errorf("failed to decode rename response: %w", err) + } + + if renameResp.Code != 1000 { + return fmt.Errorf("rename failed with code %d", renameResp.Code) + } + + return nil +} + +func (d *ProtonDrive) executeMoveAPI(ctx context.Context, linkID string, req MoveRequest) error { + // fmt.Printf("DEBUG Move Request - Name: %s\n", req.Name) + // fmt.Printf("DEBUG Move Request - Hash: %s\n", req.Hash) + // fmt.Printf("DEBUG Move Request - OriginalHash: %s\n", req.OriginalHash) + // fmt.Printf("DEBUG Move Request - ParentLinkID: %s\n", req.ParentLinkID) + + // fmt.Printf("DEBUG Move Request - Name length: %d\n", len(req.Name)) + // fmt.Printf("DEBUG Move Request - NameSignatureEmail: %s\n", req.NameSignatureEmail) + // fmt.Printf("DEBUG Move Request - ContentHash: %v\n", req.ContentHash) + // fmt.Printf("DEBUG Move Request - NodePassphrase length: %d\n", len(req.NodePassphrase)) + // fmt.Printf("DEBUG Move Request - NodePassphraseSignature length: %d\n", len(req.NodePassphraseSignature)) + + // fmt.Printf("DEBUG Move Request - SrcLinkID: %s\n", linkID) + // fmt.Printf("DEBUG Move Request - DstParentLinkID: %s\n", req.ParentLinkID) + // fmt.Printf("DEBUG Move Request - ShareID: %s\n", d.MainShare.ShareID) + + srcLink, _ := d.getLink(ctx, linkID) + if srcLink != nil && srcLink.ParentLinkID == req.ParentLinkID { + return fmt.Errorf("cannot move to same parent directory") + } + + moveURL := fmt.Sprintf(d.apiBase+"/drive/v2/volumes/%s/links/%s/move", + d.MainShare.VolumeID, linkID) + + reqBody, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal move request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "PUT", moveURL, bytes.NewReader(reqBody)) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+d.ReusableCredential.AccessToken) + httpReq.Header.Set("Accept", d.protonJson) + httpReq.Header.Set("X-Pm-Appversion", d.webDriveAV) + httpReq.Header.Set("X-Pm-Drive-Sdk-Version", d.sdkVersion) + httpReq.Header.Set("X-Pm-Uid", d.ReusableCredential.UID) + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to execute move request: %w", err) + } + defer resp.Body.Close() + + var moveResp RenameResponse + if err := json.NewDecoder(resp.Body).Decode(&moveResp); err != nil { + return fmt.Errorf("failed to decode move response: %w", err) + } + + if moveResp.Code != 1000 { + return fmt.Errorf("move operation failed with code: %d", moveResp.Code) + } + + return nil +} + +func (d *ProtonDrive) DirectMove(ctx context.Context, srcObj model.Obj, dstDir model.Obj) (model.Obj, error) { + // fmt.Printf("DEBUG DirectMove: srcPath=%s, dstPath=%s", srcObj.GetPath(), dstDir.GetPath()) + + srcLink, err := d.getLink(ctx, srcObj.GetID()) + if err != nil { + return nil, fmt.Errorf("failed to find source: %w", err) + } + + dstParentLinkID := dstDir.GetID() + + if srcObj.IsDir() { + // Check if destination is a descendant of source + if err := d.checkCircularMove(ctx, srcLink.LinkID, dstParentLinkID); err != nil { + return nil, err + } + } + + // Encrypt the filename for the new location + encryptedName, err := d.encryptFileName(ctx, srcObj.GetName(), dstParentLinkID) + if err != nil { + return nil, fmt.Errorf("failed to encrypt filename: %w", err) + } + + newHash, err := d.generateNameHash(ctx, srcObj.GetName(), dstParentLinkID) + if err != nil { + return nil, fmt.Errorf("failed to generate new hash: %w", err) + } + + originalHash, err := d.getOriginalNameHash(srcLink) + if err != nil { + return nil, fmt.Errorf("failed to get original hash: %w", err) + } + + // Re-encrypt node passphrase for new parent context + reencryptedPassphrase, err := d.reencryptNodePassphrase(ctx, srcLink, dstParentLinkID) + if err != nil { + return nil, fmt.Errorf("failed to re-encrypt node passphrase: %w", err) + } + + moveReq := MoveRequest{ + ParentLinkID: dstParentLinkID, + NodePassphrase: reencryptedPassphrase, + Name: encryptedName, + NameSignatureEmail: d.MainShare.Creator, + Hash: newHash, + OriginalHash: originalHash, + ContentHash: nil, + + // *** Causes rejection *** + /* NodePassphraseSignature: srcLink.NodePassphraseSignature, */ + } + + //fmt.Printf("DEBUG MoveRequest validation:\n") + //fmt.Printf(" Name length: %d\n", len(moveReq.Name)) + //fmt.Printf(" Hash: %s\n", moveReq.Hash) + //fmt.Printf(" OriginalHash: %s\n", moveReq.OriginalHash) + //fmt.Printf(" NodePassphrase length: %d\n", len(moveReq.NodePassphrase)) + /* fmt.Printf(" NodePassphraseSignature length: %d\n", len(moveReq.NodePassphraseSignature)) */ + //fmt.Printf(" NameSignatureEmail: %s\n", moveReq.NameSignatureEmail) + + err = d.executeMoveAPI(ctx, srcLink.LinkID, moveReq) + if err != nil { + return nil, fmt.Errorf("move API call failed: %w", err) + } + + return &model.Object{ + ID: srcLink.LinkID, + Name: srcObj.GetName(), + Size: srcObj.GetSize(), + Modified: srcObj.ModTime(), + IsFolder: srcObj.IsDir(), + }, nil +} + +func (d *ProtonDrive) reencryptNodePassphrase(ctx context.Context, srcLink *proton.Link, dstParentLinkID string) (string, error) { + // Get source parent link with metadata + srcParentLink, err := d.getLink(ctx, srcLink.ParentLinkID) + if err != nil { + return "", fmt.Errorf("failed to get source parent link: %w", err) + } + + // Get source parent keyring using link object + srcParentKR, err := d.getLinkKR(ctx, srcParentLink) + if err != nil { + return "", fmt.Errorf("failed to get source parent keyring: %w", err) + } + + // Get destination parent link with metadata + dstParentLink, err := d.getLink(ctx, dstParentLinkID) + if err != nil { + return "", fmt.Errorf("failed to get destination parent link: %w", err) + } + + // Get destination parent keyring using link object + dstParentKR, err := d.getLinkKR(ctx, dstParentLink) + if err != nil { + return "", fmt.Errorf("failed to get destination parent keyring: %w", err) + } + + // Re-encrypt the node passphrase from source parent context to destination parent context + reencryptedPassphrase, err := reencryptKeyPacket(srcParentKR, dstParentKR, d.DefaultAddrKR, srcLink.NodePassphrase) + if err != nil { + return "", fmt.Errorf("failed to re-encrypt key packet: %w", err) + } + + return reencryptedPassphrase, nil +} + +func (d *ProtonDrive) generateNameHash(ctx context.Context, name string, parentLinkID string) (string, error) { + parentLink, err := d.getLink(ctx, parentLinkID) + if err != nil { + return "", fmt.Errorf("failed to get parent link: %w", err) + } + + // Get parent node keyring + parentNodeKR, err := d.getLinkKR(ctx, parentLink) + if err != nil { + return "", fmt.Errorf("failed to get parent keyring: %w", err) + } + + // Get signature verification keyring + signatureVerificationKR, err := d.getSignatureVerificationKeyring([]string{parentLink.SignatureEmail}, parentNodeKR) + if err != nil { + return "", fmt.Errorf("failed to get signature verification keyring: %w", err) + } + + parentHashKey, err := parentLink.GetHashKey(parentNodeKR, signatureVerificationKR) + if err != nil { + return "", fmt.Errorf("failed to get parent hash key: %w", err) + } + + nameHash, err := proton.GetNameHash(name, parentHashKey) + if err != nil { + return "", fmt.Errorf("failed to generate name hash: %w", err) + } + + return nameHash, nil +} + +func reencryptKeyPacket(srcKR, dstKR, _ *crypto.KeyRing, passphrase string) (string, error) { // addrKR (3) + oldSplitMessage, err := crypto.NewPGPSplitMessageFromArmored(passphrase) + if err != nil { + return "", err + } + + sessionKey, err := srcKR.DecryptSessionKey(oldSplitMessage.KeyPacket) + if err != nil { + return "", err + } + + newKeyPacket, err := dstKR.EncryptSessionKey(sessionKey) + if err != nil { + return "", err + } + + newSplitMessage := crypto.NewPGPSplitMessage(newKeyPacket, oldSplitMessage.DataPacket) + + return newSplitMessage.GetArmored() +} + +func (d *ProtonDrive) checkCircularMove(ctx context.Context, srcLinkID, dstParentLinkID string) error { + currentLinkID := dstParentLinkID + + for currentLinkID != "" && currentLinkID != d.RootFolderID { + if currentLinkID == srcLinkID { + return fmt.Errorf("cannot move folder into itself or its subfolder") + } + + currentLink, err := d.getLink(ctx, currentLinkID) + if err != nil { + return err + } + currentLinkID = currentLink.ParentLinkID + } + + return nil +} + +func (d *ProtonDrive) authHandler(auth proton.Auth) { + if auth.AccessToken != d.ReusableCredential.AccessToken || auth.RefreshToken != d.ReusableCredential.RefreshToken { + d.ReusableCredential.UID = auth.UID + d.ReusableCredential.AccessToken = auth.AccessToken + d.ReusableCredential.RefreshToken = auth.RefreshToken + + if err := d.initClient(context.Background()); err != nil { + fmt.Printf("ProtonDrive: failed to reinitialize client after auth refresh: %v\n", err) + } + + op.MustSaveDriverStorage(d) + } +} + +func (d *ProtonDrive) initClient(ctx context.Context) error { + clientOptions := []proton.Option{ + proton.WithAppVersion(d.appVersion), + proton.WithUserAgent(d.userAgent), + } + manager := proton.New(clientOptions...) + d.c = manager.NewClient(d.ReusableCredential.UID, d.ReusableCredential.AccessToken, d.ReusableCredential.RefreshToken) + + saltedKeyPassBytes, err := base64.StdEncoding.DecodeString(d.ReusableCredential.SaltedKeyPass) + if err != nil { + return fmt.Errorf("failed to decode salted key pass: %w", err) + } + + _, addrKRs, addrs, _, err := getAccountKRs(ctx, d.c, nil, saltedKeyPassBytes) + if err != nil { + return fmt.Errorf("failed to get account keyrings: %w", err) + } + + d.addrKRs = addrKRs + d.addrData = addrs + + return nil +} diff --git a/drivers/quark_uc_tv/driver.go b/drivers/quark_uc_tv/driver.go index 05735f87..dc062d9f 100644 --- a/drivers/quark_uc_tv/driver.go +++ b/drivers/quark_uc_tv/driver.go @@ -95,14 +95,22 @@ func (d *QuarkUCTV) List(ctx context.Context, dir model.Obj, args model.ListArgs files := make([]model.Obj, 0) pageIndex := int64(0) pageSize := int64(100) + desc := "1" + orderBy := "3" + if d.OrderDirection == "asc" { + desc = "0" + } + if d.OrderBy == "file_name" { + orderBy = "1" + } for { var filesData FilesData _, err := d.request(ctx, "/file", http.MethodGet, func(req *resty.Request) { req.SetQueryParams(map[string]string{ "method": "list", "parent_fid": dir.GetID(), - "order_by": "3", - "desc": "1", + "order_by": orderBy, + "desc": desc, "category": "", "source": "", "ex_source": "", @@ -161,7 +169,7 @@ func (d *QuarkUCTV) Put(ctx context.Context, dstDir model.Obj, stream model.File } type QuarkUCTVCommon struct { - AccessToken string `json:"access_token"` + AccessToken string } var _ driver.Driver = (*QuarkUCTV)(nil) diff --git a/drivers/quark_uc_tv/meta.go b/drivers/quark_uc_tv/meta.go index 81c9c7ef..a8af1a13 100644 --- a/drivers/quark_uc_tv/meta.go +++ b/drivers/quark_uc_tv/meta.go @@ -8,6 +8,8 @@ import ( type Addition struct { // Usually one of two driver.RootID + OrderBy string `json:"order_by" type:"select" options:"file_name,updated_at" default:"updated_at"` + OrderDirection string `json:"order_direction" type:"select" options:"asc,desc" default:"desc"` // define other RefreshToken string `json:"refresh_token" required:"false" default:""` // 必要且影响登录,由签名决定 diff --git a/drivers/sftp/driver.go b/drivers/sftp/driver.go index 7de24248..a3b3a96d 100644 --- a/drivers/sftp/driver.go +++ b/drivers/sftp/driver.go @@ -4,6 +4,7 @@ import ( "context" "os" "path" + "strings" "github.com/OpenListTeam/OpenList/v4/internal/driver" "github.com/OpenListTeam/OpenList/v4/internal/errs" @@ -68,15 +69,10 @@ func (d *SFTP) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (* Limiter: stream.ServerDownloadLimit, Ctx: ctx, } - if !d.Config().OnlyLinkMFile { - return &model.Link{ - RangeReader: stream.GetRangeReaderFromMFile(file.GetSize(), mFile), - SyncClosers: utils.NewSyncClosers(remoteFile), - }, nil - } return &model.Link{ - MFile: mFile, - SyncClosers: utils.NewSyncClosers(remoteFile), + RangeReader: stream.GetRangeReaderFromMFile(file.GetSize(), mFile), + SyncClosers: utils.NewSyncClosers(remoteFile), + RequireReference: true, }, nil } @@ -127,4 +123,22 @@ func (d *SFTP) Put(ctx context.Context, dstDir model.Obj, stream model.FileStrea return err } +func (d *SFTP) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + stat, err := d.client.StatVFS(d.RootFolderPath) + if err != nil { + if strings.Contains(err.Error(), "unimplemented") { + return nil, errs.NotImplement + } + return nil, err + } + total := stat.Blocks * stat.Bsize + free := stat.Bfree * stat.Bsize + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: total, + FreeSpace: free, + }, + }, nil +} + var _ driver.Driver = (*SFTP)(nil) diff --git a/drivers/sftp/meta.go b/drivers/sftp/meta.go index 9dada9ef..1c9bd3e3 100644 --- a/drivers/sftp/meta.go +++ b/drivers/sftp/meta.go @@ -16,12 +16,12 @@ type Addition struct { } var config = driver.Config{ - Name: "SFTP", - LocalSort: true, - OnlyLinkMFile: false, - DefaultRoot: "/", - CheckStatus: true, - NoLinkURL: true, + Name: "SFTP", + LocalSort: true, + OnlyProxy: true, + DefaultRoot: "/", + CheckStatus: true, + NoLinkURL: true, } func init() { diff --git a/drivers/smb/driver.go b/drivers/smb/driver.go index 3e12f122..7d4b0cb9 100644 --- a/drivers/smb/driver.go +++ b/drivers/smb/driver.go @@ -11,7 +11,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/stream" "github.com/OpenListTeam/OpenList/v4/pkg/utils" - "github.com/hirochachacha/go-smb2" + "github.com/cloudsoda/go-smb2" ) type SMB struct { @@ -33,7 +33,7 @@ func (d *SMB) Init(ctx context.Context) error { if !strings.Contains(d.Addition.Address, ":") { d.Addition.Address = d.Addition.Address + ":445" } - return d._initFS() + return d._initFS(ctx) } func (d *SMB) Drop(ctx context.Context) error { @@ -44,7 +44,7 @@ func (d *SMB) Drop(ctx context.Context) error { } func (d *SMB) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { - if err := d.checkConn(); err != nil { + if err := d.checkConn(ctx); err != nil { return nil, err } fullPath := dir.GetPath() @@ -71,7 +71,7 @@ func (d *SMB) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]m } func (d *SMB) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { - if err := d.checkConn(); err != nil { + if err := d.checkConn(ctx); err != nil { return nil, err } fullPath := file.GetPath() @@ -86,20 +86,15 @@ func (d *SMB) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*m Limiter: stream.ServerDownloadLimit, Ctx: ctx, } - if !d.Config().OnlyLinkMFile { - return &model.Link{ - RangeReader: stream.GetRangeReaderFromMFile(file.GetSize(), mFile), - SyncClosers: utils.NewSyncClosers(remoteFile), - }, nil - } return &model.Link{ - MFile: mFile, - SyncClosers: utils.NewSyncClosers(remoteFile), + RangeReader: stream.GetRangeReaderFromMFile(file.GetSize(), mFile), + SyncClosers: utils.NewSyncClosers(remoteFile), + RequireReference: true, }, nil } func (d *SMB) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error { - if err := d.checkConn(); err != nil { + if err := d.checkConn(ctx); err != nil { return err } fullPath := filepath.Join(parentDir.GetPath(), dirName) @@ -113,7 +108,7 @@ func (d *SMB) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) } func (d *SMB) Move(ctx context.Context, srcObj, dstDir model.Obj) error { - if err := d.checkConn(); err != nil { + if err := d.checkConn(ctx); err != nil { return err } srcPath := srcObj.GetPath() @@ -128,7 +123,7 @@ func (d *SMB) Move(ctx context.Context, srcObj, dstDir model.Obj) error { } func (d *SMB) Rename(ctx context.Context, srcObj model.Obj, newName string) error { - if err := d.checkConn(); err != nil { + if err := d.checkConn(ctx); err != nil { return err } srcPath := srcObj.GetPath() @@ -143,7 +138,7 @@ func (d *SMB) Rename(ctx context.Context, srcObj model.Obj, newName string) erro } func (d *SMB) Copy(ctx context.Context, srcObj, dstDir model.Obj) error { - if err := d.checkConn(); err != nil { + if err := d.checkConn(ctx); err != nil { return err } srcPath := srcObj.GetPath() @@ -163,7 +158,7 @@ func (d *SMB) Copy(ctx context.Context, srcObj, dstDir model.Obj) error { } func (d *SMB) Remove(ctx context.Context, obj model.Obj) error { - if err := d.checkConn(); err != nil { + if err := d.checkConn(ctx); err != nil { return err } var err error @@ -182,7 +177,7 @@ func (d *SMB) Remove(ctx context.Context, obj model.Obj) error { } func (d *SMB) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) error { - if err := d.checkConn(); err != nil { + if err := d.checkConn(ctx); err != nil { return err } fullPath := filepath.Join(dstDir.GetPath(), stream.GetName()) @@ -205,6 +200,22 @@ func (d *SMB) Put(ctx context.Context, dstDir model.Obj, stream model.FileStream return nil } +func (d *SMB) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + if err := d.checkConn(ctx); err != nil { + return nil, err + } + stat, err := d.fs.Statfs(d.RootFolderPath) + if err != nil { + return nil, err + } + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: stat.BlockSize() * stat.TotalBlockCount(), + FreeSpace: stat.BlockSize() * stat.AvailableBlockCount(), + }, + }, nil +} + //func (d *SMB) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) { // return nil, errs.NotSupport //} diff --git a/drivers/smb/meta.go b/drivers/smb/meta.go index 87a98277..ce0e5d3b 100644 --- a/drivers/smb/meta.go +++ b/drivers/smb/meta.go @@ -14,12 +14,12 @@ type Addition struct { } var config = driver.Config{ - Name: "SMB", - LocalSort: true, - OnlyLinkMFile: false, - DefaultRoot: ".", - NoCache: true, - NoLinkURL: true, + Name: "SMB", + LocalSort: true, + OnlyProxy: true, + DefaultRoot: ".", + NoCache: true, + NoLinkURL: true, } func init() { diff --git a/drivers/smb/util.go b/drivers/smb/util.go index 3e40f813..6ae365f8 100644 --- a/drivers/smb/util.go +++ b/drivers/smb/util.go @@ -1,9 +1,9 @@ package smb import ( + "context" "fmt" "io/fs" - "net" "os" "path/filepath" "sync/atomic" @@ -12,7 +12,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/pkg/singleflight" "github.com/OpenListTeam/OpenList/v4/pkg/utils" - "github.com/hirochachacha/go-smb2" + "github.com/cloudsoda/go-smb2" ) func (d *SMB) updateLastConnTime() { @@ -27,24 +27,20 @@ func (d *SMB) getLastConnTime() time.Time { return time.Unix(atomic.LoadInt64(&d.lastConnTime), 0) } -func (d *SMB) initFS() error { +func (d *SMB) initFS(ctx context.Context) error { _, err, _ := singleflight.AnyGroup.Do(fmt.Sprintf("SMB.initFS:%p", d), func() (any, error) { - return nil, d._initFS() + return nil, d._initFS(ctx) }) return err } -func (d *SMB) _initFS() error { - conn, err := net.Dial("tcp", d.Address) - if err != nil { - return err - } +func (d *SMB) _initFS(ctx context.Context) error { dialer := &smb2.Dialer{ Initiator: &smb2.NTLMInitiator{ User: d.Username, Password: d.Password, }, } - s, err := dialer.Dial(conn) + s, err := dialer.Dial(ctx, d.Address) if err != nil { return err } @@ -56,14 +52,14 @@ func (d *SMB) _initFS() error { return err } -func (d *SMB) checkConn() error { +func (d *SMB) checkConn(ctx context.Context) error { if time.Since(d.getLastConnTime()) < 5*time.Minute { return nil } if d.fs != nil { _ = d.fs.Umount() } - return d.initFS() + return d.initFS(ctx) } // CopyFile File copies a single file from src to dst diff --git a/drivers/strm/driver.go b/drivers/strm/driver.go index 80af52d4..e4482cf9 100644 --- a/drivers/strm/driver.go +++ b/drivers/strm/driver.go @@ -12,6 +12,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/fs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/sign" + "github.com/OpenListTeam/OpenList/v4/internal/stream" "github.com/OpenListTeam/OpenList/v4/pkg/utils" "github.com/OpenListTeam/OpenList/v4/server/common" ) @@ -108,7 +109,7 @@ func (d *Strm) Get(ctx context.Context, path string) (model.Obj, error) { if err != nil { continue } - // fs.Get 没报错,说明不是strm生成的路径,需要直接返回 + // fs.Get 没报错,说明不是strm驱动映射的路径,需要直接返回 size := int64(0) if !obj.IsDir() { size = obj.GetSize() @@ -123,6 +124,11 @@ func (d *Strm) Get(ctx context.Context, path string) (model.Obj, error) { HashInfo: obj.GetHash(), }, nil } + if strings.HasSuffix(path, ".strm") { + // 上面fs.Get都没找到且后缀为.strm + // 返回errs.NotSupport使得op.Get尝试从op.List中查找 + return nil, errs.NotSupport + } return nil, errs.ObjectNotFound } @@ -151,7 +157,7 @@ func (d *Strm) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (* if file.GetID() == "strm" { link := d.getLink(ctx, file.GetPath()) return &model.Link{ - MFile: strings.NewReader(link), + RangeReader: stream.GetRangeReaderFromMFile(int64(len(link)), strings.NewReader(link)), }, nil } // ftp,s3 diff --git a/drivers/strm/meta.go b/drivers/strm/meta.go index 0f3e0a99..d3c33164 100644 --- a/drivers/strm/meta.go +++ b/drivers/strm/meta.go @@ -15,14 +15,13 @@ type Addition struct { } var config = driver.Config{ - Name: "Strm", - LocalSort: true, - NoCache: true, - NoUpload: true, - DefaultRoot: "/", - OnlyLinkMFile: true, - OnlyProxy: true, - NoLinkURL: true, + Name: "Strm", + LocalSort: true, + OnlyProxy: true, + NoCache: true, + NoUpload: true, + DefaultRoot: "/", + NoLinkURL: true, } func init() { diff --git a/drivers/teldrive/types.go b/drivers/teldrive/types.go index f6399e06..084f967e 100644 --- a/drivers/teldrive/types.go +++ b/drivers/teldrive/types.go @@ -2,6 +2,7 @@ package teldrive import ( "context" + "io" "time" "github.com/OpenListTeam/OpenList/v4/internal/model" @@ -50,8 +51,8 @@ type chunkTask struct { chunkIdx int fileName string chunkSize int64 - reader *stream.SectionReader - ss *stream.StreamSectionReader + reader io.ReadSeeker + ss stream.StreamSectionReaderIF } type CopyManager struct { diff --git a/drivers/teldrive/upload.go b/drivers/teldrive/upload.go index 168d9bef..4f717dc8 100644 --- a/drivers/teldrive/upload.go +++ b/drivers/teldrive/upload.go @@ -164,7 +164,7 @@ func (d *Teldrive) doSingleUpload(ctx context.Context, dstDir model.Obj, file mo if err := d.singleUploadRequest(fileId, func(req *resty.Request) { uploadParams := map[string]string{ "partName": func() string { - digits := len(fmt.Sprintf("%d", totalParts)) + digits := len(strconv.Itoa(totalParts)) return file.GetName() + fmt.Sprintf(".%0*d", digits, 1) }(), "partNo": strconv.Itoa(1), @@ -333,7 +333,7 @@ func (d *Teldrive) uploadSingleChunk(ctx context.Context, fileId string, task ch err := d.singleUploadRequest(fileId, func(req *resty.Request) { uploadParams := map[string]string{ "partName": func() string { - digits := len(fmt.Sprintf("%d", totalParts)) + digits := len(strconv.Itoa(totalParts)) return task.fileName + fmt.Sprintf(".%0*d", digits, task.chunkIdx) }(), "partNo": strconv.Itoa(task.chunkIdx), diff --git a/drivers/template/driver.go b/drivers/template/driver.go index 5587dfea..477ca7f7 100644 --- a/drivers/template/driver.go +++ b/drivers/template/driver.go @@ -93,6 +93,11 @@ func (d *Template) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.O return nil, errs.NotImplement } +func (d *Template) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + // TODO return storage details (total space, free space, etc.) + return nil, errs.NotImplement +} + //func (d *Template) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) { // return nil, errs.NotSupport //} diff --git a/drivers/template/meta.go b/drivers/template/meta.go index a546e676..f525b5a9 100644 --- a/drivers/template/meta.go +++ b/drivers/template/meta.go @@ -16,7 +16,6 @@ type Addition struct { var config = driver.Config{ Name: "Template", LocalSort: false, - OnlyLinkMFile: false, OnlyProxy: false, NoCache: false, NoUpload: false, diff --git a/drivers/virtual/driver.go b/drivers/virtual/driver.go index 1d14427c..6e6bd087 100644 --- a/drivers/virtual/driver.go +++ b/drivers/virtual/driver.go @@ -2,11 +2,11 @@ package virtual import ( "context" - "io" "time" "github.com/OpenListTeam/OpenList/v4/internal/driver" "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/stream" "github.com/OpenListTeam/OpenList/v4/pkg/utils/random" ) @@ -42,16 +42,14 @@ func (d *Virtual) List(ctx context.Context, dir model.Obj, args model.ListArgs) return res, nil } -type DummyMFile struct { - io.Reader -} +type DummyMFile struct{} func (f DummyMFile) Read(p []byte) (n int, err error) { - return f.Reader.Read(p) + return random.Rand.Read(p) } func (f DummyMFile) ReadAt(p []byte, off int64) (n int, err error) { - return f.Reader.Read(p) + return random.Rand.Read(p) } func (DummyMFile) Seek(offset int64, whence int) (int64, error) { @@ -60,7 +58,7 @@ func (DummyMFile) Seek(offset int64, whence int) (int64, error) { func (d *Virtual) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { return &model.Link{ - MFile: DummyMFile{Reader: random.Rand}, + RangeReader: stream.GetRangeReaderFromMFile(file.GetSize(), DummyMFile{}), }, nil } diff --git a/drivers/virtual/meta.go b/drivers/virtual/meta.go index f567830a..c270ec12 100644 --- a/drivers/virtual/meta.go +++ b/drivers/virtual/meta.go @@ -14,11 +14,11 @@ type Addition struct { } var config = driver.Config{ - Name: "Virtual", - OnlyLinkMFile: true, - LocalSort: true, - NeedMs: true, - NoLinkURL: true, + Name: "Virtual", + LocalSort: true, + OnlyProxy: true, + NeedMs: true, + NoLinkURL: true, } func init() { diff --git a/drivers/wopan/util.go b/drivers/wopan/util.go index 6b8918b0..fec5122b 100644 --- a/drivers/wopan/util.go +++ b/drivers/wopan/util.go @@ -36,5 +36,6 @@ func (d *Wopan) getSpaceType() string { // 20230607214351 func getTime(str string) (time.Time, error) { - return time.Parse("20060102150405", str) + loc := time.FixedZone("UTC+8", 8*60*60) + return time.ParseInLocation("20060102150405", str, loc) } diff --git a/go.mod b/go.mod index da8a1974..263eb479 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/OpenListTeam/times v0.1.0 github.com/OpenListTeam/wopan-sdk-go v0.1.5 github.com/ProtonMail/go-crypto v1.3.0 - github.com/power721/115driver v0.2.5 + github.com/ProtonMail/gopenpgp/v2 v2.9.0 github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible github.com/avast/retry-go v3.0.0+incompatible github.com/aws/aws-sdk-go v1.55.7 @@ -21,6 +21,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 github.com/city404/v6-public-rpc-proto/go v0.0.0-20240817070657-90f8e24b653e + github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc github.com/coreos/go-oidc v2.3.0+incompatible github.com/deckarep/golang-set/v2 v2.8.0 github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 @@ -37,8 +38,9 @@ require ( github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/halalcloud/golang-sdk-lite v0.0.0-20251006164234-3c629727c499 github.com/hekmon/transmissionrpc/v3 v3.0.0 - github.com/hirochachacha/go-smb2 v1.1.0 + github.com/henrybear327/go-proton-api v1.0.0 github.com/ipfs/go-ipfs-api v0.7.0 github.com/itsHenry35/gofakes3 v0.0.8 github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 @@ -51,7 +53,9 @@ require ( github.com/ncw/swift/v2 v2.0.4 github.com/pkg/errors v0.9.1 github.com/pkg/sftp v1.13.9 + github.com/power721/115driver v0.2.5 github.com/pquerna/otp v1.5.0 + github.com/quic-go/quic-go v0.54.1 github.com/rclone/rclone v1.70.3 github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d github.com/shirou/gopsutil/v4 v4.25.5 @@ -60,6 +64,7 @@ require ( github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 github.com/t3rm1n4l/go-mega v0.0.0-20241213151442-a19cff0ec7b5 + github.com/tchap/go-patricia/v2 v2.3.3 github.com/u2takey/ffmpeg-go v0.5.0 github.com/upyun/go-sdk/v3 v3.0.4 github.com/winfsp/cgofuse v1.6.0 @@ -81,18 +86,42 @@ require ( require ( cloud.google.com/go/compute/metadata v0.7.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect + github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect + github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect + github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect + github.com/ProtonMail/go-srp v0.0.7 // indirect + github.com/PuerkitoBio/goquery v1.10.3 // indirect github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/bradenaw/juniper v0.15.3 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/cronokirby/saferith v0.33.0 // indirect github.com/ebitengine/purego v0.8.4 // indirect + github.com/emersion/go-message v0.18.2 // indirect + github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect + github.com/geoffgarside/ber v1.2.0 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/jcmturner/aescts/v2 v2.0.0 // indirect + github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect + github.com/jcmturner/gofork v1.7.6 // indirect + github.com/jcmturner/goidentity/v6 v6.0.1 // indirect + github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect + github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/lanrat/extsort v1.0.2 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/minio/minlz v1.0.0 // indirect github.com/minio/xxml v0.0.3 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/otiai10/mint v1.6.3 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/relvacode/iso8601 v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect + golang.org/x/mod v0.27.0 // indirect gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect ) @@ -168,7 +197,6 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect - github.com/geoffgarside/ber v1.2.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-chi/chi/v5 v5.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect @@ -185,6 +213,7 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-version v1.6.0 // indirect + github.com/henrybear327/Proton-API-Bridge v1.0.0 github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/ipfs/go-cid v0.5.0 github.com/jackc/pgpassfile v1.0.0 // indirect @@ -254,11 +283,11 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect go.etcd.io/bbolt v1.4.0 // indirect golang.org/x/arch v0.18.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.34.0 // indirect + golang.org/x/sync v0.16.0 + golang.org/x/sys v0.34.0 golang.org/x/term v0.33.0 // indirect golang.org/x/text v0.27.0 - golang.org/x/tools v0.34.0 // indirect + golang.org/x/tools v0.35.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/grpc v1.73.0 google.golang.org/protobuf v1.36.6 // indirect @@ -268,4 +297,8 @@ require ( lukechampine.com/blake3 v1.1.7 // indirect ) +replace github.com/ProtonMail/go-proton-api => github.com/henrybear327/go-proton-api v1.0.0 + +replace github.com/cronokirby/saferith => github.com/Da3zKi7/saferith v0.33.0-fixed + // replace github.com/OpenListTeam/115-sdk-go => ../../OpenListTeam/115-sdk-go diff --git a/go.sum b/go.sum index c3a333df..b3377c27 100644 --- a/go.sum +++ b/go.sum @@ -37,6 +37,10 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83 github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Da3zKi7/saferith v0.33.0-fixed h1:fnIWTk7EP9mZAICf7aQjeoAwpfrlCrkOvqmi6CbWdTk= +github.com/Da3zKi7/saferith v0.33.0-fixed/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA= +github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= +github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd h1:nzE1YQBdx1bq9IlZinHa+HVffy+NmVRoKr+wHN8fpLE= github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd/go.mod h1:C8yoIfvESpM3GD07OCHU7fqI7lhwyZ2Td1rbNbTAhnc= github.com/OpenListTeam/115-sdk-go v0.2.2 h1:JCrGHqQjBX3laOA6Hw4CuBovSg7g+FC5s0LEAYsRciU= @@ -53,14 +57,26 @@ github.com/OpenListTeam/times v0.1.0 h1:qknxw+qj5CYKgXAwydA102UEpPcpU8TYNGRmwRyP github.com/OpenListTeam/times v0.1.0/go.mod h1:Jx7qen5NCYzKk2w14YuvU48YYMcPa1P9a+EJePC15Pc= github.com/OpenListTeam/wopan-sdk-go v0.1.5 h1:iKKcVzIqBgtGDbn0QbdWrCazSGxXFmYFyrnFBG+U8dI= github.com/OpenListTeam/wopan-sdk-go v0.1.5/go.mod h1:otynv0CgSNUClPpUgZ44qCZGcMRe0dc83Pkk65xAunI= +github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57/go.mod h1:HecWFHognK8GfRDGnFQbW/LiV7A3MX3gZVs45vk5h8I= +github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug= +github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo= +github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e h1:lCsqUUACrcMC83lg5rTo9Y0PnPItE61JSfvMyIcANwk= +github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e/go.mod h1:Og5/Dz1MiGpCJn51XujZwxiLG7WzvvjE5PRpZBQmAHo= +github.com/ProtonMail/go-crypto v0.0.0-20230321155629-9a39f2531310/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k= +github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= +github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI= +github.com/ProtonMail/go-srp v0.0.7/go.mod h1:giCp+7qRnMIcCvI6V6U3S1lDDXDQYx2ewJ6F/9wdlJk= +github.com/ProtonMail/gopenpgp/v2 v2.9.0 h1:ruLzBmwe4dR1hdnrsEJ/S7psSBmV15gFttFUPP/+/kE= +github.com/ProtonMail/gopenpgp/v2 v2.9.0/go.mod h1:IldDyh9Hv1ZCCYatTuuEt1XZJ0OPjxLpTarDfglih7s= +github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= +github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg= github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= github.com/STARRY-S/zip v0.2.1 h1:pWBd4tuSGm3wtpoqRZZ2EAwOmcHK6XFf7bU9qcJXyFg= github.com/STARRY-S/zip v0.2.1/go.mod h1:xNvshLODWtC4EJ702g7cTYn13G53o1+X9BWnPFpcWV4= -github.com/SheltonZhu/115driver v1.1.1 h1:9EMhe2ZJflGiAaZbYInw2jqxTcqZNF+DtVDsEy70aFU= -github.com/SheltonZhu/115driver v1.1.1/go.mod h1:rKvNd4Y4OkXv1TMbr/SKjGdcvMQxh6AW5Tw9w0CJb7E= github.com/abbot/go-http-auth v0.4.0 h1:QjmvZ5gSC7jm3Zg54DqWE/T5m1t2AfDu6QlXJT0EVT0= github.com/abbot/go-http-auth v0.4.0/go.mod h1:Cz6ARTIzApMJDzh5bRMSUou6UMSp0IEXg9km/ci7TJM= github.com/aead/ecdh v0.2.0 h1:pYop54xVaq/CEREFEcukHRZfTdjiWvYIsZDXXrBapQQ= @@ -72,6 +88,8 @@ github.com/andreburgaud/crypt2go v1.8.0/go.mod h1:L5nfShQ91W78hOWhUH2tlGRPO+POAP github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 h1:8PmGpDEZl9yDpcdEr6Odf23feCxK3LNUNMxjXg41pZQ= github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= @@ -164,6 +182,9 @@ github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/bradenaw/juniper v0.15.3 h1:RHIAMEDTpvmzV1wg1jMAHGOoI2oJUSPx3lxRldXnFGo= +github.com/bradenaw/juniper v0.15.3/go.mod h1:UX4FX57kVSaDp4TPqvSjkAAewmRFAfXf27BOs5z9dq8= +github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= @@ -198,8 +219,13 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/city404/v6-public-rpc-proto/go v0.0.0-20240817070657-90f8e24b653e h1:GLC8iDDcbt1H8+RkNao2nRGjyNTIo81e1rAJT9/uWYA= github.com/city404/v6-public-rpc-proto/go v0.0.0-20240817070657-90f8e24b653e/go.mod h1:ln9Whp+wVY/FTbn2SK0ag+SKD2fC0yQCF/Lqowc1LmU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc h1:t8YjNUCt1DimB4HCIXBztwWMhgxr5yG5/YaRl9Afdfg= +github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc/go.mod h1:CgWpFCFWzzEA5hVkhAc6DZZzGd3czx+BblvOzjmg6KA= +github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc h1:0xCWmFKBmarCqqqLeM7jFBSw/Or81UEElFqO8MY+GDs= +github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc/go.mod h1:uvR42Hb/t52HQd7x5/ZLzZEK8oihrFpgnodIJ1vte2E= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= @@ -235,6 +261,10 @@ github.com/dustinxie/ecc v0.0.0-20210511000915-959544187564 h1:I6KUy4CI6hHjqnyJL github.com/dustinxie/ecc v0.0.0-20210511000915-959544187564/go.mod h1:yekO+3ZShy19S+bsmnERmznGy9Rfg6dWWWpiGJjNAz8= github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= +github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff h1:4N8wnS3f1hNHSmFD5zgFkWCyA4L1kCDkImPAtK7D6tg= +github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -254,7 +284,6 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= -github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc= github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64= github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= @@ -355,8 +384,14 @@ github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3 github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/halalcloud/golang-sdk-lite v0.0.0-20251006164234-3c629727c499 h1:4ovnBdiGDFi8putQGxhipuuhXItAgh4/YnzufPYkZkQ= +github.com/halalcloud/golang-sdk-lite v0.0.0-20251006164234-3c629727c499/go.mod h1:8x1h4rm3s8xMcTyJrq848sQ6BJnKzl57mDY4CNshdPM= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -364,6 +399,9 @@ github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9n github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= @@ -375,8 +413,10 @@ github.com/hekmon/cunits/v2 v2.1.0 h1:k6wIjc4PlacNOHwKEMBgWV2/c8jyD4eRMs5mR1BBhI github.com/hekmon/cunits/v2 v2.1.0/go.mod h1:9r1TycXYXaTmEWlAIfFV8JT+Xo59U96yUJAYHxzii2M= github.com/hekmon/transmissionrpc/v3 v3.0.0 h1:0Fb11qE0IBh4V4GlOwHNYpqpjcYDp5GouolwrpmcUDQ= github.com/hekmon/transmissionrpc/v3 v3.0.0/go.mod h1:38SlNhFzinVUuY87wGj3acOmRxeYZAZfrj6Re7UgCDg= -github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI= -github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE= +github.com/henrybear327/Proton-API-Bridge v1.0.0 h1:gjKAaWfKu++77WsZTHg6FUyPC5W0LTKWQciUm8PMZb0= +github.com/henrybear327/Proton-API-Bridge v1.0.0/go.mod h1:gunH16hf6U74W2b9CGDaWRadiLICsoJ6KRkSt53zLts= +github.com/henrybear327/go-proton-api v1.0.0 h1:zYi/IbjLwFAW7ltCeqXneUGJey0TN//Xo851a/BgLXw= +github.com/henrybear327/go-proton-api v1.0.0/go.mod h1:w63MZuzufKcIZ93pwRgiOtxMXYafI8H74D77AxytOBc= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -396,6 +436,18 @@ github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -567,8 +619,14 @@ github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQP github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg= +github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/rclone/rclone v1.70.3 h1:rg/WNh4DmSVZyKP2tHZ4lAaWEyMi7h/F0r7smOMA3IE= github.com/rclone/rclone v1.70.3/go.mod h1:nLyN+hpxAsQn9Rgt5kM774lcRDad82x/KqQeBZ83cMo= +github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU= +github.com/relvacode/iso8601 v1.6.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= github.com/rfjakob/eme v1.1.2 h1:SxziR8msSOElPayZNFfQw4Tjx/Sbaeeh3eRvrHVMUs4= github.com/rfjakob/eme v1.1.2/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -626,6 +684,8 @@ github.com/t3rm1n4l/go-mega v0.0.0-20241213151442-a19cff0ec7b5 h1:Sa+sR8aaAMFwxh github.com/t3rm1n4l/go-mega v0.0.0-20241213151442-a19cff0ec7b5/go.mod h1:UdZiFUFu6e2WjjtjxivwXWcwc1N/8zgbkBR9QNucUOY= github.com/taruti/bytepool v0.0.0-20160310082835-5e3a9ea56543 h1:6Y51mutOvRGRx6KqyMNo//xk8B8o6zW9/RVmy1VamOs= github.com/taruti/bytepool v0.0.0-20160310082835-5e3a9ea56543/go.mod h1:jpwqYA8KUVEvSUJHkCXsnBRJCSKP1BMa81QZ6kvRpow= +github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc= +github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= @@ -682,6 +742,10 @@ go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5J go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= +go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU= gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs= @@ -693,8 +757,9 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= @@ -736,6 +801,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -748,16 +815,19 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -800,6 +870,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -818,6 +889,7 @@ golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXct golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= @@ -833,6 +905,7 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= @@ -874,8 +947,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/archive/archives/archives.go b/internal/archive/archives/archives.go index d9c59aa9..6f6245a8 100644 --- a/internal/archive/archives/archives.go +++ b/internal/archive/archives/archives.go @@ -1,10 +1,11 @@ package archives import ( + "fmt" "io" "io/fs" "os" - stdpath "path" + "path/filepath" "strings" "github.com/OpenListTeam/OpenList/v4/internal/archive/tool" @@ -107,7 +108,7 @@ func (Archives) Decompress(ss []*stream.SeekableStream, outputPath string, args } if stat.IsDir() { isDir = true - outputPath = stdpath.Join(outputPath, stat.Name()) + outputPath = filepath.Join(outputPath, stat.Name()) err = os.Mkdir(outputPath, 0700) if err != nil { return filterPassword(err) @@ -120,11 +121,14 @@ func (Archives) Decompress(ss []*stream.SeekableStream, outputPath string, args return err } relPath := strings.TrimPrefix(p, path+"/") - dstPath := stdpath.Join(outputPath, relPath) + dstPath := filepath.Join(outputPath, relPath) + if !strings.HasPrefix(dstPath, outputPath+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path: %s", relPath) + } if d.IsDir() { err = os.MkdirAll(dstPath, 0700) } else { - dir := stdpath.Dir(dstPath) + dir := filepath.Dir(dstPath) err = decompress(fsys, p, dir, func(_ float64) {}) } return err diff --git a/internal/archive/archives/utils.go b/internal/archive/archives/utils.go index ce84da09..ddead84c 100644 --- a/internal/archive/archives/utils.go +++ b/internal/archive/archives/utils.go @@ -1,10 +1,11 @@ package archives import ( + "fmt" "io" fs2 "io/fs" "os" - stdpath "path" + "path/filepath" "strings" "github.com/OpenListTeam/OpenList/v4/internal/errs" @@ -69,7 +70,11 @@ func decompress(fsys fs2.FS, filePath, targetPath string, up model.UpdateProgres if err != nil { return err } - f, err := os.OpenFile(stdpath.Join(targetPath, stat.Name()), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + destPath := filepath.Join(targetPath, stat.Name()) + if !strings.HasPrefix(destPath, targetPath+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path: %s", stat.Name()) + } + f, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if err != nil { return err } diff --git a/internal/archive/iso9660/iso9660.go b/internal/archive/iso9660/iso9660.go index eb4e975d..7b26dcae 100644 --- a/internal/archive/iso9660/iso9660.go +++ b/internal/archive/iso9660/iso9660.go @@ -1,9 +1,11 @@ package iso9660 import ( + "fmt" "io" "os" - stdpath "path" + "path/filepath" + "strings" "github.com/OpenListTeam/OpenList/v4/internal/archive/tool" "github.com/OpenListTeam/OpenList/v4/internal/errs" @@ -79,7 +81,11 @@ func (ISO9660) Decompress(ss []*stream.SeekableStream, outputPath string, args m } if obj.IsDir() { if args.InnerPath != "/" { - outputPath = stdpath.Join(outputPath, obj.Name()) + rootpath := outputPath + outputPath = filepath.Join(outputPath, obj.Name()) + if !strings.HasPrefix(outputPath, rootpath+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path: %s", obj.Name()) + } if err = os.MkdirAll(outputPath, 0700); err != nil { return err } diff --git a/internal/archive/iso9660/utils.go b/internal/archive/iso9660/utils.go index e3326b9b..0e915133 100644 --- a/internal/archive/iso9660/utils.go +++ b/internal/archive/iso9660/utils.go @@ -1,8 +1,9 @@ package iso9660 import ( + "fmt" "os" - stdpath "path" + "path/filepath" "strings" "github.com/OpenListTeam/OpenList/v4/internal/errs" @@ -62,7 +63,11 @@ func toModelObj(file *iso9660.File) model.Obj { } func decompress(f *iso9660.File, path string, up model.UpdateProgress) error { - file, err := os.OpenFile(stdpath.Join(path, f.Name()), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + destPath := filepath.Join(path, f.Name()) + if !strings.HasPrefix(destPath, path+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path: %s", f.Name()) + } + file, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if err != nil { return err } @@ -84,7 +89,10 @@ func decompressAll(children []*iso9660.File, path string) error { if err != nil { return err } - nextPath := stdpath.Join(path, child.Name()) + nextPath := filepath.Join(path, child.Name()) + if !strings.HasPrefix(nextPath, path+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path: %s", child.Name()) + } if err = os.MkdirAll(nextPath, 0700); err != nil { return err } diff --git a/internal/archive/rardecode/rardecode.go b/internal/archive/rardecode/rardecode.go index d2c6a448..13a22e3e 100644 --- a/internal/archive/rardecode/rardecode.go +++ b/internal/archive/rardecode/rardecode.go @@ -3,7 +3,7 @@ package rardecode import ( "io" "os" - stdpath "path" + "path/filepath" "strings" "github.com/OpenListTeam/OpenList/v4/internal/archive/tool" @@ -93,7 +93,7 @@ func (RarDecoder) Decompress(ss []*stream.SeekableStream, outputPath string, arg } } else { innerPath := strings.TrimPrefix(args.InnerPath, "/") - innerBase := stdpath.Base(innerPath) + innerBase := filepath.Base(innerPath) createdBaseDir := false for { var header *rardecode.FileHeader @@ -115,7 +115,7 @@ func (RarDecoder) Decompress(ss []*stream.SeekableStream, outputPath string, arg } break } else if strings.HasPrefix(name, innerPath+"/") { - targetPath := stdpath.Join(outputPath, innerBase) + targetPath := filepath.Join(outputPath, innerBase) if !createdBaseDir { err = os.Mkdir(targetPath, 0700) if err != nil { diff --git a/internal/archive/rardecode/utils.go b/internal/archive/rardecode/utils.go index 93a71da9..e933005a 100644 --- a/internal/archive/rardecode/utils.go +++ b/internal/archive/rardecode/utils.go @@ -5,7 +5,7 @@ import ( "io" "io/fs" "os" - stdpath "path" + "path/filepath" "sort" "strings" "time" @@ -124,7 +124,7 @@ type WrapFileInfo struct { } func (f *WrapFileInfo) Name() string { - return stdpath.Base(f.File.Name) + return filepath.Base(f.File.Name) } func (f *WrapFileInfo) Size() int64 { @@ -183,12 +183,16 @@ func getReader(ss []*stream.SeekableStream, password string) (*rardecode.Reader, func decompress(reader *rardecode.Reader, header *rardecode.FileHeader, filePath, outputPath string) error { targetPath := outputPath - dir, base := stdpath.Split(filePath) + dir, base := filepath.Split(filePath) if dir != "" { - targetPath = stdpath.Join(targetPath, dir) - err := os.MkdirAll(targetPath, 0700) - if err != nil { - return err + targetPath = filepath.Join(targetPath, dir) + if strings.HasPrefix(targetPath, outputPath+string(os.PathSeparator)) { + err := os.MkdirAll(targetPath, 0700) + if err != nil { + return err + } + } else { + targetPath = outputPath } } if base != "" { @@ -201,7 +205,11 @@ func decompress(reader *rardecode.Reader, header *rardecode.FileHeader, filePath } func _decompress(reader *rardecode.Reader, header *rardecode.FileHeader, targetPath string, up model.UpdateProgress) error { - f, err := os.OpenFile(stdpath.Join(targetPath, stdpath.Base(header.Name)), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + destPath := filepath.Join(targetPath, filepath.Base(header.Name)) + if !strings.HasPrefix(destPath, targetPath+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path: %s", filepath.Base(header.Name)) + } + f, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if err != nil { return err } diff --git a/internal/archive/tool/helper.go b/internal/archive/tool/helper.go index 6b5658a9..adbe56ed 100644 --- a/internal/archive/tool/helper.go +++ b/internal/archive/tool/helper.go @@ -1,10 +1,11 @@ package tool import ( + "fmt" "io" "io/fs" "os" - stdpath "path" + "path/filepath" "strings" "github.com/OpenListTeam/OpenList/v4/internal/model" @@ -40,13 +41,13 @@ func GenerateMetaTreeFromFolderTraversal(r ArchiveReader) (bool, []model.ObjTree isNewFolder := false if !file.FileInfo().IsDir() { // 先将 文件 添加到 所在的文件夹 - dir = stdpath.Dir(name) + dir = filepath.Dir(name) dirObj = dirMap[dir] if dirObj == nil { isNewFolder = dir != "." dirObj = &model.ObjectTree{} dirObj.IsFolder = true - dirObj.Name = stdpath.Base(dir) + dirObj.Name = filepath.Base(dir) dirObj.Modified = file.FileInfo().ModTime() dirMap[dir] = dirObj } @@ -64,28 +65,28 @@ func GenerateMetaTreeFromFolderTraversal(r ArchiveReader) (bool, []model.ObjTree dirMap[dir] = dirObj } dirObj.IsFolder = true - dirObj.Name = stdpath.Base(dir) + dirObj.Name = filepath.Base(dir) dirObj.Modified = file.FileInfo().ModTime() } if isNewFolder { // 将 文件夹 添加到 父文件夹 // 考虑压缩包仅记录文件的路径,不记录文件夹 // 循环创建所有父文件夹 - parentDir := stdpath.Dir(dir) + parentDir := filepath.Dir(dir) for { parentDirObj := dirMap[parentDir] if parentDirObj == nil { parentDirObj = &model.ObjectTree{} if parentDir != "." { parentDirObj.IsFolder = true - parentDirObj.Name = stdpath.Base(parentDir) + parentDirObj.Name = filepath.Base(parentDir) parentDirObj.Modified = file.FileInfo().ModTime() } dirMap[parentDir] = parentDirObj } parentDirObj.Children = append(parentDirObj.Children, dirObj) - parentDir = stdpath.Dir(parentDir) + parentDir = filepath.Dir(parentDir) if dirMap[parentDir] != nil { break } @@ -127,7 +128,7 @@ func DecompressFromFolderTraversal(r ArchiveReader, outputPath string, args mode } } else { innerPath := strings.TrimPrefix(args.InnerPath, "/") - innerBase := stdpath.Base(innerPath) + innerBase := filepath.Base(innerPath) createdBaseDir := false for _, file := range files { name := file.Name() @@ -138,7 +139,7 @@ func DecompressFromFolderTraversal(r ArchiveReader, outputPath string, args mode } break } else if strings.HasPrefix(name, innerPath+"/") { - targetPath := stdpath.Join(outputPath, innerBase) + targetPath := filepath.Join(outputPath, innerBase) if !createdBaseDir { err = os.Mkdir(targetPath, 0700) if err != nil { @@ -159,12 +160,16 @@ func DecompressFromFolderTraversal(r ArchiveReader, outputPath string, args mode func decompress(file SubFile, filePath, outputPath, password string) error { targetPath := outputPath - dir, base := stdpath.Split(filePath) + dir, base := filepath.Split(filePath) if dir != "" { - targetPath = stdpath.Join(targetPath, dir) - err := os.MkdirAll(targetPath, 0700) - if err != nil { - return err + targetPath = filepath.Join(targetPath, dir) + if strings.HasPrefix(targetPath, outputPath+string(os.PathSeparator)) { + err := os.MkdirAll(targetPath, 0700) + if err != nil { + return err + } + } else { + targetPath = outputPath } } if base != "" { @@ -185,7 +190,11 @@ func _decompress(file SubFile, targetPath, password string, up model.UpdateProgr return err } defer func() { _ = rc.Close() }() - f, err := os.OpenFile(stdpath.Join(targetPath, file.FileInfo().Name()), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + destPath := filepath.Join(targetPath, file.FileInfo().Name()) + if !strings.HasPrefix(destPath, targetPath+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path: %s", file.FileInfo().Name()) + } + f, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if err != nil { return err } diff --git a/internal/bootstrap/config.go b/internal/bootstrap/config.go index 2209c64f..bb381c95 100644 --- a/internal/bootstrap/config.go +++ b/internal/bootstrap/config.go @@ -39,7 +39,21 @@ func InitConfig() { if !filepath.IsAbs(dataDir) { flags.DataDir = filepath.Join(pwd, flags.DataDir) } - configPath := filepath.Join(flags.DataDir, "config.json") + // Determine config file path: use flags.ConfigPath if provided, otherwise default to /config.json + configPath := flags.ConfigPath + if configPath == "" { + configPath = filepath.Join(flags.DataDir, "config.json") + } else { + // if relative, resolve relative to working directory + if !filepath.IsAbs(configPath) { + if absPath, err := filepath.Abs(configPath); err == nil { + configPath = absPath + } else { + configPath = filepath.Join(pwd, configPath) + } + } + } + configPath = filepath.Clean(configPath) log.Infof("reading config file: %s", configPath) if !utils.Exists(configPath) { log.Infof("config file not exists, creating default config file") diff --git a/internal/bootstrap/data/setting.go b/internal/bootstrap/data/setting.go index 3f4f8288..f05fb18a 100644 --- a/internal/bootstrap/data/setting.go +++ b/internal/bootstrap/data/setting.go @@ -100,7 +100,7 @@ func InitialSettings() []model.SettingItem { //{Key: conf.ApiUrl, Value: "", Type: conf.TypeString, Group: model.SITE}, //{Key: conf.BasePath, Value: "", Type: conf.TypeString, Group: model.SITE}, {Key: conf.SiteTitle, Value: "OpenList", Type: conf.TypeString, Group: model.SITE}, - {Key: conf.Announcement, Value: "### repo\nhttps://github.com/OpenListTeam/OpenList", Type: conf.TypeText, Group: model.SITE}, + {Key: conf.Announcement, Value: "Welcome to the OpenList project!\nFor the latest updates, to contribute code, or to submit suggestions and issues, please visit our [project repository](https://github.com/OpenListTeam/OpenList).", Type: conf.TypeText, Group: model.SITE}, {Key: "pagination_type", Value: "all", Type: conf.TypeSelect, Options: "all,pagination,load_more,auto_load_more", Group: model.SITE}, {Key: "default_page_size", Value: "30", Type: conf.TypeNumber, Group: model.SITE}, {Key: conf.AllowIndexed, Value: "false", Type: conf.TypeBool, Group: model.SITE}, @@ -114,6 +114,9 @@ func InitialSettings() []model.SettingItem { {Key: "share_icon", Value: "🎁", Type: conf.TypeString, Group: model.STYLE}, {Key: "home_container", Value: "max_980px", Type: conf.TypeSelect, Options: "max_980px,hope_container", Group: model.STYLE}, {Key: "settings_layout", Value: "list", Type: conf.TypeSelect, Options: "list,responsive", Group: model.STYLE}, + {Key: conf.HideStorageDetails, Value: "true", Type: conf.TypeBool, Group: model.STYLE, Flag: model.PRIVATE}, + {Key: conf.HideStorageDetailsInManagePage, Value: "true", Type: conf.TypeBool, Group: model.STYLE, Flag: model.PRIVATE}, + {Key: "show_disk_usage_in_plain_text", Value: "false", Type: conf.TypeBool, Group: model.STYLE, Flag: model.PUBLIC}, // preview settings {Key: conf.TextTypes, Value: "txt,htm,html,xml,java,properties,sql,js,md,json,conf,ini,vue,php,py,bat,gitignore,yml,go,sh,c,cpp,h,hpp,tsx,vtt,srt,ass,rs,lrc,strm", Type: conf.TypeText, Group: model.PREVIEW, Flag: model.PRIVATE}, {Key: conf.AudioTypes, Value: "mp3,flac,ogg,m4a,wav,opus,wma", Type: conf.TypeText, Group: model.PREVIEW, Flag: model.PRIVATE}, @@ -145,7 +148,10 @@ func InitialSettings() []model.SettingItem { {Key: "audio_cover", Value: "https://res.oplist.org/logo/logo.svg", MigrationValue: "https://cdn.oplist.org/gh/OpenListTeam/Logo@main/logo.svg", Type: conf.TypeString, Group: model.PREVIEW}, {Key: conf.AudioAutoplay, Value: "true", Type: conf.TypeBool, Group: model.PREVIEW}, {Key: conf.VideoAutoplay, Value: "true", Type: conf.TypeBool, Group: model.PREVIEW}, + {Key: conf.PreviewDownloadByDefault, Value: "false", Type: conf.TypeBool, Group: model.PREVIEW}, {Key: conf.PreviewArchivesByDefault, Value: "true", Type: conf.TypeBool, Group: model.PREVIEW}, + {Key: conf.SharePreviewDownloadByDefault, Value: "true", Type: conf.TypeBool, Group: model.PREVIEW}, + {Key: conf.SharePreviewArchivesByDefault, Value: "false", Type: conf.TypeBool, Group: model.PREVIEW}, {Key: conf.ReadMeAutoRender, Value: "true", Type: conf.TypeBool, Group: model.PREVIEW}, {Key: conf.FilterReadMeScripts, Value: "true", Type: conf.TypeBool, Group: model.PREVIEW}, // global settings @@ -155,14 +161,16 @@ func InitialSettings() []model.SettingItem { {Key: conf.CustomizeBody, Type: conf.TypeText, Group: model.GLOBAL, Flag: model.PRIVATE}, {Key: conf.LinkExpiration, Value: "0", Type: conf.TypeNumber, Group: model.GLOBAL, Flag: model.PRIVATE}, {Key: conf.SignAll, Value: "true", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PRIVATE}, - {Key: conf.PrivacyRegs, Value: `(?:(?:\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(?:\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]) + { + Key: conf.PrivacyRegs, Value: `(?:(?:\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(?:\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]) ([[:xdigit:]]{1,4}(?::[[:xdigit:]]{1,4}){7}|::|:(?::[[:xdigit:]]{1,4}){1,6}|[[:xdigit:]]{1,4}:(?::[[:xdigit:]]{1,4}){1,5}|(?:[[:xdigit:]]{1,4}:){2}(?::[[:xdigit:]]{1,4}){1,4}|(?:[[:xdigit:]]{1,4}:){3}(?::[[:xdigit:]]{1,4}){1,3}|(?:[[:xdigit:]]{1,4}:){4}(?::[[:xdigit:]]{1,4}){1,2}|(?:[[:xdigit:]]{1,4}:){5}:[[:xdigit:]]{1,4}|(?:[[:xdigit:]]{1,4}:){1,6}:) (?U)access_token=(.*)&`, - Type: conf.TypeText, Group: model.GLOBAL, Flag: model.PRIVATE}, + Type: conf.TypeText, Group: model.GLOBAL, Flag: model.PRIVATE, + }, {Key: conf.OcrApi, Value: "https://openlistteam-ocr-api-server.hf.space/ocr/file/json", MigrationValue: "https://api.example.com/ocr/file/json", Type: conf.TypeString, Group: model.GLOBAL}, // TODO: This can be replace by a community-hosted endpoint, see https://github.com/OpenListTeam/ocr_api_server {Key: conf.FilenameCharMapping, Value: `{"/": "|"}`, Type: conf.TypeText, Group: model.GLOBAL}, {Key: conf.ForwardDirectLinkParams, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL}, - {Key: conf.IgnoreDirectLinkParams, Value: "sign,openlist_ts", Type: conf.TypeString, Group: model.GLOBAL}, + {Key: conf.IgnoreDirectLinkParams, Value: "sign,openlist_ts,raw", Type: conf.TypeString, Group: model.GLOBAL}, {Key: conf.WebauthnLoginEnabled, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PUBLIC}, {Key: conf.SharePreview, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PUBLIC}, {Key: conf.ShareArchivePreview, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PUBLIC}, @@ -212,12 +220,11 @@ func InitialSettings() []model.SettingItem { // ftp settings {Key: conf.FTPPublicHost, Value: "127.0.0.1", Type: conf.TypeString, Group: model.FTP, Flag: model.PRIVATE}, {Key: conf.FTPPasvPortMap, Value: "", Type: conf.TypeText, Group: model.FTP, Flag: model.PRIVATE}, - {Key: conf.FTPProxyUserAgent, Value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + - "Chrome/87.0.4280.88 Safari/537.36", Type: conf.TypeString, Group: model.FTP, Flag: model.PRIVATE}, {Key: conf.FTPMandatoryTLS, Value: "false", Type: conf.TypeBool, Group: model.FTP, Flag: model.PRIVATE}, {Key: conf.FTPImplicitTLS, Value: "false", Type: conf.TypeBool, Group: model.FTP, Flag: model.PRIVATE}, {Key: conf.FTPTLSPrivateKeyPath, Value: "", Type: conf.TypeString, Group: model.FTP, Flag: model.PRIVATE}, {Key: conf.FTPTLSPublicCertPath, Value: "", Type: conf.TypeString, Group: model.FTP, Flag: model.PRIVATE}, + {Key: conf.SFTPDisablePasswordLogin, Value: "false", Type: conf.TypeBool, Group: model.FTP, Flag: model.PRIVATE}, // traffic settings {Key: conf.TaskOfflineDownloadThreadsNum, Value: strconv.Itoa(conf.Conf.Tasks.Download.Workers), Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE}, diff --git a/internal/bootstrap/storage.go b/internal/bootstrap/storage.go index 47cd5421..3528644f 100644 --- a/internal/bootstrap/storage.go +++ b/internal/bootstrap/storage.go @@ -45,7 +45,7 @@ func LoadStorages() { i+1, storage.MountPath, storage.Driver) } } - conf.StoragesLoaded = true + conf.SendStoragesLoadedSignal() log.Infof("=== load storages completed ===") if conf.LazyLoad { syncStatus(2) diff --git a/internal/cache/keyed_cache.go b/internal/cache/keyed_cache.go new file mode 100644 index 00000000..87e71f18 --- /dev/null +++ b/internal/cache/keyed_cache.go @@ -0,0 +1,101 @@ +package cache + +import ( + "sync" + "time" +) + +type KeyedCache[T any] struct { + entries map[string]*CacheEntry[T] + mu sync.RWMutex + ttl time.Duration +} + +func NewKeyedCache[T any](ttl time.Duration) *KeyedCache[T] { + c := &KeyedCache[T]{ + entries: make(map[string]*CacheEntry[T]), + ttl: ttl, + } + gcFuncs = append(gcFuncs, c.GC) + return c +} + +func (c *KeyedCache[T]) Set(key string, value T) { + c.SetWithExpirable(key, value, ExpirationTime(time.Now().Add(c.ttl))) +} + +func (c *KeyedCache[T]) SetWithTTL(key string, value T, ttl time.Duration) { + c.SetWithExpirable(key, value, ExpirationTime(time.Now().Add(ttl))) +} + +func (c *KeyedCache[T]) SetWithExpirable(key string, value T, exp Expirable) { + c.mu.Lock() + defer c.mu.Unlock() + + c.entries[key] = &CacheEntry[T]{ + data: value, + Expirable: exp, + } +} + +func (c *KeyedCache[T]) Get(key string) (T, bool) { + c.mu.RLock() + entry, exists := c.entries[key] + if !exists { + c.mu.RUnlock() + return *new(T), false + } + + expired := entry.Expired() + c.mu.RUnlock() + + if !expired { + return entry.data, true + } + + c.mu.Lock() + if c.entries[key] == entry { + delete(c.entries, key) + c.mu.Unlock() + return *new(T), false + } + c.mu.Unlock() + return *new(T), false +} + +func (c *KeyedCache[T]) Delete(key string) { + c.mu.Lock() + defer c.mu.Unlock() + + delete(c.entries, key) +} + +func (c *KeyedCache[T]) Take(key string) (T, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if entry, exists := c.entries[key]; exists { + delete(c.entries, key) + return entry.data, true + } + return *new(T), false +} + +func (c *KeyedCache[T]) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.entries = make(map[string]*CacheEntry[T]) +} + +func (c *KeyedCache[T]) GC() { + c.mu.Lock() + defer c.mu.Unlock() + expiredKeys := make([]string, 0, len(c.entries)) + for key, entry := range c.entries { + if entry.Expired() { + expiredKeys = append(expiredKeys, key) + } + } + for _, key := range expiredKeys { + delete(c.entries, key) + } +} diff --git a/internal/cache/type.go b/internal/cache/type.go new file mode 100644 index 00000000..4eccba52 --- /dev/null +++ b/internal/cache/type.go @@ -0,0 +1,18 @@ +package cache + +import "time" + +type Expirable interface { + Expired() bool +} + +type ExpirationTime time.Time + +func (e ExpirationTime) Expired() bool { + return time.Now().After(time.Time(e)) +} + +type CacheEntry[T any] struct { + Expirable + data T +} diff --git a/internal/cache/typed_cache.go b/internal/cache/typed_cache.go new file mode 100644 index 00000000..7ba126be --- /dev/null +++ b/internal/cache/typed_cache.go @@ -0,0 +1,113 @@ +package cache + +import ( + "sync" + "time" +) + +type TypedCache[T any] struct { + entries map[string]map[string]*CacheEntry[T] + mu sync.RWMutex + ttl time.Duration +} + +func NewTypedCache[T any](ttl time.Duration) *TypedCache[T] { + c := &TypedCache[T]{ + entries: make(map[string]map[string]*CacheEntry[T]), + ttl: ttl, + } + gcFuncs = append(gcFuncs, c.GC) + return c +} + +func (c *TypedCache[T]) SetType(key, typeKey string, value T) { + c.SetTypeWithExpirable(key, typeKey, value, ExpirationTime(time.Now().Add(c.ttl))) +} + +func (c *TypedCache[T]) SetTypeWithTTL(key, typeKey string, value T, ttl time.Duration) { + c.SetTypeWithExpirable(key, typeKey, value, ExpirationTime(time.Now().Add(ttl))) +} + +func (c *TypedCache[T]) SetTypeWithExpirable(key, typeKey string, value T, exp Expirable) { + c.mu.Lock() + defer c.mu.Unlock() + cache, exists := c.entries[key] + if !exists { + cache = make(map[string]*CacheEntry[T]) + c.entries[key] = cache + } + + cache[typeKey] = &CacheEntry[T]{ + data: value, + Expirable: exp, + } +} + +func (c *TypedCache[T]) GetType(key, typeKey string) (T, bool) { + c.mu.RLock() + cache, exists := c.entries[key] + if !exists { + c.mu.RUnlock() + return *new(T), false + } + entry, exists := cache[typeKey] + if !exists { + c.mu.RUnlock() + return *new(T), false + } + expired := entry.Expired() + c.mu.RUnlock() + + if !expired { + return entry.data, true + } + + c.mu.Lock() + if cache[typeKey] == entry { + delete(cache, typeKey) + if len(cache) == 0 { + delete(c.entries, key) + } + c.mu.Unlock() + return *new(T), false + } + c.mu.Unlock() + return *new(T), false +} + +func (c *TypedCache[T]) DeleteKey(key string) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.entries, key) +} + +func (c *TypedCache[T]) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.entries = make(map[string]map[string]*CacheEntry[T]) +} + +func (c *TypedCache[T]) GC() { + c.mu.Lock() + defer c.mu.Unlock() + expiredKeys := make(map[string][]string) + for tk, entries := range c.entries { + for key, entry := range entries { + if !entry.Expired() { + continue + } + if _, ok := expiredKeys[tk]; !ok { + expiredKeys[tk] = make([]string, 0, len(entries)) + } + expiredKeys[tk] = append(expiredKeys[tk], key) + } + } + for tk, keys := range expiredKeys { + for _, key := range keys { + delete(c.entries[tk], key) + } + if len(c.entries[tk]) == 0 { + delete(c.entries, tk) + } + } +} diff --git a/internal/cache/utils.go b/internal/cache/utils.go new file mode 100644 index 00000000..82b7cf85 --- /dev/null +++ b/internal/cache/utils.go @@ -0,0 +1,24 @@ +package cache + +import ( + "time" + + "github.com/OpenListTeam/OpenList/v4/pkg/cron" + log "github.com/sirupsen/logrus" +) + +var ( + cacheGcCron *cron.Cron + gcFuncs []func() +) + +func init() { + // TODO Move to bootstrap + cacheGcCron = cron.NewCron(time.Hour) + cacheGcCron.Do(func() { + log.Infof("Start cache GC") + for _, f := range gcFuncs { + f() + } + }) +} diff --git a/internal/conf/config.go b/internal/conf/config.go index af198e91..4f239a11 100644 --- a/internal/conf/config.go +++ b/internal/conf/config.go @@ -35,6 +35,7 @@ type Scheme struct { UnixFile string `json:"unix_file" env:"UNIX_FILE"` UnixFilePerm string `json:"unix_file_perm" env:"UNIX_FILE_PERM"` EnableH2c bool `json:"enable_h2c" env:"ENABLE_H2C"` + EnableH3 bool `json:"enable_h3" env:"ENABLE_H3"` } type LogConfig struct { diff --git a/internal/conf/const.go b/internal/conf/const.go index e37914bf..1ef56208 100644 --- a/internal/conf/const.go +++ b/internal/conf/const.go @@ -19,22 +19,27 @@ const ( AllowMounted = "allow_mounted" RobotsTxt = "robots_txt" - Logo = "logo" // multi-lines text, L1: light, EOL: dark - Favicon = "favicon" - MainColor = "main_color" + Logo = "logo" // multi-lines text, L1: light, EOL: dark + Favicon = "favicon" + MainColor = "main_color" + HideStorageDetails = "hide_storage_details" + HideStorageDetailsInManagePage = "hide_storage_details_in_manage_page" // preview - TextTypes = "text_types" - AudioTypes = "audio_types" - VideoTypes = "video_types" - ImageTypes = "image_types" - ProxyTypes = "proxy_types" - ProxyIgnoreHeaders = "proxy_ignore_headers" - AudioAutoplay = "audio_autoplay" - VideoAutoplay = "video_autoplay" - PreviewArchivesByDefault = "preview_archives_by_default" - ReadMeAutoRender = "readme_autorender" - FilterReadMeScripts = "filter_readme_scripts" + TextTypes = "text_types" + AudioTypes = "audio_types" + VideoTypes = "video_types" + ImageTypes = "image_types" + ProxyTypes = "proxy_types" + ProxyIgnoreHeaders = "proxy_ignore_headers" + AudioAutoplay = "audio_autoplay" + VideoAutoplay = "video_autoplay" + PreviewDownloadByDefault = "preview_download_by_default" + PreviewArchivesByDefault = "preview_archives_by_default" + SharePreviewDownloadByDefault = "share_preview_download_by_default" + SharePreviewArchivesByDefault = "share_preview_archives_by_default" + ReadMeAutoRender = "readme_autorender" + FilterReadMeScripts = "filter_readme_scripts" // global HideFiles = "hide_files" @@ -138,14 +143,18 @@ const ( QbittorrentUrl = "qbittorrent_url" QbittorrentSeedtime = "qbittorrent_seedtime" + // 123 open offline download + Pan123OpenOfflineDownloadCallbackUrl = "123_open_callback_url" + Pan123OpenTempDir = "123_open_temp_dir" + // ftp - FTPPublicHost = "ftp_public_host" - FTPPasvPortMap = "ftp_pasv_port_map" - FTPProxyUserAgent = "ftp_proxy_user_agent" - FTPMandatoryTLS = "ftp_mandatory_tls" - FTPImplicitTLS = "ftp_implicit_tls" - FTPTLSPrivateKeyPath = "ftp_tls_private_key_path" - FTPTLSPublicCertPath = "ftp_tls_public_cert_path" + FTPPublicHost = "ftp_public_host" + FTPPasvPortMap = "ftp_pasv_port_map" + FTPMandatoryTLS = "ftp_mandatory_tls" + FTPImplicitTLS = "ftp_implicit_tls" + FTPTLSPrivateKeyPath = "ftp_tls_private_key_path" + FTPTLSPublicCertPath = "ftp_tls_public_cert_path" + SFTPDisablePasswordLogin = "sftp_disable_password_login" // traffic TaskOfflineDownloadThreadsNum = "offline_download_task_threads_num" diff --git a/internal/conf/var.go b/internal/conf/var.go index 1ba1f688..0a43c74d 100644 --- a/internal/conf/var.go +++ b/internal/conf/var.go @@ -3,6 +3,7 @@ package conf import ( "net/url" "regexp" + "sync" ) var ( @@ -24,8 +25,7 @@ var PrivacyReg []*regexp.Regexp var ( // StoragesLoaded loaded success if empty - StoragesLoaded = false - LazyLoad = false + LazyLoad = false // 单个Buffer最大限制 MaxBufferLimit = 16 * 1024 * 1024 // 超过该阈值的Buffer将使用 mmap 分配,可主动释放内存 @@ -36,3 +36,39 @@ var ( ManageHtml string IndexHtml string ) + +var ( + // StoragesLoaded loaded success if empty + StoragesLoaded = false + storagesLoadMu sync.RWMutex + storagesLoadSignal chan struct{} = make(chan struct{}) +) + +func StoragesLoadSignal() <-chan struct{} { + storagesLoadMu.RLock() + ch := storagesLoadSignal + storagesLoadMu.RUnlock() + return ch +} +func SendStoragesLoadedSignal() { + storagesLoadMu.Lock() + select { + case <-storagesLoadSignal: + // already closed + default: + StoragesLoaded = true + close(storagesLoadSignal) + } + storagesLoadMu.Unlock() +} +func ResetStoragesLoadSignal() { + storagesLoadMu.Lock() + select { + case <-storagesLoadSignal: + StoragesLoaded = false + storagesLoadSignal = make(chan struct{}) + default: + // not closed -> nothing to do + } + storagesLoadMu.Unlock() +} diff --git a/internal/db/db.go b/internal/db/db.go index c223fe10..96529c15 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -12,7 +12,7 @@ var db *gorm.DB func Init(d *gorm.DB) { db = d - err := AutoMigrate(new(model.Storage), new(model.User), new(model.Meta), new(model.SettingItem), new(model.Token), new(model.SearchNode), new(model.TaskItem), new(model.SSHPublicKey), new(model.SharingDB)) + err := AutoMigrate(new(model.Storage), new(model.User), new(model.Meta), new(model.SettingItem), new(model.SearchNode), new(model.TaskItem), new(model.SSHPublicKey), new(model.SharingDB)) if err != nil { log.Fatalf("failed migrate database: %s", err.Error()) } diff --git a/internal/db/sharing.go b/internal/db/sharing.go index 3748796b..c96572a4 100644 --- a/internal/db/sharing.go +++ b/internal/db/sharing.go @@ -60,3 +60,7 @@ func DeleteSharingById(id string) error { s := model.SharingDB{ID: id} return errors.WithStack(db.Where(s).Delete(&s).Error) } + +func DeleteSharingsByCreatorId(creatorId uint) error { + return errors.WithStack(db.Where("creator_id = ?", creatorId).Delete(&model.SharingDB{}).Error) +} diff --git a/internal/db/tasks.go b/internal/db/tasks.go index dcb9dfea..36054898 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -1,6 +1,7 @@ package db import ( + "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/pkg/errors" ) @@ -30,6 +31,7 @@ func GetTaskDataFunc(type_s string, enabled bool) func() ([]byte, error) { return nil } return func() ([]byte, error) { + <-conf.StoragesLoadSignal() return []byte(task.PersistData), nil } } diff --git a/internal/driver/config.go b/internal/driver/config.go index bec6d47b..12fb1577 100644 --- a/internal/driver/config.go +++ b/internal/driver/config.go @@ -3,11 +3,9 @@ package driver type Config struct { Name string `json:"name"` LocalSort bool `json:"local_sort"` - // if the driver returns Link with MFile, this should be set to true - OnlyLinkMFile bool `json:"only_local"` - OnlyProxy bool `json:"only_proxy"` - NoCache bool `json:"no_cache"` - NoUpload bool `json:"no_upload"` + OnlyProxy bool `json:"only_proxy"` + NoCache bool `json:"no_cache"` + NoUpload bool `json:"no_upload"` // if need get message from user, such as validate code NeedMs bool `json:"need_ms"` DefaultRoot string `json:"default_root"` @@ -19,8 +17,24 @@ type Config struct { ProxyRangeOption bool `json:"-"` // if the driver returns Link without URL, this should be set to true NoLinkURL bool `json:"-"` + // Link cache behaviour: + // - LinkCacheAuto: let driver decide per-path (implement driver.LinkCacheModeResolver) + // - LinkCacheNone: no extra info added to cache key (default) + // - flags (OR-able) can add more attributes to cache key (IP, UA, ...) + LinkCacheMode `json:"-"` } +type LinkCacheMode int8 + +const ( + LinkCacheAuto LinkCacheMode = -1 // Let the driver decide per-path (use driver.LinkCacheModeResolver) + LinkCacheNone LinkCacheMode = 0 // No extra info added to cache key (default) +) + +const ( + LinkCacheIP LinkCacheMode = 1 << iota // include client IP in cache key + LinkCacheUA // include User-Agent in cache key +) func (c Config) MustProxy() bool { - return c.OnlyProxy || c.OnlyLinkMFile || c.NoLinkURL + return c.OnlyProxy || c.NoLinkURL } diff --git a/internal/driver/driver.go b/internal/driver/driver.go index 2884b543..1ce1e451 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -9,8 +9,8 @@ import ( type Driver interface { Meta Reader - //Writer - //Other + // Writer + // Other } type Meta interface { @@ -47,11 +47,6 @@ type Getter interface { Get(ctx context.Context, path string) (model.Obj, error) } -type GetObjInfo interface { - // GetObjInfo get file info by path - GetObjInfo(ctx context.Context, path string) (model.Obj, error) -} - //type Writer interface { // Mkdir // Move @@ -210,6 +205,16 @@ type ArchiveDecompressResult interface { ArchiveDecompress(ctx context.Context, srcObj, dstDir model.Obj, args model.ArchiveDecompressArgs) ([]model.Obj, error) } +type WithDetails interface { + // GetDetails get storage details (total space, free space, etc.) + GetDetails(ctx context.Context) (*model.StorageDetails, error) +} + type Reference interface { InitReference(storage Driver) error } + +type LinkCacheModeResolver interface { + // ResolveLinkCacheMode returns the LinkCacheMode for the given path. + ResolveLinkCacheMode(path string) LinkCacheMode +} diff --git a/internal/driver/utils.go b/internal/driver/utils.go index 9e4b6b01..22e1e85b 100644 --- a/internal/driver/utils.go +++ b/internal/driver/utils.go @@ -20,7 +20,7 @@ func (p *Progress) Write(b []byte) (n int, err error) { n = len(b) p.Done += int64(n) p.up(float64(p.Done) / float64(p.Total) * 100) - return + return n, err } func NewProgress(total int64, up UpdateProgress) *Progress { @@ -61,3 +61,10 @@ type ReaderWithCtx = stream.ReaderWithCtx type ReaderUpdatingProgress = stream.ReaderUpdatingProgress type SimpleReaderWithSize = stream.SimpleReaderWithSize + +func DiskUsageFromUsedAndTotal(used, total uint64) model.DiskUsage { + return model.DiskUsage{ + TotalSpace: max(used, total), + FreeSpace: total - min(used, total), + } +} diff --git a/internal/errs/errors.go b/internal/errs/errors.go index 4910094e..fdf7f218 100644 --- a/internal/errs/errors.go +++ b/internal/errs/errors.go @@ -12,13 +12,12 @@ var ( NotSupport = errors.New("not support") RelativePath = errors.New("using relative path is not allowed") - MoveBetweenTwoStorages = errors.New("can't move files between two storages, try to copy") - UploadNotSupported = errors.New("upload not supported") - - MetaNotFound = errors.New("meta not found") - StorageNotFound = errors.New("storage not found") - StreamIncomplete = errors.New("upload/download stream incomplete, possible network issue") - StreamPeekFail = errors.New("StreamPeekFail") + UploadNotSupported = errors.New("upload not supported") + MetaNotFound = errors.New("meta not found") + StorageNotFound = errors.New("storage not found") + StorageNotInit = errors.New("storage not init") + StreamIncomplete = errors.New("upload/download stream incomplete, possible network issue") + StreamPeekFail = errors.New("StreamPeekFail") UnknownArchiveFormat = errors.New("unknown archive format") WrongArchivePassword = errors.New("wrong archive password") @@ -42,6 +41,6 @@ func IsNotFoundError(err error) bool { func IsNotSupportError(err error) bool { return errors.Is(pkgerr.Cause(err), NotSupport) } -func IsNotImplement(err error) bool { +func IsNotImplementError(err error) bool { return errors.Is(pkgerr.Cause(err), NotImplement) } diff --git a/internal/errs/unwrap.go b/internal/errs/unwrap.go new file mode 100644 index 00000000..f34953c7 --- /dev/null +++ b/internal/errs/unwrap.go @@ -0,0 +1,11 @@ +package errs + +func UnwrapOrSelf(err error) error { + u, ok := err.(interface { + Unwrap() error + }) + if !ok { + return err + } + return u.Unwrap() +} diff --git a/internal/fs/archive.go b/internal/fs/archive.go index e1e4c448..b2885d2b 100644 --- a/internal/fs/archive.go +++ b/internal/fs/archive.go @@ -41,6 +41,18 @@ func (t *ArchiveDownloadTask) Run() error { if err := t.ReinitCtx(); err != nil { return err } + if t.SrcStorage == nil { + if srcStorage, _, err := op.GetStorageAndActualPath(t.SrcStorageMp); err == nil { + t.SrcStorage = srcStorage + } else { + return err + } + if dstStorage, _, err := op.GetStorageAndActualPath(t.DstStorageMp); err == nil { + t.DstStorage = dstStorage + } else { + return err + } + } t.ClearEndTime() t.SetStartTime(time.Now()) defer func() { t.SetEndTime(time.Now()) }() diff --git a/internal/fs/copy_move.go b/internal/fs/copy_move.go index dbdd2835..e9b70856 100644 --- a/internal/fs/copy_move.go +++ b/internal/fs/copy_move.go @@ -48,6 +48,19 @@ func (t *FileTransferTask) Run() error { if err := t.ReinitCtx(); err != nil { return err } + if t.SrcStorage == nil { + if srcStorage, _, err := op.GetStorageAndActualPath(t.SrcStorageMp); err == nil { + t.SrcStorage = srcStorage + } else { + return err + } + if dstStorage, _, err := op.GetStorageAndActualPath(t.DstStorageMp); err == nil { + t.DstStorage = dstStorage + } else { + return err + } + } + t.ClearEndTime() t.SetStartTime(time.Now()) defer func() { t.SetEndTime(time.Now()) }() @@ -139,7 +152,7 @@ func transfer(ctx context.Context, taskType taskType, srcObjPath, dstDirPath str if taskType == move { task_group.RefreshAndRemove(dstDirPath, task_group.SrcPathToRemove(srcObjPath)) } else { - op.DeleteCache(t.DstStorage, dstDirActualPath) + op.Cache.DeleteDirectory(t.DstStorage, dstDirActualPath) } } return nil, err @@ -173,7 +186,7 @@ func (t *FileTransferTask) RunWithNextTaskCallback(f func(nextTask *FileTransfer dstActualPath := stdpath.Join(t.DstActualPath, srcObj.GetName()) if t.TaskType == copy { if t.Ctx().Value(conf.NoTaskKey) != nil { - defer op.DeleteCache(t.DstStorage, dstActualPath) + defer op.Cache.DeleteDirectory(t.DstStorage, dstActualPath) } else { task_group.TransferCoordinator.AppendPayload(t.groupID, task_group.DstPathToRefresh(dstActualPath)) } diff --git a/internal/fs/fs.go b/internal/fs/fs.go index 8c1f646b..ca199ed4 100644 --- a/internal/fs/fs.go +++ b/internal/fs/fs.go @@ -19,8 +19,9 @@ import ( // then pass the actual path to the op package type ListArgs struct { - Refresh bool - NoLog bool + Refresh bool + NoLog bool + WithStorageDetails bool } func List(ctx context.Context, path string, args *ListArgs) ([]model.Obj, error) { @@ -35,11 +36,12 @@ func List(ctx context.Context, path string, args *ListArgs) ([]model.Obj, error) } type GetArgs struct { - NoLog bool + NoLog bool + WithStorageDetails bool } func Get(ctx context.Context, path string, args *GetArgs) (model.Obj, error) { - res, err := get(ctx, path) + 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 2761322d..459282ce 100644 --- a/internal/fs/get.go +++ b/internal/fs/get.go @@ -11,11 +11,11 @@ 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) { path = utils.FixAndCleanPath(path) // maybe a virtual file if path != "/" { - virtualFiles := op.GetStorageVirtualFilesByPath(stdpath.Dir(path)) + virtualFiles := op.GetStorageVirtualFilesWithDetailsByPath(ctx, stdpath.Dir(path), !args.WithStorageDetails, false) for _, f := range virtualFiles { if f.GetName() == stdpath.Base(path) { return f, nil diff --git a/internal/fs/list.go b/internal/fs/list.go index cfc13229..fc3b25ab 100644 --- a/internal/fs/list.go +++ b/internal/fs/list.go @@ -15,7 +15,7 @@ import ( func list(ctx context.Context, path string, args *ListArgs) ([]model.Obj, error) { meta, _ := ctx.Value(conf.MetaKey).(*model.Meta) user, _ := ctx.Value(conf.UserKey).(*model.User) - virtualFiles := op.GetStorageVirtualFilesByPath(path) + virtualFiles := op.GetStorageVirtualFilesWithDetailsByPath(ctx, path, !args.WithStorageDetails, args.Refresh) storage, actualPath, err := op.GetStorageAndActualPath(path) if err != nil && len(virtualFiles) == 0 { return nil, errors.WithMessage(err, "failed get storage") @@ -24,8 +24,9 @@ func list(ctx context.Context, path string, args *ListArgs) ([]model.Obj, error) var _objs []model.Obj if storage != nil { _objs, err = op.List(ctx, storage, actualPath, model.ListArgs{ - ReqPath: path, - Refresh: args.Refresh, + ReqPath: path, + Refresh: args.Refresh, + WithStorageDetails: args.WithStorageDetails, }) if err != nil { if !args.NoLog { diff --git a/internal/model/args.go b/internal/model/args.go index ead19cbb..3fd5b9e3 100644 --- a/internal/model/args.go +++ b/internal/model/args.go @@ -11,9 +11,10 @@ import ( ) type ListArgs struct { - ReqPath string - S3ShowPlaceholder bool - Refresh bool + ReqPath string + S3ShowPlaceholder bool + Refresh bool + WithStorageDetails bool } type LinkArgs struct { @@ -27,7 +28,6 @@ type Link struct { URL string `json:"url"` // most common way Header http.Header `json:"header"` // needed header (for url) RangeReader RangeReaderIF `json:"-"` // recommended way if can't use URL - MFile File `json:"-"` // best for local,smb... file system, which exposes MFile Expiration *time.Duration // local cache expire Duration @@ -37,6 +37,8 @@ type Link struct { ContentLength int64 `json:"-"` // 转码视频、缩略图 utils.SyncClosers `json:"-"` + // 如果SyncClosers中的资源被关闭后Link将不可用,则此值应为 true + RequireReference bool `json:"-"` } type OtherArgs struct { diff --git a/internal/model/obj.go b/internal/model/obj.go index 836904fc..b3bf5ebe 100644 --- a/internal/model/obj.go +++ b/internal/model/obj.go @@ -80,6 +80,10 @@ type SetPath interface { SetPath(path string) } +type ObjWithProvider interface { + GetProvider() string +} + func SortFiles(objs []Obj, orderBy, orderDirection string) { if orderBy == "" { return @@ -166,6 +170,16 @@ func GetUrl(obj Obj) (url string, ok bool) { return url, false } +func GetProvider(obj Obj) (string, bool) { + if obj, ok := obj.(ObjWithProvider); ok { + return obj.GetProvider(), true + } + if unwrap, ok := obj.(ObjUnwrap); ok { + return GetProvider(unwrap.Unwrap()) + } + return "unknown", false +} + func GetRawObject(obj Obj) *Object { switch v := obj.(type) { case *ObjThumbURL: @@ -174,6 +188,8 @@ func GetRawObject(obj Obj) *Object { return &v.Object case *ObjectURL: return &v.Object + case *ObjectProvider: + return &v.Object case *Object: return v } diff --git a/internal/model/object.go b/internal/model/object.go index b6940746..8e5cdf04 100644 --- a/internal/model/object.go +++ b/internal/model/object.go @@ -99,3 +99,16 @@ type ObjThumbURL struct { Thumbnail Url } + +type Provider struct { + Provider string +} + +func (p Provider) GetProvider() string { + return p.Provider +} + +type ObjectProvider struct { + Object + Provider +} diff --git a/internal/model/sharing.go b/internal/model/sharing.go index dfbc81b0..c5dd95e9 100644 --- a/internal/model/sharing.go +++ b/internal/model/sharing.go @@ -33,7 +33,7 @@ func (s *Sharing) Valid() bool { if len(s.Files) == 0 { return false } - if !s.Creator.CanShare() { + if s.Creator == nil || !s.Creator.CanShare() { return false } if s.Expires != nil && !s.Expires.IsZero() && s.Expires.Before(time.Now()) { diff --git a/internal/model/storage.go b/internal/model/storage.go index 1f60667e..863464cb 100644 --- a/internal/model/storage.go +++ b/internal/model/storage.go @@ -32,7 +32,7 @@ type Proxy struct { WebdavPolicy string `json:"webdav_policy"` ProxyRange bool `json:"proxy_range"` DownProxyURL string `json:"down_proxy_url"` - //Disable sign for DownProxyURL + // Disable sign for DownProxyURL DisableProxySign bool `json:"disable_proxy_sign"` } @@ -55,3 +55,40 @@ func (p Proxy) Webdav302() bool { func (p Proxy) WebdavProxyURL() bool { return p.WebdavPolicy == "use_proxy_url" } + +type DiskUsage struct { + TotalSpace uint64 `json:"total_space"` + FreeSpace uint64 `json:"free_space"` +} + +type StorageDetails struct { + DiskUsage +} + +type StorageDetailsWithName struct { + *StorageDetails + DriverName string `json:"driver_name"` +} + +type ObjWithStorageDetails interface { + GetStorageDetails() *StorageDetailsWithName +} + +type ObjStorageDetails struct { + Obj + StorageDetailsWithName +} + +func (o ObjStorageDetails) GetStorageDetails() *StorageDetailsWithName { + return &o.StorageDetailsWithName +} + +func GetStorageDetails(obj Obj) (*StorageDetailsWithName, bool) { + if obj, ok := obj.(ObjWithStorageDetails); ok { + return obj.GetStorageDetails(), true + } + if unwrap, ok := obj.(ObjUnwrap); ok { + return GetStorageDetails(unwrap.Unwrap()) + } + return nil, false +} diff --git a/internal/net/request.go b/internal/net/request.go index 399e01f3..8d380ea4 100644 --- a/internal/net/request.go +++ b/internal/net/request.go @@ -12,6 +12,7 @@ import ( "time" "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/pkg/utils" "github.com/rclone/rclone/lib/mmap" @@ -125,7 +126,7 @@ type ConcurrencyLimit struct { Limit int // 需要大于0 } -var ErrExceedMaxConcurrency = ErrorHttpStatusCode(http.StatusTooManyRequests) +var ErrExceedMaxConcurrency = HttpStatusCodeError(http.StatusTooManyRequests) func (l *ConcurrencyLimit) sub() error { l._m.Lock() @@ -403,7 +404,7 @@ var errInfiniteRetry = errors.New("infinite retry") func (d *downloader) tryDownloadChunk(params *HttpRequestParams, ch *chunk) (int64, error) { resp, err := d.cfg.HttpClient(d.ctx, params) if err != nil { - statusCode, ok := errors.Unwrap(err).(ErrorHttpStatusCode) + statusCode, ok := errs.UnwrapOrSelf(err).(HttpStatusCodeError) if !ok { return 0, err } diff --git a/internal/net/serve.go b/internal/net/serve.go index 1fd40b1c..f8d2e3ca 100644 --- a/internal/net/serve.go +++ b/internal/net/serve.go @@ -14,6 +14,7 @@ import ( "time" "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/pkg/http_range" "github.com/OpenListTeam/OpenList/v4/pkg/utils" @@ -114,7 +115,7 @@ func ServeHTTP(w http.ResponseWriter, r *http.Request, name string, modTime time reader, err := RangeReadCloser.RangeRead(ctx, http_range.Range{Length: -1}) if err != nil { code = http.StatusRequestedRangeNotSatisfiable - if statusCode, ok := errors.Unwrap(err).(ErrorHttpStatusCode); ok { + if statusCode, ok := errs.UnwrapOrSelf(err).(HttpStatusCodeError); ok { code = int(statusCode) } http.Error(w, err.Error(), code) @@ -137,7 +138,7 @@ func ServeHTTP(w http.ResponseWriter, r *http.Request, name string, modTime time sendContent, err = RangeReadCloser.RangeRead(ctx, ra) if err != nil { code = http.StatusRequestedRangeNotSatisfiable - if statusCode, ok := errors.Unwrap(err).(ErrorHttpStatusCode); ok { + if statusCode, ok := errs.UnwrapOrSelf(err).(HttpStatusCodeError); ok { code = int(statusCode) } http.Error(w, err.Error(), code) @@ -199,7 +200,7 @@ func ServeHTTP(w http.ResponseWriter, r *http.Request, name string, modTime time log.Warnf("Maybe size incorrect or reader not giving correct/full data, or connection closed before finish. written bytes: %d ,sendSize:%d, ", written, sendSize) } code = http.StatusInternalServerError - if statusCode, ok := errors.Unwrap(err).(ErrorHttpStatusCode); ok { + if statusCode, ok := errs.UnwrapOrSelf(err).(HttpStatusCodeError); ok { code = int(statusCode) } w.WriteHeader(code) @@ -253,14 +254,14 @@ func RequestHttp(ctx context.Context, httpMethod string, headerOverride http.Hea _ = res.Body.Close() msg := string(all) log.Debugln(msg) - return nil, fmt.Errorf("http request [%s] failure,status: %w response:%s", URL, ErrorHttpStatusCode(res.StatusCode), msg) + return nil, fmt.Errorf("http request [%s] failure,status: %w response:%s", URL, HttpStatusCodeError(res.StatusCode), msg) } return res, nil } -type ErrorHttpStatusCode int +type HttpStatusCodeError int -func (e ErrorHttpStatusCode) Error() string { +func (e HttpStatusCodeError) Error() string { return fmt.Sprintf("%d|%s", e, http.StatusText(int(e))) } diff --git a/internal/offline_download/123_open/client.go b/internal/offline_download/123_open/client.go new file mode 100644 index 00000000..ce1453c3 --- /dev/null +++ b/internal/offline_download/123_open/client.go @@ -0,0 +1,119 @@ +package _123_open + +import ( + "context" + "fmt" + "strconv" + + _123_open "github.com/OpenListTeam/OpenList/v4/drivers/123_open" + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/offline_download/tool" + "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/internal/setting" +) + +type Open123 struct{} + +func (*Open123) Name() string { + return "123 Open" +} + +func (*Open123) Items() []model.SettingItem { + return nil +} + +func (*Open123) Run(_ *tool.DownloadTask) error { + return errs.NotSupport +} + +func (*Open123) Init() (string, error) { + return "ok", nil +} + +func (*Open123) IsReady() bool { + tempDir := setting.GetStr(conf.Pan123OpenTempDir) + if tempDir == "" { + return false + } + storage, _, err := op.GetStorageAndActualPath(tempDir) + if err != nil { + return false + } + if _, ok := storage.(*_123_open.Open123); !ok { + return false + } + return true +} + +func (*Open123) AddURL(args *tool.AddUrlArgs) (string, error) { + storage, actualPath, err := op.GetStorageAndActualPath(args.TempDir) + if err != nil { + return "", err + } + driver123Open, ok := storage.(*_123_open.Open123) + if !ok { + return "", fmt.Errorf("unsupported storage driver for offline download, only 123 Open is supported") + } + ctx := context.Background() + if err := op.MakeDir(ctx, storage, actualPath); err != nil { + return "", err + } + parentDir, err := op.GetUnwrap(ctx, storage, actualPath) + if err != nil { + return "", err + } + cb := setting.GetStr(conf.Pan123OpenOfflineDownloadCallbackUrl) + taskID, err := driver123Open.OfflineDownload(ctx, args.Url, parentDir, cb) + if err != nil { + return "", fmt.Errorf("failed to add offline download task: %w", err) + } + return strconv.Itoa(taskID), nil +} + +func (*Open123) Remove(_ *tool.DownloadTask) error { + return errs.NotSupport +} + +func (*Open123) Status(task *tool.DownloadTask) (*tool.Status, error) { + taskID, err := strconv.Atoi(task.GID) + if err != nil { + return nil, fmt.Errorf("failed to parse task ID: %s", task.GID) + } + storage, _, err := op.GetStorageAndActualPath(task.TempDir) + if err != nil { + return nil, err + } + driver123Open, ok := storage.(*_123_open.Open123) + if !ok { + return nil, fmt.Errorf("unsupported storage driver for offline download, only 123 Open is supported") + } + process, status, err := driver123Open.OfflineDownloadProcess(context.Background(), taskID) + if err != nil { + return nil, err + } + var statusStr string + switch status { + case 0: + statusStr = "downloading" + case 1: + err = fmt.Errorf("offline download failed") + case 2: + statusStr = "succeed" + case 3: + statusStr = "retrying" + } + return &tool.Status{ + Progress: process, + Completed: status == 2, + Status: statusStr, + Err: err, + }, nil +} + +var _ tool.Tool = (*Open123)(nil) + +func init() { + tool.Tools.Add(&Open123{}) +} diff --git a/internal/offline_download/all.go b/internal/offline_download/all.go index f79a8312..a133e98b 100644 --- a/internal/offline_download/all.go +++ b/internal/offline_download/all.go @@ -3,6 +3,7 @@ package offline_download import ( _ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/115" _ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/115_open" + _ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/123_open" _ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/aria2" _ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/http" _ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/pikpak" diff --git a/internal/offline_download/http/client.go b/internal/offline_download/http/client.go index f0bb5b4f..5dcb6d59 100644 --- a/internal/offline_download/http/client.go +++ b/internal/offline_download/http/client.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/offline_download/tool" "github.com/OpenListTeam/OpenList/v4/pkg/http_range" @@ -58,6 +59,7 @@ func (s SimpleHttp) Run(task *tool.DownloadTask) error { if err != nil { return err } + req.Header.Set("User-Agent", base.UserAgent) if streamPut { req.Header.Set("Range", "bytes=0-") } diff --git a/internal/offline_download/tool/add.go b/internal/offline_download/tool/add.go index aea88e2a..6edee740 100644 --- a/internal/offline_download/tool/add.go +++ b/internal/offline_download/tool/add.go @@ -2,18 +2,16 @@ package tool import ( "context" - "github.com/OpenListTeam/OpenList/v4/drivers/thunder_browser" - - _115_open "github.com/OpenListTeam/OpenList/v4/drivers/115_open" - "github.com/OpenListTeam/OpenList/v4/server/common" - "net/url" stdpath "path" "path/filepath" _115 "github.com/OpenListTeam/OpenList/v4/drivers/115" + _115_open "github.com/OpenListTeam/OpenList/v4/drivers/115_open" + _123_open "github.com/OpenListTeam/OpenList/v4/drivers/123_open" "github.com/OpenListTeam/OpenList/v4/drivers/pikpak" "github.com/OpenListTeam/OpenList/v4/drivers/thunder" + "github.com/OpenListTeam/OpenList/v4/drivers/thunder_browser" "github.com/OpenListTeam/OpenList/v4/drivers/thunderx" "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/errs" @@ -22,6 +20,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/op" "github.com/OpenListTeam/OpenList/v4/internal/setting" "github.com/OpenListTeam/OpenList/v4/internal/task" + "github.com/OpenListTeam/OpenList/v4/server/common" "github.com/google/uuid" "github.com/pkg/errors" ) @@ -104,6 +103,13 @@ func AddURL(ctx context.Context, args *AddURLArgs) (task.TaskExtensionInfo, erro } else { tempDir = filepath.Join(setting.GetStr(conf.Pan115OpenTempDir), uid) } + case "123 Open": + if _, ok := storage.(*_123_open.Open123); ok && dstDirActualPath != "/" { + // directly offline downloading to the root path is not allowed via 123 open platform + tempDir = args.DstDirPath + } else { + tempDir = filepath.Join(setting.GetStr(conf.Pan123OpenTempDir), uid) + } case "PikPak": if _, ok := storage.(*pikpak.PikPak); ok { tempDir = args.DstDirPath diff --git a/internal/offline_download/tool/download.go b/internal/offline_download/tool/download.go index 2d402dcf..f6c53081 100644 --- a/internal/offline_download/tool/download.go +++ b/internal/offline_download/tool/download.go @@ -111,6 +111,9 @@ outer: if t.tool.Name() == "115 Open" { return nil } + if t.tool.Name() == "123 Open" { + return nil + } t.Status = "offline download completed, maybe transferring" // hack for qBittorrent if t.tool.Name() == "qBittorrent" { @@ -174,7 +177,7 @@ func (t *DownloadTask) Update() (bool, error) { func (t *DownloadTask) Transfer() error { toolName := t.tool.Name() - if toolName == "115 Cloud" || toolName == "115 Open" || toolName == "PikPak" || toolName == "Thunder" || toolName == "ThunderX" || toolName == "ThunderBrowser" { + if toolName == "115 Cloud" || toolName == "115 Open" || toolName == "123 Open" || toolName == "PikPak" || toolName == "Thunder" || toolName == "ThunderX" || toolName == "ThunderBrowser" { // 如果不是直接下载到目标路径,则进行转存 if t.TempDir != t.DstDirPath { return transferObj(t.Ctx(), t.TempDir, t.DstDirPath, t.DeletePolicy) diff --git a/internal/offline_download/tool/transfer.go b/internal/offline_download/tool/transfer.go index 1c1284a0..7daf0b17 100644 --- a/internal/offline_download/tool/transfer.go +++ b/internal/offline_download/tool/transfer.go @@ -34,6 +34,20 @@ func (t *TransferTask) Run() error { if err := t.ReinitCtx(); err != nil { return err } + if t.SrcStorage == nil && t.SrcStorageMp != "" { + if srcStorage, _, err := op.GetStorageAndActualPath(t.SrcStorageMp); err == nil { + t.SrcStorage = srcStorage + } else { + return err + } + if t.DstStorage == nil { + if dstStorage, _, err := op.GetStorageAndActualPath(t.DstStorageMp); err == nil { + t.DstStorage = dstStorage + } else { + return err + } + } + } t.ClearEndTime() t.SetStartTime(time.Now()) defer func() { t.SetEndTime(time.Now()) }() @@ -64,9 +78,8 @@ func (t *TransferTask) Run() error { return op.Put(t.Ctx(), t.DstStorage, t.DstActualPath, s, t.SetProgress) } return transferStdPath(t) - } else { - return transferObjPath(t) } + return transferObjPath(t) } func (t *TransferTask) GetName() string { diff --git a/internal/op/archive.go b/internal/op/archive.go index 964e9397..50e05141 100644 --- a/internal/op/archive.go +++ b/internal/op/archive.go @@ -10,6 +10,7 @@ import ( "time" "github.com/OpenListTeam/OpenList/v4/internal/archive/tool" + "github.com/OpenListTeam/OpenList/v4/internal/cache" "github.com/OpenListTeam/OpenList/v4/internal/stream" "github.com/OpenListTeam/OpenList/v4/internal/driver" @@ -17,17 +18,17 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/pkg/singleflight" "github.com/OpenListTeam/OpenList/v4/pkg/utils" - "github.com/OpenListTeam/go-cache" + gocache "github.com/OpenListTeam/go-cache" "github.com/pkg/errors" log "github.com/sirupsen/logrus" ) -var archiveMetaCache = cache.NewMemCache(cache.WithShards[*model.ArchiveMetaProvider](64)) +var archiveMetaCache = gocache.NewMemCache(gocache.WithShards[*model.ArchiveMetaProvider](64)) var archiveMetaG singleflight.Group[*model.ArchiveMetaProvider] func GetArchiveMeta(ctx context.Context, storage driver.Driver, path string, args model.ArchiveMetaArgs) (*model.ArchiveMetaProvider, error) { if storage.Config().CheckStatus && storage.GetStorage().Status != WORK { - return nil, errors.Errorf("storage not init: %s", storage.GetStorage().Status) + return nil, errors.WithMessagef(errs.StorageNotInit, "storage status: %s", storage.GetStorage().Status) } path = utils.FixAndCleanPath(path) key := Key(storage, path) @@ -37,14 +38,14 @@ func GetArchiveMeta(ctx context.Context, storage driver.Driver, path string, arg return nil, errors.Wrapf(err, "failed to get %s archive met: %+v", path, err) } if m.Expiration != nil { - archiveMetaCache.Set(key, m, cache.WithEx[*model.ArchiveMetaProvider](*m.Expiration)) + archiveMetaCache.Set(key, m, gocache.WithEx[*model.ArchiveMetaProvider](*m.Expiration)) } return m, nil } - if storage.Config().OnlyLinkMFile { - meta, err := fn() - return meta, err - } + // if storage.Config().NoLinkSingleflight { + // meta, err := fn() + // return meta, err + // } if !args.Refresh { if meta, ok := archiveMetaCache.Get(key); ok { log.Debugf("use cache when get %s archive meta", path) @@ -158,12 +159,12 @@ func getArchiveMeta(ctx context.Context, storage driver.Driver, path string, arg return obj, archiveMetaProvider, err } -var archiveListCache = cache.NewMemCache(cache.WithShards[[]model.Obj](64)) +var archiveListCache = gocache.NewMemCache(gocache.WithShards[[]model.Obj](64)) var archiveListG singleflight.Group[[]model.Obj] func ListArchive(ctx context.Context, storage driver.Driver, path string, args model.ArchiveListArgs) ([]model.Obj, error) { if storage.Config().CheckStatus && storage.GetStorage().Status != WORK { - return nil, errors.Errorf("storage not init: %s", storage.GetStorage().Status) + return nil, errors.WithMessagef(errs.StorageNotInit, "storage status: %s", storage.GetStorage().Status) } path = utils.FixAndCleanPath(path) metaKey := Key(storage, path) @@ -199,7 +200,7 @@ func ListArchive(ctx context.Context, storage driver.Driver, path string, args m if !storage.Config().NoCache { if len(files) > 0 { log.Debugf("set cache: %s => %+v", key, files) - archiveListCache.Set(key, files, cache.WithEx[[]model.Obj](time.Minute*time.Duration(storage.GetStorage().CacheExpiration))) + archiveListCache.Set(key, files, gocache.WithEx[[]model.Obj](time.Minute*time.Duration(storage.GetStorage().CacheExpiration))) } else { log.Debugf("del cache: %s", key) archiveListCache.Del(key) @@ -309,7 +310,7 @@ func splitPath(path string) []string { func ArchiveGet(ctx context.Context, storage driver.Driver, path string, args model.ArchiveListArgs) (model.Obj, model.Obj, error) { if storage.Config().CheckStatus && storage.GetStorage().Status != WORK { - return nil, nil, errors.Errorf("storage not init: %s", storage.GetStorage().Status) + return nil, nil, errors.WithMessagef(errs.StorageNotInit, "storage status: %s", storage.GetStorage().Status) } path = utils.FixAndCleanPath(path) af, err := GetUnwrap(ctx, storage, path) @@ -354,78 +355,50 @@ func ArchiveGet(ctx context.Context, storage driver.Driver, path string, args mo return nil, nil, errors.WithStack(errs.ObjectNotFound) } -type extractLink struct { - *model.Link - Obj model.Obj +type objWithLink struct { + link *model.Link + obj model.Obj } -var extractCache = cache.NewMemCache(cache.WithShards[*extractLink](16)) -var extractG = singleflight.Group[*extractLink]{Remember: true} +var extractCache = cache.NewKeyedCache[*objWithLink](5 * time.Minute) +var extractG = singleflight.Group[*objWithLink]{} func DriverExtract(ctx context.Context, storage driver.Driver, path string, args model.ArchiveInnerArgs) (*model.Link, model.Obj, error) { if storage.Config().CheckStatus && storage.GetStorage().Status != WORK { - return nil, nil, errors.Errorf("storage not init: %s", storage.GetStorage().Status) + return nil, nil, errors.WithMessagef(errs.StorageNotInit, "storage status: %s", storage.GetStorage().Status) } key := stdpath.Join(Key(storage, path), args.InnerPath) - if link, ok := extractCache.Get(key); ok { - return link.Link, link.Obj, nil + if ol, ok := extractCache.Get(key); ok { + if ol.link.Expiration != nil || ol.link.SyncClosers.AcquireReference() || !ol.link.RequireReference { + return ol.link, ol.obj, nil + } } - var forget any - var linkM *extractLink - fn := func() (*extractLink, error) { - link, err := driverExtract(ctx, storage, path, args) + fn := func() (*objWithLink, error) { + ol, err := driverExtract(ctx, storage, path, args) if err != nil { return nil, errors.Wrapf(err, "failed extract archive") } - if link.MFile != nil && forget != nil { - linkM = link - return nil, errLinkMFileCache + if ol.link.Expiration != nil { + extractCache.SetWithTTL(key, ol, *ol.link.Expiration) + } else { + extractCache.SetWithExpirable(key, ol, &ol.link.SyncClosers) } - if link.Link.Expiration != nil { - extractCache.Set(key, link, cache.WithEx[*extractLink](*link.Link.Expiration)) - } - link.AddIfCloser(forget) - return link, nil + return ol, nil } - if storage.Config().OnlyLinkMFile { - link, err := fn() + for { + ol, err, _ := extractG.Do(key, fn) if err != nil { return nil, nil, err } - return link.Link, link.Obj, nil - } - - forget = utils.CloseFunc(func() error { - if forget != nil { - forget = nil - linkG.Forget(key) + if ol.link.SyncClosers.AcquireReference() || !ol.link.RequireReference { + return ol.link, ol.obj, nil } - return nil - }) - link, err, _ := extractG.Do(key, fn) - if err == nil && !link.AcquireReference() { - link, err, _ = extractG.Do(key, fn) - if err == nil { - link.AcquireReference() - } - } - if err == errLinkMFileCache { - if linkM != nil { - return linkM.Link, linkM.Obj, nil - } - forget = nil - link, err = fn() - } - - if err != nil { - return nil, nil, err } - return link.Link, link.Obj, nil } -func driverExtract(ctx context.Context, storage driver.Driver, path string, args model.ArchiveInnerArgs) (*extractLink, error) { +func driverExtract(ctx context.Context, storage driver.Driver, path string, args model.ArchiveInnerArgs) (*objWithLink, error) { storageAr, ok := storage.(driver.ArchiveReader) if !ok { return nil, errs.DriverExtractNotSupported @@ -441,7 +414,7 @@ func driverExtract(ctx context.Context, storage driver.Driver, path string, args return nil, errors.WithStack(errs.NotFile) } link, err := storageAr.Extract(ctx, archiveFile, args) - return &extractLink{Link: link, Obj: extracted}, err + return &objWithLink{link: link, obj: extracted}, err } type streamWithParent struct { @@ -483,7 +456,7 @@ func InternalExtract(ctx context.Context, storage driver.Driver, path string, ar func ArchiveDecompress(ctx context.Context, storage driver.Driver, srcPath, dstDirPath string, args model.ArchiveDecompressArgs, lazyCache ...bool) error { if storage.Config().CheckStatus && storage.GetStorage().Status != WORK { - return errors.Errorf("storage not init: %s", storage.GetStorage().Status) + return errors.WithMessagef(errs.StorageNotInit, "storage status: %s", storage.GetStorage().Status) } srcPath = utils.FixAndCleanPath(srcPath) dstDirPath = utils.FixAndCleanPath(dstDirPath) @@ -503,16 +476,16 @@ func ArchiveDecompress(ctx context.Context, storage driver.Driver, srcPath, dstD if err == nil { if len(newObjs) > 0 { for _, newObj := range newObjs { - addCacheObj(storage, dstDirPath, model.WrapObjName(newObj)) + Cache.addDirectoryObject(storage, dstDirPath, model.WrapObjName(newObj)) } } else if !utils.IsBool(lazyCache...) { - DeleteCache(storage, dstDirPath) + Cache.DeleteDirectory(storage, dstDirPath) } } case driver.ArchiveDecompress: err = s.ArchiveDecompress(ctx, srcObj, dstDir, args) if err == nil && !utils.IsBool(lazyCache...) { - DeleteCache(storage, dstDirPath) + Cache.DeleteDirectory(storage, dstDirPath) } default: return errs.NotImplement diff --git a/internal/op/cache.go b/internal/op/cache.go new file mode 100644 index 00000000..52e430b1 --- /dev/null +++ b/internal/op/cache.go @@ -0,0 +1,257 @@ +package op + +import ( + stdpath "path" + "sync" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/cache" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +type CacheManager struct { + dirCache *cache.KeyedCache[*directoryCache] // Cache for directory listings + linkCache *cache.TypedCache[*objWithLink] // Cache for file links + userCache *cache.KeyedCache[*model.User] // Cache for user data + settingCache *cache.KeyedCache[any] // Cache for settings + detailCache *cache.KeyedCache[*model.StorageDetails] // Cache for storage details +} + +func NewCacheManager() *CacheManager { + return &CacheManager{ + dirCache: cache.NewKeyedCache[*directoryCache](time.Minute * 5), + linkCache: cache.NewTypedCache[*objWithLink](time.Minute * 30), + userCache: cache.NewKeyedCache[*model.User](time.Hour), + settingCache: cache.NewKeyedCache[any](time.Hour), + detailCache: cache.NewKeyedCache[*model.StorageDetails](time.Minute * 30), + } +} + +// global instance +var Cache = NewCacheManager() + +func Key(storage driver.Driver, path string) string { + return stdpath.Join(storage.GetStorage().MountPath, path) +} + +// update object in dirCache. +// if it's a directory, remove all its children from dirCache too. +// if it's a file, remove its link from linkCache. +func (cm *CacheManager) updateDirectoryObject(storage driver.Driver, dirPath string, oldObj model.Obj, newObj model.Obj) { + key := Key(storage, dirPath) + if !oldObj.IsDir() { + cm.linkCache.DeleteKey(stdpath.Join(key, oldObj.GetName())) + cm.linkCache.DeleteKey(stdpath.Join(key, newObj.GetName())) + } + if storage.Config().NoCache { + return + } + + if cache, exist := cm.dirCache.Get(key); exist { + if oldObj.IsDir() { + cm.deleteDirectoryTree(stdpath.Join(key, oldObj.GetName())) + } + cache.UpdateObject(oldObj.GetName(), newObj) + } +} + +// add new object to dirCache +func (cm *CacheManager) addDirectoryObject(storage driver.Driver, dirPath string, newObj model.Obj) { + if storage.Config().NoCache { + return + } + cache, exist := cm.dirCache.Get(Key(storage, dirPath)) + if exist { + cache.UpdateObject(newObj.GetName(), newObj) + } +} + +// recursively delete directory and its children from dirCache +func (cm *CacheManager) DeleteDirectoryTree(storage driver.Driver, dirPath string) { + if storage.Config().NoCache { + return + } + cm.deleteDirectoryTree(Key(storage, dirPath)) +} +func (cm *CacheManager) deleteDirectoryTree(key string) { + if dirCache, exists := cm.dirCache.Take(key); exists { + for _, obj := range dirCache.objs { + if obj.IsDir() { + cm.deleteDirectoryTree(stdpath.Join(key, obj.GetName())) + } + } + } +} + +// remove directory from dirCache +func (cm *CacheManager) DeleteDirectory(storage driver.Driver, dirPath string) { + if storage.Config().NoCache { + return + } + cm.dirCache.Delete(Key(storage, dirPath)) +} + +// remove object from dirCache. +// if it's a directory, remove all its children from dirCache too. +// if it's a file, remove its link from linkCache. +func (cm *CacheManager) removeDirectoryObject(storage driver.Driver, dirPath string, obj model.Obj) { + key := Key(storage, dirPath) + if !obj.IsDir() { + cm.linkCache.DeleteKey(stdpath.Join(key, obj.GetName())) + } + + if storage.Config().NoCache { + return + } + if cache, exist := cm.dirCache.Get(key); exist { + if obj.IsDir() { + cm.deleteDirectoryTree(stdpath.Join(key, obj.GetName())) + } + cache.RemoveObject(obj.GetName()) + } +} + +// cache user data +func (cm *CacheManager) SetUser(username string, user *model.User) { + cm.userCache.Set(username, user) +} + +// cached user data +func (cm *CacheManager) GetUser(username string) (*model.User, bool) { + return cm.userCache.Get(username) +} + +// remove user data from cache +func (cm *CacheManager) DeleteUser(username string) { + cm.userCache.Delete(username) +} + +// caches setting +func (cm *CacheManager) SetSetting(key string, setting *model.SettingItem) { + cm.settingCache.Set(key, setting) +} + +// cached setting +func (cm *CacheManager) GetSetting(key string) (*model.SettingItem, bool) { + if data, exists := cm.settingCache.Get(key); exists { + if setting, ok := data.(*model.SettingItem); ok { + return setting, true + } + } + return nil, false +} + +// cache setting groups +func (cm *CacheManager) SetSettingGroup(key string, settings []model.SettingItem) { + cm.settingCache.Set(key, settings) +} + +// cached setting group +func (cm *CacheManager) GetSettingGroup(key string) ([]model.SettingItem, bool) { + if data, exists := cm.settingCache.Get(key); exists { + if settings, ok := data.([]model.SettingItem); ok { + return settings, true + } + } + return nil, false +} + +func (cm *CacheManager) SetStorageDetails(storage driver.Driver, details *model.StorageDetails) { + if storage.Config().NoCache { + return + } + expiration := time.Minute * time.Duration(storage.GetStorage().CacheExpiration) + cm.detailCache.SetWithTTL(storage.GetStorage().MountPath, details, expiration) +} + +func (cm *CacheManager) GetStorageDetails(storage driver.Driver) (*model.StorageDetails, bool) { + return cm.detailCache.Get(storage.GetStorage().MountPath) +} + +func (cm *CacheManager) InvalidateStorageDetails(storage driver.Driver) { + cm.detailCache.Delete(storage.GetStorage().MountPath) +} + +// clears all caches +func (cm *CacheManager) ClearAll() { + cm.dirCache.Clear() + cm.linkCache.Clear() + cm.userCache.Clear() + cm.settingCache.Clear() + cm.detailCache.Clear() +} + +type directoryCache struct { + objs []model.Obj + sorted []model.Obj + mu sync.RWMutex + + dirtyFlags uint8 +} + +const ( + dirtyRemove uint8 = 1 << iota // 对象删除:刷新 sorted 副本,但不需要 full sort/extract + dirtyUpdate // 对象更新:需要执行 full sort + extract +) + +func newDirectoryCache(objs []model.Obj) *directoryCache { + sorted := make([]model.Obj, len(objs)) + copy(sorted, objs) + return &directoryCache{ + objs: objs, + sorted: sorted, + } +} + +func (dc *directoryCache) RemoveObject(name string) { + dc.mu.Lock() + defer dc.mu.Unlock() + for i, obj := range dc.objs { + if obj.GetName() == name { + dc.objs = append(dc.objs[:i], dc.objs[i+1:]...) + dc.dirtyFlags |= dirtyRemove + break + } + } +} + +func (dc *directoryCache) UpdateObject(oldName string, newObj model.Obj) { + dc.mu.Lock() + defer dc.mu.Unlock() + if oldName != "" { + for i, obj := range dc.objs { + if obj.GetName() == oldName { + dc.objs[i] = newObj + dc.dirtyFlags |= dirtyUpdate + return + } + } + } + dc.objs = append(dc.objs, newObj) + dc.dirtyFlags |= dirtyUpdate +} + +func (dc *directoryCache) GetSortedObjects(meta driver.Meta) []model.Obj { + dc.mu.RLock() + if dc.dirtyFlags == 0 { + dc.mu.RUnlock() + return dc.sorted + } + dc.mu.RUnlock() + dc.mu.Lock() + defer dc.mu.Unlock() + + sorted := make([]model.Obj, len(dc.objs)) + copy(sorted, dc.objs) + dc.sorted = sorted + if dc.dirtyFlags&dirtyUpdate != 0 { + storage := meta.GetStorage() + if meta.Config().LocalSort { + model.SortFiles(sorted, storage.OrderBy, storage.OrderDirection) + } + model.ExtractFolder(sorted, storage.ExtractFolder) + } + dc.dirtyFlags = 0 + return sorted +} diff --git a/internal/op/fs.go b/internal/op/fs.go index bdf0567b..c761828e 100644 --- a/internal/op/fs.go +++ b/internal/op/fs.go @@ -4,129 +4,35 @@ import ( "context" stderrors "errors" stdpath "path" - "slices" - "strings" "time" "github.com/OpenListTeam/OpenList/v4/internal/driver" "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/stream" - "github.com/OpenListTeam/OpenList/v4/pkg/generic_sync" "github.com/OpenListTeam/OpenList/v4/pkg/singleflight" "github.com/OpenListTeam/OpenList/v4/pkg/utils" - "github.com/OpenListTeam/go-cache" "github.com/pkg/errors" log "github.com/sirupsen/logrus" ) -// In order to facilitate adding some other things before and after file op - -var listCache = cache.NewMemCache(cache.WithShards[[]model.Obj](64)) var listG singleflight.Group[[]model.Obj] -func updateCacheObj(storage driver.Driver, path string, oldObj model.Obj, newObj model.Obj) { - key := Key(storage, path) - objs, ok := listCache.Get(key) - if ok { - for i, obj := range objs { - if obj.GetName() == newObj.GetName() { - objs = slices.Delete(objs, i, i+1) - break - } - } - for i, obj := range objs { - if obj.GetName() == oldObj.GetName() { - objs[i] = newObj - break - } - } - listCache.Set(key, objs, cache.WithEx[[]model.Obj](time.Minute*time.Duration(storage.GetStorage().CacheExpiration))) - } -} - -func delCacheObj(storage driver.Driver, path string, obj model.Obj) { - key := Key(storage, path) - objs, ok := listCache.Get(key) - if ok { - for i, oldObj := range objs { - if oldObj.GetName() == obj.GetName() { - objs = append(objs[:i], objs[i+1:]...) - break - } - } - listCache.Set(key, objs, cache.WithEx[[]model.Obj](time.Minute*time.Duration(storage.GetStorage().CacheExpiration))) - } -} - -var addSortDebounceMap generic_sync.MapOf[string, func(func())] - -func addCacheObj(storage driver.Driver, path string, newObj model.Obj) { - key := Key(storage, path) - objs, ok := listCache.Get(key) - if ok { - for i, obj := range objs { - if obj.GetName() == newObj.GetName() { - objs[i] = newObj - return - } - } - - // Simple separation of files and folders - if len(objs) > 0 && objs[len(objs)-1].IsDir() == newObj.IsDir() { - objs = append(objs, newObj) - } else { - objs = append([]model.Obj{newObj}, objs...) - } - - if storage.Config().LocalSort { - debounce, _ := addSortDebounceMap.LoadOrStore(key, utils.NewDebounce(time.Minute)) - log.Debug("addCacheObj: wait start sort") - debounce(func() { - log.Debug("addCacheObj: start sort") - model.SortFiles(objs, storage.GetStorage().OrderBy, storage.GetStorage().OrderDirection) - addSortDebounceMap.Delete(key) - }) - } - - listCache.Set(key, objs, cache.WithEx[[]model.Obj](time.Minute*time.Duration(storage.GetStorage().CacheExpiration))) - } -} - -func ClearCache(storage driver.Driver, path string) { - objs, ok := listCache.Get(Key(storage, path)) - if ok { - for _, obj := range objs { - if obj.IsDir() { - ClearCache(storage, stdpath.Join(path, obj.GetName())) - } - } - } - listCache.Del(Key(storage, path)) -} - -func DeleteCache(storage driver.Driver, path string) { - listCache.Del(Key(storage, path)) -} - -func Key(storage driver.Driver, path string) string { - return stdpath.Join(storage.GetStorage().MountPath, utils.FixAndCleanPath(path)) -} - // List files in storage, not contains virtual file func List(ctx context.Context, storage driver.Driver, path string, args model.ListArgs) ([]model.Obj, error) { if storage.Config().CheckStatus && storage.GetStorage().Status != WORK { - return nil, errors.Errorf("storage not init: %s", storage.GetStorage().Status) + return nil, errors.WithMessagef(errs.StorageNotInit, "storage status: %s", storage.GetStorage().Status) } path = utils.FixAndCleanPath(path) log.Debugf("op.List %s", path) key := Key(storage, path) if !args.Refresh { - if files, ok := listCache.Get(key); ok { + if dirCache, exists := Cache.dirCache.Get(key); exists { log.Debugf("use cache when list %s", path) - return files, nil + return dirCache.GetSortedObjects(storage), nil } } + dir, err := GetUnwrap(ctx, storage, path) if err != nil { return nil, errors.WithMessage(err, "failed get dir") @@ -135,6 +41,7 @@ func List(ctx context.Context, storage driver.Driver, path string, args model.Li if !dir.IsDir() { return nil, errors.WithStack(errs.NotFolder) } + objs, err, _ := listG.Do(key, func() ([]model.Obj, error) { files, err := storage.List(ctx, dir, args) if err != nil { @@ -162,10 +69,11 @@ func List(ctx context.Context, storage driver.Driver, path string, args model.Li if !storage.Config().NoCache { if len(files) > 0 { log.Debugf("set cache: %s => %+v", key, files) - listCache.Set(key, files, cache.WithEx[[]model.Obj](time.Minute*time.Duration(storage.GetStorage().CacheExpiration))) + ttl := time.Minute * time.Duration(storage.GetStorage().CacheExpiration) + Cache.dirCache.SetWithTTL(key, newDirectoryCache(files), ttl) } else { log.Debugf("del cache: %s", key) - listCache.Del(key) + Cache.deleteDirectoryTree(key) } } return files, nil @@ -184,6 +92,9 @@ func Get(ctx context.Context, storage driver.Driver, path string) (model.Obj, er if err == nil { return model.WrapObjName(obj), nil } + if !errs.IsNotImplementError(err) && !errs.IsNotSupportError(err) { + return nil, errors.WithMessage(err, "failed to get obj") + } } // is root folder @@ -249,103 +160,68 @@ func GetUnwrap(ctx context.Context, storage driver.Driver, path string) (model.O return model.UnwrapObj(obj), err } -var linkCache = cache.NewMemCache(cache.WithShards[*model.Link](16)) -var linkG = singleflight.Group[*model.Link]{Remember: true} -var errLinkMFileCache = stderrors.New("ErrLinkMFileCache") +var linkG = singleflight.Group[*objWithLink]{} // Link get link, if is an url. should have an expiry time func Link(ctx context.Context, storage driver.Driver, path string, args model.LinkArgs) (*model.Link, model.Obj, error) { if storage.Config().CheckStatus && storage.GetStorage().Status != WORK { - return nil, nil, errors.Errorf("storage not init: %s", storage.GetStorage().Status) - } - var ( - file model.Obj - err error - ) - // use cache directly - dir, name := stdpath.Split(stdpath.Join(storage.GetStorage().MountPath, path)) - if cacheFiles, ok := listCache.Get(strings.TrimSuffix(dir, "/")); ok { - for _, f := range cacheFiles { - if f.GetName() == name { - file = model.UnwrapObj(f) - break - } - } - } else { - if g, ok := storage.(driver.GetObjInfo); ok { - file, err = g.GetObjInfo(ctx, path) - } else { - file, err = GetUnwrap(ctx, storage, path) - } + return nil, nil, errors.WithMessagef(errs.StorageNotInit, "storage status: %s", storage.GetStorage().Status) } - if file == nil { - if err != nil { - return nil, nil, errors.WithMessage(err, "failed to get file") - } - return nil, nil, errors.WithStack(errs.ObjectNotFound) + + mode := storage.Config().LinkCacheMode + if mode == -1 { + mode = storage.(driver.LinkCacheModeResolver).ResolveLinkCacheMode(path) } - if file.IsDir() { - return nil, nil, errors.WithStack(errs.NotFile) + typeKey := args.Type + if mode&driver.LinkCacheIP == 1 { + typeKey += "/" + args.IP } - - key := stdpath.Join(Key(storage, path), args.Type) - if link, ok := linkCache.Get(key); ok { - return link, file, nil + if mode&driver.LinkCacheUA == 1 { + typeKey += "/" + args.Header.Get("User-Agent") } + key := Key(storage, path) + if ol, exists := Cache.linkCache.GetType(key, typeKey); exists { + if ol.link.Expiration != nil || + ol.link.SyncClosers.AcquireReference() || !ol.link.RequireReference { + return ol.link, ol.obj, nil + } + } + + fn := func() (*objWithLink, error) { + file, err := GetUnwrap(ctx, storage, path) + if err != nil { + return nil, errors.WithMessage(err, "failed to get file") + } + if file.IsDir() { + return nil, errors.WithStack(errs.NotFile) + } - var forget any - var linkM *model.Link - fn := func() (*model.Link, error) { link, err := storage.Link(ctx, file, args) if err != nil { return nil, errors.Wrapf(err, "failed get link") } - if link.MFile != nil && forget != nil { - linkM = link - return nil, errLinkMFileCache - } + ol := &objWithLink{link: link, obj: file} if link.Expiration != nil { - linkCache.Set(key, link, cache.WithEx[*model.Link](*link.Expiration)) + Cache.linkCache.SetTypeWithTTL(key, typeKey, ol, *link.Expiration) + } else { + Cache.linkCache.SetTypeWithExpirable(key, typeKey, ol, &link.SyncClosers) } - link.AddIfCloser(forget) - return link, nil + return ol, nil } - - if storage.Config().OnlyLinkMFile { - link, err := fn() + retry := 0 + for { + ol, err, _ := linkG.Do(key+"/"+typeKey, fn) if err != nil { return nil, nil, err } - return link, file, err - } - - forget = utils.CloseFunc(func() error { - if forget != nil { - forget = nil - linkG.Forget(key) - } - return nil - }) - link, err, _ := linkG.Do(key, fn) - if err == nil && !link.AcquireReference() { - link, err, _ = linkG.Do(key, fn) - if err == nil { - link.AcquireReference() - } - } - - if err == errLinkMFileCache { - if linkM != nil { - return linkM, file, nil + if ol.link.SyncClosers.AcquireReference() || !ol.link.RequireReference { + if retry > 1 { + log.Warnf("Link retry successed after %d times: %s %s", retry, key, typeKey) + } + return ol.link, ol.obj, nil } - forget = nil - link, err = fn() + retry++ } - - if err != nil { - return nil, nil, err - } - return link, file, nil } // Other api @@ -365,15 +241,15 @@ func Other(ctx context.Context, storage driver.Driver, args model.FsOtherArgs) ( } } -var mkdirG singleflight.Group[interface{}] +var mkdirG singleflight.Group[any] func MakeDir(ctx context.Context, storage driver.Driver, path string, lazyCache ...bool) error { if storage.Config().CheckStatus && storage.GetStorage().Status != WORK { - return errors.Errorf("storage not init: %s", storage.GetStorage().Status) + return errors.WithMessagef(errs.StorageNotInit, "storage status: %s", storage.GetStorage().Status) } path = utils.FixAndCleanPath(path) key := Key(storage, path) - _, err, _ := mkdirG.Do(key, func() (interface{}, error) { + _, err, _ := mkdirG.Do(key, func() (any, error) { // check if dir exists f, err := GetUnwrap(ctx, storage, path) if err != nil { @@ -395,15 +271,19 @@ func MakeDir(ctx context.Context, storage driver.Driver, path string, lazyCache newObj, err = s.MakeDir(ctx, parentDir, dirName) if err == nil { if newObj != nil { - addCacheObj(storage, parentPath, model.WrapObjName(newObj)) + if !storage.Config().NoCache { + if dirCache, exist := Cache.dirCache.Get(Key(storage, parentPath)); exist { + dirCache.UpdateObject("", newObj) + } + } } else if !utils.IsBool(lazyCache...) { - DeleteCache(storage, parentPath) + Cache.DeleteDirectory(storage, parentPath) } } case driver.Mkdir: err = s.MakeDir(ctx, parentDir, dirName) if err == nil && !utils.IsBool(lazyCache...) { - DeleteCache(storage, parentPath) + Cache.DeleteDirectory(storage, parentPath) } default: return nil, errs.NotImplement @@ -424,10 +304,14 @@ func MakeDir(ctx context.Context, storage driver.Driver, path string, lazyCache func Move(ctx context.Context, storage driver.Driver, srcPath, dstDirPath string, lazyCache ...bool) error { if storage.Config().CheckStatus && storage.GetStorage().Status != WORK { - return errors.Errorf("storage not init: %s", storage.GetStorage().Status) + return errors.WithMessagef(errs.StorageNotInit, "storage status: %s", storage.GetStorage().Status) } srcPath = utils.FixAndCleanPath(srcPath) + srcDirPath := stdpath.Dir(srcPath) dstDirPath = utils.FixAndCleanPath(dstDirPath) + if dstDirPath == srcDirPath { + return stderrors.New("move in place") + } srcRawObj, err := Get(ctx, storage, srcPath) if err != nil { return errors.WithMessage(err, "failed to get src object") @@ -437,26 +321,25 @@ func Move(ctx context.Context, storage driver.Driver, srcPath, dstDirPath string if err != nil { return errors.WithMessage(err, "failed to get dst dir") } - srcDirPath := stdpath.Dir(srcPath) switch s := storage.(type) { case driver.MoveResult: var newObj model.Obj newObj, err = s.Move(ctx, srcObj, dstDir) if err == nil { - delCacheObj(storage, srcDirPath, srcRawObj) + Cache.removeDirectoryObject(storage, srcDirPath, srcRawObj) if newObj != nil { - addCacheObj(storage, dstDirPath, model.WrapObjName(newObj)) + Cache.addDirectoryObject(storage, dstDirPath, model.WrapObjName(newObj)) } else if !utils.IsBool(lazyCache...) { - DeleteCache(storage, dstDirPath) + Cache.DeleteDirectory(storage, dstDirPath) } } case driver.Move: err = s.Move(ctx, srcObj, dstDir) if err == nil { - delCacheObj(storage, srcDirPath, srcRawObj) + Cache.removeDirectoryObject(storage, srcDirPath, srcRawObj) if !utils.IsBool(lazyCache...) { - DeleteCache(storage, dstDirPath) + Cache.DeleteDirectory(storage, dstDirPath) } } default: @@ -467,7 +350,7 @@ func Move(ctx context.Context, storage driver.Driver, srcPath, dstDirPath string func Rename(ctx context.Context, storage driver.Driver, srcPath, dstName string, lazyCache ...bool) error { if storage.Config().CheckStatus && storage.GetStorage().Status != WORK { - return errors.Errorf("storage not init: %s", storage.GetStorage().Status) + return errors.WithMessagef(errs.StorageNotInit, "storage status: %s", storage.GetStorage().Status) } srcPath = utils.FixAndCleanPath(srcPath) srcRawObj, err := Get(ctx, storage, srcPath) @@ -475,23 +358,30 @@ func Rename(ctx context.Context, storage driver.Driver, srcPath, dstName string, return errors.WithMessage(err, "failed to get src object") } srcObj := model.UnwrapObj(srcRawObj) - srcDirPath := stdpath.Dir(srcPath) switch s := storage.(type) { case driver.RenameResult: var newObj model.Obj newObj, err = s.Rename(ctx, srcObj, dstName) if err == nil { + srcDirPath := stdpath.Dir(srcPath) if newObj != nil { - updateCacheObj(storage, srcDirPath, srcRawObj, model.WrapObjName(newObj)) - } else if !utils.IsBool(lazyCache...) { - DeleteCache(storage, srcDirPath) + Cache.updateDirectoryObject(storage, srcDirPath, srcRawObj, model.WrapObjName(newObj)) + } else { + Cache.removeDirectoryObject(storage, srcDirPath, srcRawObj) + if !utils.IsBool(lazyCache...) { + Cache.DeleteDirectory(storage, srcDirPath) + } } } case driver.Rename: err = s.Rename(ctx, srcObj, dstName) - if err == nil && !utils.IsBool(lazyCache...) { - DeleteCache(storage, srcDirPath) + if err == nil { + srcDirPath := stdpath.Dir(srcPath) + Cache.removeDirectoryObject(storage, srcDirPath, srcRawObj) + if !utils.IsBool(lazyCache...) { + Cache.DeleteDirectory(storage, srcDirPath) + } } default: return errs.NotImplement @@ -502,14 +392,18 @@ func Rename(ctx context.Context, storage driver.Driver, srcPath, dstName string, // Copy Just copy file[s] in a storage func Copy(ctx context.Context, storage driver.Driver, srcPath, dstDirPath string, lazyCache ...bool) error { if storage.Config().CheckStatus && storage.GetStorage().Status != WORK { - return errors.Errorf("storage not init: %s", storage.GetStorage().Status) + return errors.WithMessagef(errs.StorageNotInit, "storage status: %s", storage.GetStorage().Status) } srcPath = utils.FixAndCleanPath(srcPath) dstDirPath = utils.FixAndCleanPath(dstDirPath) - srcObj, err := GetUnwrap(ctx, storage, srcPath) + if dstDirPath == stdpath.Dir(srcPath) { + return stderrors.New("copy in place") + } + srcRawObj, err := Get(ctx, storage, srcPath) if err != nil { return errors.WithMessage(err, "failed to get src object") } + srcObj := model.UnwrapObj(srcRawObj) dstDir, err := GetUnwrap(ctx, storage, dstDirPath) if err != nil { return errors.WithMessage(err, "failed to get dst dir") @@ -521,15 +415,17 @@ func Copy(ctx context.Context, storage driver.Driver, srcPath, dstDirPath string newObj, err = s.Copy(ctx, srcObj, dstDir) if err == nil { if newObj != nil { - addCacheObj(storage, dstDirPath, model.WrapObjName(newObj)) + Cache.addDirectoryObject(storage, dstDirPath, model.WrapObjName(newObj)) } else if !utils.IsBool(lazyCache...) { - DeleteCache(storage, dstDirPath) + Cache.DeleteDirectory(storage, dstDirPath) } } case driver.Copy: err = s.Copy(ctx, srcObj, dstDir) - if err == nil && !utils.IsBool(lazyCache...) { - DeleteCache(storage, dstDirPath) + if err == nil { + if !utils.IsBool(lazyCache...) { + Cache.DeleteDirectory(storage, dstDirPath) + } } default: return errs.NotImplement @@ -539,7 +435,7 @@ func Copy(ctx context.Context, storage driver.Driver, srcPath, dstDirPath string func Remove(ctx context.Context, storage driver.Driver, path string) error { if storage.Config().CheckStatus && storage.GetStorage().Status != WORK { - return errors.Errorf("storage not init: %s", storage.GetStorage().Status) + return errors.WithMessagef(errs.StorageNotInit, "storage status: %s", storage.GetStorage().Status) } if utils.PathEqual(path, "/") { return errors.New("delete root folder is not allowed, please goto the manage page to delete the storage instead") @@ -560,11 +456,7 @@ func Remove(ctx context.Context, storage driver.Driver, path string) error { case driver.Remove: err = s.Remove(ctx, model.UnwrapObj(rawObj)) if err == nil { - delCacheObj(storage, dirPath, rawObj) - // clear folder cache recursively - if rawObj.IsDir() { - ClearCache(storage, path) - } + Cache.removeDirectoryObject(storage, dirPath, rawObj) } default: return errs.NotImplement @@ -580,7 +472,7 @@ func Put(ctx context.Context, storage driver.Driver, dstDirPath string, file mod } }() if storage.Config().CheckStatus && storage.GetStorage().Status != WORK { - return errors.Errorf("storage not init: %s", storage.GetStorage().Status) + return errors.WithMessagef(errs.StorageNotInit, "storage status: %s", storage.GetStorage().Status) } // UrlTree PUT if storage.GetStorage().Driver == "UrlTree" { @@ -624,21 +516,30 @@ func Put(ctx context.Context, storage driver.Driver, dstDirPath string, file mod up = func(p float64) {} } + // 如果小于0,则通过缓存获取完整大小,可能发生于流式上传 + if file.GetSize() < 0 { + log.Warnf("file size < 0, try to get full size from cache") + file.CacheFullAndWriter(nil, nil) + } switch s := storage.(type) { case driver.PutResult: var newObj model.Obj newObj, err = s.Put(ctx, parentDir, file, up) if err == nil { + Cache.linkCache.DeleteKey(Key(storage, dstPath)) if newObj != nil { - addCacheObj(storage, dstDirPath, model.WrapObjName(newObj)) + Cache.addDirectoryObject(storage, dstDirPath, model.WrapObjName(newObj)) } else if !utils.IsBool(lazyCache...) { - DeleteCache(storage, dstDirPath) + Cache.DeleteDirectory(storage, dstDirPath) } } case driver.Put: err = s.Put(ctx, parentDir, file, up) - if err == nil && !utils.IsBool(lazyCache...) { - DeleteCache(storage, dstDirPath) + if err == nil { + Cache.linkCache.DeleteKey(Key(storage, dstPath)) + if !utils.IsBool(lazyCache...) { + Cache.DeleteDirectory(storage, dstDirPath) + } } default: return errs.NotImplement @@ -653,13 +554,7 @@ func Put(ctx context.Context, storage driver.Driver, dstDirPath string, file mod } } else { // upload success, remove old obj - err := Remove(ctx, storage, tempPath) - if err != nil { - return err - } else { - key := Key(storage, stdpath.Join(dstDirPath, file.GetName())) - linkCache.Del(key) - } + err = Remove(ctx, storage, tempPath) } } return errors.WithStack(err) @@ -667,10 +562,11 @@ func Put(ctx context.Context, storage driver.Driver, dstDirPath string, file mod func PutURL(ctx context.Context, storage driver.Driver, dstDirPath, dstName, url string, lazyCache ...bool) error { if storage.Config().CheckStatus && storage.GetStorage().Status != WORK { - return errors.Errorf("storage not init: %s", storage.GetStorage().Status) + return errors.WithMessagef(errs.StorageNotInit, "storage status: %s", storage.GetStorage().Status) } dstDirPath = utils.FixAndCleanPath(dstDirPath) - _, err := GetUnwrap(ctx, storage, stdpath.Join(dstDirPath, dstName)) + dstPath := stdpath.Join(dstDirPath, dstName) + _, err := GetUnwrap(ctx, storage, dstPath) if err == nil { return errors.New("obj already exists") } @@ -687,16 +583,20 @@ func PutURL(ctx context.Context, storage driver.Driver, dstDirPath, dstName, url var newObj model.Obj newObj, err = s.PutURL(ctx, dstDir, dstName, url) if err == nil { + Cache.linkCache.DeleteKey(Key(storage, dstPath)) if newObj != nil { - addCacheObj(storage, dstDirPath, model.WrapObjName(newObj)) + Cache.addDirectoryObject(storage, dstDirPath, model.WrapObjName(newObj)) } else if !utils.IsBool(lazyCache...) { - DeleteCache(storage, dstDirPath) + Cache.DeleteDirectory(storage, dstDirPath) } } case driver.PutURL: err = s.PutURL(ctx, dstDir, dstName, url) - if err == nil && !utils.IsBool(lazyCache...) { - DeleteCache(storage, dstDirPath) + if err == nil { + Cache.linkCache.DeleteKey(Key(storage, dstPath)) + if !utils.IsBool(lazyCache...) { + Cache.DeleteDirectory(storage, dstDirPath) + } } default: return errs.NotImplement diff --git a/internal/op/setting.go b/internal/op/setting.go index cbfa083f..bda4daaf 100644 --- a/internal/op/setting.go +++ b/internal/op/setting.go @@ -5,26 +5,21 @@ import ( "sort" "strconv" "strings" - "time" "github.com/OpenListTeam/OpenList/v4/internal/db" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/pkg/singleflight" - "github.com/OpenListTeam/OpenList/v4/pkg/utils" - "github.com/OpenListTeam/go-cache" "github.com/pkg/errors" ) -var settingCache = cache.NewMemCache(cache.WithShards[*model.SettingItem](4)) var settingG singleflight.Group[*model.SettingItem] var settingCacheF = func(item *model.SettingItem) { - settingCache.Set(item.Key, item, cache.WithEx[*model.SettingItem](time.Hour)) + Cache.SetSetting(item.Key, item) } -var settingGroupCache = cache.NewMemCache(cache.WithShards[[]model.SettingItem](4)) var settingGroupG singleflight.Group[[]model.SettingItem] -var settingGroupCacheF = func(key string, item []model.SettingItem) { - settingGroupCache.Set(key, item, cache.WithEx[[]model.SettingItem](time.Hour)) +var settingGroupCacheF = func(key string, items []model.SettingItem) { + Cache.SetSettingGroup(key, items) } var settingChangingCallbacks = make([]func(), 0) @@ -34,8 +29,7 @@ func RegisterSettingChangingCallback(f func()) { } func SettingCacheUpdate() { - settingCache.Clear() - settingGroupCache.Clear() + Cache.ClearAll() for _, cb := range settingChangingCallbacks { cb() } @@ -60,7 +54,7 @@ func GetSettingsMap() map[string]string { } func GetSettingItems() ([]model.SettingItem, error) { - if items, ok := settingGroupCache.Get("ALL_SETTING_ITEMS"); ok { + if items, exists := Cache.GetSettingGroup("ALL_SETTING_ITEMS"); exists { return items, nil } items, err, _ := settingGroupG.Do("ALL_SETTING_ITEMS", func() ([]model.SettingItem, error) { @@ -75,7 +69,7 @@ func GetSettingItems() ([]model.SettingItem, error) { } func GetPublicSettingItems() ([]model.SettingItem, error) { - if items, ok := settingGroupCache.Get("ALL_PUBLIC_SETTING_ITEMS"); ok { + if items, exists := Cache.GetSettingGroup("ALL_PUBLIC_SETTING_ITEMS"); exists { return items, nil } items, err, _ := settingGroupG.Do("ALL_PUBLIC_SETTING_ITEMS", func() ([]model.SettingItem, error) { @@ -90,7 +84,7 @@ func GetPublicSettingItems() ([]model.SettingItem, error) { } func GetSettingItemByKey(key string) (*model.SettingItem, error) { - if item, ok := settingCache.Get(key); ok { + if item, exists := Cache.GetSetting(key); exists { return item, nil } @@ -118,8 +112,8 @@ func GetSettingItemInKeys(keys []string) ([]model.SettingItem, error) { } func GetSettingItemsByGroup(group int) ([]model.SettingItem, error) { - key := strconv.Itoa(group) - if items, ok := settingGroupCache.Get(key); ok { + key := fmt.Sprintf("GROUP_%d", group) + if items, exists := Cache.GetSettingGroup(key); exists { return items, nil } items, err, _ := settingGroupG.Do(key, func() ([]model.SettingItem, error) { @@ -135,11 +129,14 @@ func GetSettingItemsByGroup(group int) ([]model.SettingItem, error) { func GetSettingItemsInGroups(groups []int) ([]model.SettingItem, error) { sort.Ints(groups) - key := strings.Join(utils.MustSliceConvert(groups, func(i int) string { - return strconv.Itoa(i) - }), ",") - if items, ok := settingGroupCache.Get(key); ok { + keyParts := make([]string, 0, len(groups)) + for _, g := range groups { + keyParts = append(keyParts, strconv.Itoa(g)) + } + key := "GROUPS_" + strings.Join(keyParts, "_") + + if items, exists := Cache.GetSettingGroup(key); exists { return items, nil } items, err, _ := settingGroupG.Do(key, func() ([]model.SettingItem, error) { @@ -165,10 +162,10 @@ func SaveSettingItems(items []model.SettingItem) error { } } err := db.SaveSettingItems(items) - if err != nil { + if err != nil { return fmt.Errorf("failed save setting: %+v", err) } - SettingCacheUpdate() + SettingCacheUpdate() return nil } diff --git a/internal/op/sharing.go b/internal/op/sharing.go index dbff1ba8..9db51c59 100644 --- a/internal/op/sharing.go +++ b/internal/op/sharing.go @@ -137,3 +137,7 @@ func DeleteSharing(sid string) error { sharingCache.Del(sid) return db.DeleteSharingById(sid) } + +func DeleteSharingsByCreatorId(creatorId uint) error { + return db.DeleteSharingsByCreatorId(creatorId) +} diff --git a/internal/op/storage.go b/internal/op/storage.go index 68c1c4e1..c47334c0 100644 --- a/internal/op/storage.go +++ b/internal/op/storage.go @@ -9,6 +9,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" "github.com/OpenListTeam/OpenList/v4/internal/db" @@ -16,8 +17,8 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/pkg/generic_sync" + "github.com/OpenListTeam/OpenList/v4/pkg/singleflight" "github.com/OpenListTeam/OpenList/v4/pkg/utils" - mapset "github.com/deckarep/golang-set/v2" "github.com/pkg/errors" log "github.com/sirupsen/logrus" ) @@ -369,6 +370,8 @@ func UpdateStorage(ctx context.Context, storage model.Storage) error { if oldStorage.MountPath != storage.MountPath { // mount path renamed, need to drop the storage storagesMap.Delete(oldStorage.MountPath) + Cache.DeleteDirectoryTree(storageDriver, "/") + Cache.InvalidateStorageDetails(storageDriver) } if err != nil { return errors.WithMessage(err, "failed get storage driver") @@ -389,6 +392,7 @@ func DeleteStorageById(ctx context.Context, id uint) error { if err != nil { return errors.WithMessage(err, "failed get storage") } + var dropErr error = nil if !storage.Disabled { storageDriver, err := GetStorageByMountPath(storage.MountPath) if err != nil { @@ -396,17 +400,19 @@ func DeleteStorageById(ctx context.Context, id uint) error { } // drop the storage in the driver if err := storageDriver.Drop(ctx); err != nil { - return errors.Wrapf(err, "failed drop storage") + dropErr = errors.Wrapf(err, "failed drop storage") } // delete the storage in the memory storagesMap.Delete(storage.MountPath) + Cache.DeleteDirectoryTree(storageDriver, "/") + Cache.InvalidateStorageDetails(storageDriver) go callStorageHooks("del", storageDriver) } // delete the storage in the database if err := db.DeleteStorageById(id); err != nil { return errors.WithMessage(err, "failed delete storage in database") } - return nil + return dropErr } // MustSaveDriverStorage call from specific driver @@ -465,6 +471,38 @@ func getStoragesByPath(path string) []driver.Driver { // for example, there are: /a/b,/a/c,/a/d/e,/a/b.balance1,/av // GetStorageVirtualFilesByPath(/a) => b,c,d func GetStorageVirtualFilesByPath(prefix string) []model.Obj { + return getStorageVirtualFilesByPath(prefix, func(_ driver.Driver, obj model.Obj) model.Obj { + return obj + }) +} + +func GetStorageVirtualFilesWithDetailsByPath(ctx context.Context, prefix string, hideDetails, refresh bool) []model.Obj { + if hideDetails { + return GetStorageVirtualFilesByPath(prefix) + } + return getStorageVirtualFilesByPath(prefix, func(d driver.Driver, obj model.Obj) model.Obj { + ret := &model.ObjStorageDetails{ + Obj: obj, + StorageDetailsWithName: model.StorageDetailsWithName{ + StorageDetails: nil, + DriverName: d.Config().Name, + }, + } + timeoutCtx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + details, err := GetStorageDetails(timeoutCtx, d, refresh) + if err != nil { + if !errors.Is(err, errs.NotImplement) && !errors.Is(err, errs.StorageNotInit) { + log.Errorf("failed get %s storage details: %+v", d.GetStorage().MountPath, err) + } + return ret + } + ret.StorageDetails = details + return ret + }) +} + +func getStorageVirtualFilesByPath(prefix string, rootCallback func(driver.Driver, model.Obj) model.Obj) []model.Obj { files := make([]model.Obj, 0) storages := storagesMap.Values() sort.Slice(storages, func(i, j int) bool { @@ -475,23 +513,44 @@ func GetStorageVirtualFilesByPath(prefix string) []model.Obj { }) prefix = utils.FixAndCleanPath(prefix) - set := mapset.NewSet[string]() + set := make(map[string]int) + var wg sync.WaitGroup for _, v := range storages { mountPath := utils.GetActualMountPath(v.GetStorage().MountPath) // Exclude prefix itself and non prefix if len(prefix) >= len(mountPath) || !utils.IsSubPath(prefix, mountPath) { continue } - name := strings.SplitN(strings.TrimPrefix(mountPath[len(prefix):], "/"), "/", 2)[0] - if set.Add(name) { - files = append(files, &model.Object{ - Name: name, + names := strings.SplitN(strings.TrimPrefix(mountPath[len(prefix):], "/"), "/", 2) + idx, ok := set[names[0]] + if !ok { + set[names[0]] = len(files) + obj := &model.Object{ + Name: names[0], Size: 0, Modified: v.GetStorage().Modified, IsFolder: true, - }) + } + if len(names) == 1 { + idx = len(files) + files = append(files, obj) + wg.Add(1) + go func() { + defer wg.Done() + files[idx] = rootCallback(v, files[idx]) + }() + } else { + files = append(files, obj) + } + } else if len(names) == 1 { + wg.Add(1) + go func() { + defer wg.Done() + files[idx] = rootCallback(v, files[idx]) + }() } } + wg.Wait() return files } @@ -515,3 +574,29 @@ func GetBalancedStorage(path string) driver.Driver { return storages[i] } } + +var detailsG singleflight.Group[*model.StorageDetails] + +func GetStorageDetails(ctx context.Context, storage driver.Driver, refresh ...bool) (*model.StorageDetails, error) { + if storage.Config().CheckStatus && storage.GetStorage().Status != WORK { + return nil, errors.WithMessagef(errs.StorageNotInit, "storage status: %s", storage.GetStorage().Status) + } + wd, ok := storage.(driver.WithDetails) + if !ok { + return nil, errs.NotImplement + } + if !utils.IsBool(refresh...) { + if ret, ok := Cache.GetStorageDetails(storage); ok { + return ret, nil + } + } + details, err, _ := detailsG.Do(storage.GetStorage().MountPath, func() (*model.StorageDetails, error) { + ret, err := wd.GetDetails(ctx) + if err != nil { + return nil, err + } + Cache.SetStorageDetails(storage, ret) + return ret, nil + }) + return details, err +} diff --git a/internal/op/user.go b/internal/op/user.go index 3f526625..dc2a0ce3 100644 --- a/internal/op/user.go +++ b/internal/op/user.go @@ -1,17 +1,14 @@ package op import ( - "time" - "github.com/OpenListTeam/OpenList/v4/internal/db" "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/pkg/singleflight" "github.com/OpenListTeam/OpenList/v4/pkg/utils" - "github.com/OpenListTeam/go-cache" + "github.com/pkg/errors" ) -var userCache = cache.NewMemCache(cache.WithShards[*model.User](2)) var userG singleflight.Group[*model.User] var guestUser *model.User var adminUser *model.User @@ -46,7 +43,7 @@ func GetUserByName(username string) (*model.User, error) { if username == "" { return nil, errs.EmptyUsername } - if user, ok := userCache.Get(username); ok { + if user, exists := Cache.GetUser(username); exists { return user, nil } user, err, _ := userG.Do(username, func() (*model.User, error) { @@ -54,7 +51,7 @@ func GetUserByName(username string) (*model.User, error) { if err != nil { return nil, err } - userCache.Set(username, _user, cache.WithEx[*model.User](time.Hour)) + Cache.SetUser(username, _user) return _user, nil }) return user, err @@ -81,7 +78,10 @@ func DeleteUserById(id uint) error { if old.IsAdmin() || old.IsGuest() { return errs.DeleteAdminOrGuest } - userCache.Del(old.Username) + Cache.DeleteUser(old.Username) + if err := DeleteSharingsByCreatorId(id); err != nil { + return errors.WithMessage(err, "failed to delete user's sharings") + } return db.DeleteUserById(id) } @@ -96,7 +96,7 @@ func UpdateUser(u *model.User) error { if u.IsGuest() { guestUser = nil } - userCache.Del(old.Username) + Cache.DeleteUser(old.Username) u.BasePath = utils.FixAndCleanPath(u.BasePath) return db.UpdateUser(u) } @@ -125,6 +125,6 @@ func DelUserCache(username string) error { if user.IsGuest() { guestUser = nil } - userCache.Del(username) + Cache.DeleteUser(username) return nil } diff --git a/internal/stream/stream.go b/internal/stream/stream.go index 94772761..8d2f504f 100644 --- a/internal/stream/stream.go +++ b/internal/stream/stream.go @@ -137,6 +137,60 @@ func (f *FileStream) CacheFullAndWriter(up *model.UpdateProgress, writer io.Writ if writer != nil { reader = io.TeeReader(reader, writer) } + + if f.GetSize() < 0 { + if f.peekBuff == nil { + f.peekBuff = &buffer.Reader{} + } + // 检查是否有数据 + buf := []byte{0} + n, err := io.ReadFull(reader, buf) + if n > 0 { + f.peekBuff.Append(buf[:n]) + } + if err == io.ErrUnexpectedEOF { + f.size = f.peekBuff.Size() + f.Reader = f.peekBuff + return f.peekBuff, nil + } else if err != nil { + return nil, err + } + if conf.MaxBufferLimit-n > conf.MmapThreshold && conf.MmapThreshold > 0 { + m, err := mmap.Alloc(conf.MaxBufferLimit - n) + if err == nil { + f.Add(utils.CloseFunc(func() error { + return mmap.Free(m) + })) + n, err = io.ReadFull(reader, m) + if n > 0 { + f.peekBuff.Append(m[:n]) + } + if err == io.ErrUnexpectedEOF { + f.size = f.peekBuff.Size() + f.Reader = f.peekBuff + return f.peekBuff, nil + } else if err != nil { + return nil, err + } + } + } + + tmpF, err := utils.CreateTempFile(reader, 0) + if err != nil { + return nil, err + } + f.Add(utils.CloseFunc(func() error { + return errors.Join(tmpF.Close(), os.RemoveAll(tmpF.Name())) + })) + peekF, err := buffer.NewPeekFile(f.peekBuff, tmpF) + if err != nil { + return nil, err + } + f.size = peekF.Size() + f.Reader = peekF + return peekF, nil + } + f.Reader = reader return f.cache(f.GetSize()) } @@ -162,7 +216,7 @@ func (f *FileStream) RangeRead(httpRange http_range.Range) (io.Reader, error) { } size := httpRange.Start + httpRange.Length - if f.peekBuff != nil && size <= int64(f.peekBuff.Len()) { + if f.peekBuff != nil && size <= int64(f.peekBuff.Size()) { return io.NewSectionReader(f.peekBuff, httpRange.Start, httpRange.Length), nil } @@ -194,7 +248,7 @@ func (f *FileStream) cache(maxCacheSize int64) (model.File, error) { f.peekBuff = &buffer.Reader{} f.oriReader = f.Reader } - bufSize := maxCacheSize - int64(f.peekBuff.Len()) + bufSize := maxCacheSize - int64(f.peekBuff.Size()) var buf []byte if conf.MmapThreshold > 0 && bufSize >= int64(conf.MmapThreshold) { m, err := mmap.Alloc(int(bufSize)) @@ -213,7 +267,7 @@ func (f *FileStream) cache(maxCacheSize int64) (model.File, error) { return nil, fmt.Errorf("failed to read all data: (expect =%d, actual =%d) %w", bufSize, n, err) } f.peekBuff.Append(buf) - if int64(f.peekBuff.Len()) >= f.GetSize() { + if int64(f.peekBuff.Size()) >= f.GetSize() { f.Reader = f.peekBuff f.oriReader = nil } else { diff --git a/internal/stream/util.go b/internal/stream/util.go index 20cb4be0..95f56e8a 100644 --- a/internal/stream/util.go +++ b/internal/stream/util.go @@ -8,8 +8,10 @@ import ( "fmt" "io" "net/http" + "os" "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/net" "github.com/OpenListTeam/OpenList/v4/pkg/http_range" @@ -26,9 +28,6 @@ func (f RangeReaderFunc) RangeRead(ctx context.Context, httpRange http_range.Ran } func GetRangeReaderFromLink(size int64, link *model.Link) (model.RangeReaderIF, error) { - if link.MFile != nil { - return GetRangeReaderFromMFile(size, link.MFile), nil - } if link.Concurrency > 0 || link.PartSize > 0 { down := net.NewDownloader(func(d *net.Downloader) { d.Concurrency = link.Concurrency @@ -65,7 +64,7 @@ func GetRangeReaderFromLink(size int64, link *model.Link) (model.RangeReaderIF, } if len(link.URL) == 0 { - return nil, errors.New("invalid link: must have at least one of MFile, URL, or RangeReader") + return nil, errors.New("invalid link: must have at least one of URL or RangeReader") } rangeReader := func(ctx context.Context, httpRange http_range.Range) (io.ReadCloser, error) { if httpRange.Length < 0 || httpRange.Start+httpRange.Length > size { @@ -77,7 +76,7 @@ func GetRangeReaderFromLink(size int64, link *model.Link) (model.RangeReaderIF, response, err := net.RequestHttp(ctx, "GET", header, link.URL) if err != nil { - if _, ok := errors.Unwrap(err).(net.ErrorHttpStatusCode); ok { + if _, ok := errs.UnwrapOrSelf(err).(net.HttpStatusCodeError); ok { return nil, err } return nil, fmt.Errorf("http request failure, err:%w", err) @@ -151,32 +150,58 @@ func CacheFullAndHash(stream model.FileStreamer, up *model.UpdateProgress, hashT return tmpF, hex.EncodeToString(h.Sum(nil)), nil } -type StreamSectionReader struct { - file model.FileStreamer - off int64 - bufPool *pool.Pool[[]byte] +type StreamSectionReaderIF interface { + // 线程不安全 + GetSectionReader(off, length int64) (io.ReadSeeker, error) + FreeSectionReader(sr io.ReadSeeker) + // 线程不安全 + DiscardSection(off int64, length int64) error } -func NewStreamSectionReader(file model.FileStreamer, maxBufferSize int, up *model.UpdateProgress) (*StreamSectionReader, error) { - ss := &StreamSectionReader{file: file} +func NewStreamSectionReader(file model.FileStreamer, maxBufferSize int, up *model.UpdateProgress) (StreamSectionReaderIF, error) { if file.GetFile() != nil { - return ss, nil + return &cachedSectionReader{file.GetFile()}, nil } maxBufferSize = min(maxBufferSize, int(file.GetSize())) if maxBufferSize > conf.MaxBufferLimit { - _, err := file.CacheFullAndWriter(up, nil) + f, err := os.CreateTemp(conf.Conf.TempDir, "file-*") if err != nil { return nil, err } + + if f.Truncate((file.GetSize()+int64(maxBufferSize-1))/int64(maxBufferSize)*int64(maxBufferSize)) != nil { + // fallback to full cache + _, _ = f.Close(), os.Remove(f.Name()) + cache, err := file.CacheFullAndWriter(up, nil) + if err != nil { + return nil, err + } + return &cachedSectionReader{cache}, nil + } + + ss := &fileSectionReader{Reader: file, temp: f} + ss.bufPool = &pool.Pool[*offsetWriterWithBase]{ + New: func() *offsetWriterWithBase { + base := ss.fileOff + ss.fileOff += int64(maxBufferSize) + return &offsetWriterWithBase{io.NewOffsetWriter(ss.temp, base), base} + }, + } + file.Add(utils.CloseFunc(func() error { + ss.bufPool.Reset() + return errors.Join(ss.temp.Close(), os.Remove(ss.temp.Name())) + })) return ss, nil } + + ss := &directSectionReader{file: file} if conf.MmapThreshold > 0 && maxBufferSize >= conf.MmapThreshold { ss.bufPool = &pool.Pool[[]byte]{ New: func() []byte { buf, err := mmap.Alloc(maxBufferSize) if err == nil { - file.Add(utils.CloseFunc(func() error { + ss.file.Add(utils.CloseFunc(func() error { return mmap.Free(buf) })) } else { @@ -200,38 +225,113 @@ func NewStreamSectionReader(file model.FileStreamer, maxBufferSize int, up *mode return ss, nil } +type cachedSectionReader struct { + cache io.ReaderAt +} + +func (*cachedSectionReader) DiscardSection(off int64, length int64) error { + return nil +} +func (s *cachedSectionReader) GetSectionReader(off, length int64) (io.ReadSeeker, error) { + return io.NewSectionReader(s.cache, off, length), nil +} +func (*cachedSectionReader) FreeSectionReader(sr io.ReadSeeker) {} + +type fileSectionReader struct { + io.Reader + off int64 + temp *os.File + fileOff int64 + bufPool *pool.Pool[*offsetWriterWithBase] +} + +type offsetWriterWithBase struct { + *io.OffsetWriter + base int64 +} + // 线程不安全 -func (ss *StreamSectionReader) GetSectionReader(off, length int64) (*SectionReader, error) { - var cache io.ReaderAt = ss.file.GetFile() - var buf []byte - if cache == nil { - if off != ss.off { - return nil, fmt.Errorf("stream not cached: request offset %d != current offset %d", off, ss.off) - } - tempBuf := ss.bufPool.Get() - buf = tempBuf[:length] - n, err := io.ReadFull(ss.file, buf) - if int64(n) != length { - return nil, fmt.Errorf("failed to read all data: (expect =%d, actual =%d) %w", length, n, err) - } - ss.off += int64(n) - off = 0 - cache = bytes.NewReader(buf) +func (ss *fileSectionReader) DiscardSection(off int64, length int64) error { + if off != ss.off { + return fmt.Errorf("stream not cached: request offset %d != current offset %d", off, ss.off) + } + _, err := utils.CopyWithBufferN(io.Discard, ss.Reader, length) + if err != nil { + return fmt.Errorf("failed to skip data: (expect =%d) %w", length, err) } - return &SectionReader{io.NewSectionReader(cache, off, length), buf}, nil + ss.off += length + return nil } -func (ss *StreamSectionReader) FreeSectionReader(sr *SectionReader) { - if sr != nil { - if sr.buf != nil { - ss.bufPool.Put(sr.buf[0:cap(sr.buf)]) - sr.buf = nil - } +type fileBufferSectionReader struct { + io.ReadSeeker + fileBuf *offsetWriterWithBase +} + +func (ss *fileSectionReader) GetSectionReader(off, length int64) (io.ReadSeeker, error) { + if off != ss.off { + return nil, fmt.Errorf("stream not cached: request offset %d != current offset %d", off, ss.off) + } + fileBuf := ss.bufPool.Get() + _, _ = fileBuf.Seek(0, io.SeekStart) + n, err := utils.CopyWithBufferN(fileBuf, ss.Reader, length) + if err != nil { + return nil, fmt.Errorf("failed to read all data: (expect =%d, actual =%d) %w", length, n, err) + } + ss.off += length + return &fileBufferSectionReader{io.NewSectionReader(ss.temp, fileBuf.base, length), fileBuf}, nil +} + +func (ss *fileSectionReader) FreeSectionReader(rs io.ReadSeeker) { + if sr, ok := rs.(*fileBufferSectionReader); ok { + ss.bufPool.Put(sr.fileBuf) + sr.fileBuf = nil sr.ReadSeeker = nil } } -type SectionReader struct { +type directSectionReader struct { + file model.FileStreamer + off int64 + bufPool *pool.Pool[[]byte] +} + +// 线程不安全 +func (ss *directSectionReader) DiscardSection(off int64, length int64) error { + if off != ss.off { + return fmt.Errorf("stream not cached: request offset %d != current offset %d", off, ss.off) + } + _, err := utils.CopyWithBufferN(io.Discard, ss.file, length) + if err != nil { + return fmt.Errorf("failed to skip data: (expect =%d) %w", length, err) + } + ss.off += length + return nil +} + +type bufferSectionReader struct { io.ReadSeeker buf []byte } + +// 线程不安全 +func (ss *directSectionReader) GetSectionReader(off, length int64) (io.ReadSeeker, error) { + if off != ss.off { + return nil, fmt.Errorf("stream not cached: request offset %d != current offset %d", off, ss.off) + } + tempBuf := ss.bufPool.Get() + buf := tempBuf[:length] + n, err := io.ReadFull(ss.file, buf) + if int64(n) != length { + return nil, fmt.Errorf("failed to read all data: (expect =%d, actual =%d) %w", length, n, err) + } + ss.off += int64(n) + return &bufferSectionReader{bytes.NewReader(buf), buf}, nil +} +func (ss *directSectionReader) FreeSectionReader(rs io.ReadSeeker) { + if sr, ok := rs.(*bufferSectionReader); ok { + ss.bufPool.Put(sr.buf[0:cap(sr.buf)]) + sr.buf = nil + sr.ReadSeeker = nil + } +} diff --git a/internal/task/base.go b/internal/task/base.go index 8976ed90..5bfa03a8 100644 --- a/internal/task/base.go +++ b/internal/task/base.go @@ -14,7 +14,7 @@ type TaskExtension struct { Creator *model.User startTime *time.Time endTime *time.Time - totalBytes int64 + TotalBytes int64 ApiUrl string } @@ -58,11 +58,11 @@ func (t *TaskExtension) ClearEndTime() { } func (t *TaskExtension) SetTotalBytes(totalBytes int64) { - t.totalBytes = totalBytes + t.TotalBytes = totalBytes } func (t *TaskExtension) GetTotalBytes() int64 { - return t.totalBytes + return t.TotalBytes } func (t *TaskExtension) ReinitCtx() error { diff --git a/internal/task_group/transfer.go b/internal/task_group/transfer.go index 3cef5786..8c75b77d 100644 --- a/internal/task_group/transfer.go +++ b/internal/task_group/transfer.go @@ -24,16 +24,15 @@ func RefreshAndRemove(dstPath string, payloads ...any) { return } _, dstNeedRefresh := dstStorage.(driver.Put) - dstNeedRefresh = dstNeedRefresh && !dstStorage.Config().NoCache if dstNeedRefresh { - op.DeleteCache(dstStorage, dstActualPath) + op.Cache.DeleteDirectory(dstStorage, dstActualPath) } var ctx context.Context for _, payload := range payloads { switch p := payload.(type) { case DstPathToRefresh: if dstNeedRefresh { - op.DeleteCache(dstStorage, string(p)) + op.Cache.DeleteDirectory(dstStorage, string(p)) } case SrcPathToRemove: if ctx == nil { @@ -79,7 +78,7 @@ func verifyAndRemove(ctx context.Context, srcStorage, dstStorage driver.Driver, } if refresh { - op.DeleteCache(dstStorage, dstObjPath) + op.Cache.DeleteDirectory(dstStorage, dstObjPath) } hasErr := false for _, obj := range srcObjs { diff --git a/pkg/buffer/bytes.go b/pkg/buffer/bytes.go index 3ee10747..3e6cb540 100644 --- a/pkg/buffer/bytes.go +++ b/pkg/buffer/bytes.go @@ -8,83 +8,86 @@ import ( // 用于存储不复用的[]byte type Reader struct { bufs [][]byte - length int - offset int + size int64 + offset int64 } -func (r *Reader) Len() int { - return r.length +func (r *Reader) Size() int64 { + return r.size } func (r *Reader) Append(buf []byte) { - r.length += len(buf) + r.size += int64(len(buf)) r.bufs = append(r.bufs, buf) } func (r *Reader) Read(p []byte) (int, error) { - n, err := r.ReadAt(p, int64(r.offset)) + n, err := r.ReadAt(p, r.offset) if n > 0 { - r.offset += n + r.offset += int64(n) } return n, err } func (r *Reader) ReadAt(p []byte, off int64) (int, error) { - if off < 0 || off >= int64(r.length) { + if off < 0 || off >= r.size { return 0, io.EOF } - n, length := 0, int64(0) + n := 0 readFrom := false for _, buf := range r.bufs { - newLength := length + int64(len(buf)) if readFrom { - w := copy(p[n:], buf) - n += w - } else if off < newLength { + nn := copy(p[n:], buf) + n += nn + if n == len(p) { + return n, nil + } + } else if newOff := off - int64(len(buf)); newOff >= 0 { + off = newOff + } else { + nn := copy(p, buf[off:]) + if nn == len(p) { + return nn, nil + } + n += nn readFrom = true - w := copy(p[n:], buf[int(off-length):]) - n += w } - if n == len(p) { - return n, nil - } - length = newLength } return n, io.EOF } func (r *Reader) Seek(offset int64, whence int) (int64, error) { - var abs int switch whence { case io.SeekStart: - abs = int(offset) case io.SeekCurrent: - abs = r.offset + int(offset) + offset = r.offset + offset case io.SeekEnd: - abs = r.length + int(offset) + offset = r.size + offset default: return 0, errors.New("Seek: invalid whence") } - if abs < 0 || abs > r.length { + if offset < 0 || offset > r.size { return 0, errors.New("Seek: invalid offset") } - r.offset = abs - return int64(abs), nil + r.offset = offset + return offset, nil } func (r *Reader) Reset() { clear(r.bufs) r.bufs = nil - r.length = 0 + r.size = 0 r.offset = 0 } func NewReader(buf ...[]byte) *Reader { - b := &Reader{} + b := &Reader{ + bufs: make([][]byte, 0, len(buf)), + } for _, b1 := range buf { b.Append(b1) } diff --git a/pkg/buffer/bytes_test.go b/pkg/buffer/bytes_test.go index b66af229..3f4d8556 100644 --- a/pkg/buffer/bytes_test.go +++ b/pkg/buffer/bytes_test.go @@ -13,8 +13,7 @@ func TestReader_ReadAt(t *testing.T) { } bs := &Reader{} bs.Append([]byte("github.com")) - bs.Append([]byte("/")) - bs.Append([]byte("OpenList")) + bs.Append([]byte("/OpenList")) bs.Append([]byte("Team/")) bs.Append([]byte("OpenList")) tests := []struct { @@ -71,7 +70,7 @@ func TestReader_ReadAt(t *testing.T) { off: 24, }, want: func(a args, n int, err error) error { - if n != bs.Len()-int(a.off) { + if n != int(bs.Size()-a.off) { return errors.New("read length not match") } if string(a.p[:n]) != "OpenList" { diff --git a/pkg/buffer/file.go b/pkg/buffer/file.go new file mode 100644 index 00000000..48edf5a4 --- /dev/null +++ b/pkg/buffer/file.go @@ -0,0 +1,88 @@ +package buffer + +import ( + "errors" + "io" + "os" +) + +type PeekFile struct { + peek *Reader + file *os.File + offset int64 + size int64 +} + +func (p *PeekFile) Read(b []byte) (n int, err error) { + n, err = p.ReadAt(b, p.offset) + if n > 0 { + p.offset += int64(n) + } + return n, err +} + +func (p *PeekFile) ReadAt(b []byte, off int64) (n int, err error) { + if off < p.peek.Size() { + n, err = p.peek.ReadAt(b, off) + if err == nil || n == len(b) { + return n, nil + } + // EOF + } + var nn int + nn, err = p.file.ReadAt(b[n:], off+int64(n)-p.peek.Size()) + return n + nn, err +} + +func (p *PeekFile) Seek(offset int64, whence int) (int64, error) { + switch whence { + case io.SeekStart: + case io.SeekCurrent: + if offset == 0 { + return p.offset, nil + } + offset = p.offset + offset + case io.SeekEnd: + offset = p.size + offset + default: + return 0, errors.New("Seek: invalid whence") + } + + if offset < 0 || offset > p.size { + return 0, errors.New("Seek: invalid offset") + } + if offset <= p.peek.Size() { + _, err := p.peek.Seek(offset, io.SeekStart) + if err != nil { + return 0, err + } + _, err = p.file.Seek(0, io.SeekStart) + if err != nil { + return 0, err + } + } else { + _, err := p.peek.Seek(p.peek.Size(), io.SeekStart) + if err != nil { + return 0, err + } + _, err = p.file.Seek(offset-p.peek.Size(), io.SeekStart) + if err != nil { + return 0, err + } + } + + p.offset = offset + return offset, nil +} + +func (p *PeekFile) Size() int64 { + return p.size +} + +func NewPeekFile(peek *Reader, file *os.File) (*PeekFile, error) { + stat, err := file.Stat() + if err == nil { + return &PeekFile{peek: peek, file: file, size: stat.Size() + peek.Size()}, nil + } + return nil, err +} diff --git a/pkg/gowebdav/errors.go b/pkg/gowebdav/errors.go index bbf1e929..de03e7b9 100644 --- a/pkg/gowebdav/errors.go +++ b/pkg/gowebdav/errors.go @@ -1,8 +1,8 @@ package gowebdav import ( - "fmt" "os" + "strconv" ) // StatusError implements error and wraps @@ -12,7 +12,7 @@ type StatusError struct { } func (se StatusError) Error() string { - return fmt.Sprintf("%d", se.Status) + return strconv.Itoa(se.Status) } // IsErrCode returns true if the given error diff --git a/pkg/singleflight/singleflight.go b/pkg/singleflight/singleflight.go index 3555d5bd..48383478 100644 --- a/pkg/singleflight/singleflight.go +++ b/pkg/singleflight/singleflight.go @@ -73,9 +73,6 @@ type call[T any] struct { type Group[T any] struct { mu sync.Mutex // protects m m map[string]*call[T] // lazily initialized - - // Won't remember error - Remember bool } // Result holds the results of Do, so they can be passed @@ -159,7 +156,7 @@ func (g *Group[T]) doCall(c *call[T], key string, fn func() (T, error)) { g.mu.Lock() defer g.mu.Unlock() c.wg.Done() - if (!g.Remember || c.err != nil) && g.m[key] == c { + if g.m[key] == c { delete(g.m, key) } diff --git a/pkg/utils/hash.go b/pkg/utils/hash.go index 0b70e4e1..596e61e5 100644 --- a/pkg/utils/hash.go +++ b/pkg/utils/hash.go @@ -57,6 +57,11 @@ var ( Supported []*HashType ) +func GetHashByName(name string) (ht *HashType, ok bool) { + ht, ok = name2hash[name] + return +} + // RegisterHash adds a new Hash to the list and returns its Type func RegisterHash(name, alias string, width int, newFunc func() hash.Hash) *HashType { return RegisterHashWithParam(name, alias, width, func(a ...any) hash.Hash { return newFunc() }) diff --git a/pkg/utils/io.go b/pkg/utils/io.go index 172dc41c..f398161f 100644 --- a/pkg/utils/io.go +++ b/pkg/utils/io.go @@ -187,40 +187,41 @@ func NewClosers(c ...io.Closer) Closers { return Closers(c) } -type SyncClosersIF interface { - ClosersIF - AcquireReference() bool -} - type SyncClosers struct { closers []io.Closer ref int32 } -var _ SyncClosersIF = (*SyncClosers)(nil) - +// if closed, return false func (c *SyncClosers) AcquireReference() bool { ref := atomic.AddInt32(&c.ref, 1) if ref > 0 { - // log.Debugf("SyncClosers.AcquireReference %p,ref=%d\n", c, ref) + // log.Debugf("AcquireReference %p: %d", c, ref) return true } - atomic.StoreInt32(&c.ref, math.MinInt16) + atomic.StoreInt32(&c.ref, closersClosed) return false } +const closersClosed = math.MinInt32 + func (c *SyncClosers) Close() error { - ref := atomic.AddInt32(&c.ref, -1) - if ref < -1 { - atomic.StoreInt32(&c.ref, math.MinInt16) - return nil - } - // log.Debugf("SyncClosers.Close %p,ref=%d\n", c, ref+1) - if ref > 0 { - return nil + for { + ref := atomic.LoadInt32(&c.ref) + if ref < 0 { + return nil + } + if ref > 1 { + if atomic.CompareAndSwapInt32(&c.ref, ref, ref-1) { + // log.Debugf("ReleaseReference %p: %d", c, ref) + return nil + } + } else if atomic.CompareAndSwapInt32(&c.ref, ref, closersClosed) { + break + } } - atomic.StoreInt32(&c.ref, math.MinInt16) + // log.Debugf("FinalClose %p", c) var errs []error for _, closer := range c.closers { if closer != nil { @@ -250,6 +251,16 @@ func (c *SyncClosers) AddIfCloser(a any) { } } +var _ ClosersIF = (*SyncClosers)(nil) + +// 实现cache.Expirable接口 +func (c *SyncClosers) Expired() bool { + return atomic.LoadInt32(&c.ref) < 0 +} +func (c *SyncClosers) Length() int { + return len(c.closers) +} + func NewSyncClosers(c ...io.Closer) SyncClosers { return SyncClosers{closers: c} } diff --git a/server/common/proxy.go b/server/common/proxy.go index d352582e..c7c975d2 100644 --- a/server/common/proxy.go +++ b/server/common/proxy.go @@ -18,11 +18,11 @@ import ( ) func Proxy(w http.ResponseWriter, r *http.Request, link *model.Link, file model.Obj) error { - if link.MFile != nil { - attachHeader(w, file, link) - http.ServeContent(w, r, file.GetName(), file.ModTime(), link.MFile) - return nil - } + // if link.MFile != nil { + // attachHeader(w, file, link) + // http.ServeContent(w, r, file.GetName(), file.ModTime(), link.MFile) + // return nil + // } if link.Concurrency > 0 || link.PartSize > 0 { attachHeader(w, file, link) @@ -101,7 +101,7 @@ func GetEtag(file model.Obj, size int64) string { } func ProxyRange(ctx context.Context, link *model.Link, size int64) *model.Link { - if link.MFile == nil && link.RangeReader == nil && !strings.HasPrefix(link.URL, GetApiUrl(ctx)+"/") { + if link.RangeReader == nil && !strings.HasPrefix(link.URL, GetApiUrl(ctx)+"/") { if link.ContentLength > 0 { size = link.ContentLength } diff --git a/server/ftp.go b/server/ftp.go index 40fb3716..fb7d86c8 100644 --- a/server/ftp.go +++ b/server/ftp.go @@ -13,6 +13,7 @@ import ( "strings" "sync" + "github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/op" @@ -32,6 +33,7 @@ type FtpMainDriver struct { } func NewMainDriver() (*FtpMainDriver, error) { + ftp.InitStage() transferType := ftpserver.TransferTypeASCII if conf.Conf.FTP.DefaultTransferBinary { transferType = ftpserver.TransferTypeBinary @@ -79,7 +81,7 @@ func NewMainDriver() (*FtpMainDriver, error) { PasvConnectionsCheck: pasvConnCheck, }, proxyHeader: http.Header{ - "User-Agent": {setting.GetStr(conf.FTPProxyUserAgent)}, + "User-Agent": {base.UserAgent}, }, clients: make(map[uint32]ftpserver.ClientContext), shutdownLock: sync.RWMutex{}, diff --git a/server/ftp/afero.go b/server/ftp/afero.go index f5bfda5f..8b7b467f 100644 --- a/server/ftp/afero.go +++ b/server/ftp/afero.go @@ -3,6 +3,8 @@ package ftp import ( "context" "errors" + "fmt" + "io" "os" "strings" "time" @@ -97,6 +99,23 @@ func (a *AferoAdapter) GetHandle(name string, flags int, offset int64) (ftpserve if err != nil { return nil, err } + if f, err := Borrow(a.ctx, path); !errors.Is(err, errs.ObjectNotFound) { + if err != nil { + return nil, err + } + if (flags & os.O_EXCL) != 0 { + return nil, errors.New("file already exists") + } + if (flags & os.O_WRONLY) != 0 { + return nil, errors.New("cannot write to uploading file") + } + _, err = f.Seek(offset, io.SeekStart) + if err != nil { + _ = f.Close() + return nil, fmt.Errorf("failed seek borrow: %+v", err) + } + return f, nil + } _, err = fs.Get(a.ctx, path, &fs.GetArgs{}) exists := err == nil if (flags&os.O_CREATE) == 0 && !exists { diff --git a/server/ftp/fsmanage.go b/server/ftp/fsmanage.go index 045ce76c..48f72794 100644 --- a/server/ftp/fsmanage.go +++ b/server/ftp/fsmanage.go @@ -2,7 +2,6 @@ package ftp import ( "context" - "fmt" stdpath "path" "github.com/OpenListTeam/OpenList/v4/internal/conf" @@ -43,6 +42,9 @@ func Remove(ctx context.Context, path string) error { if err != nil { return err } + if err = RemoveStage(reqPath); !errors.Is(err, errs.ObjectNotFound) { + return err + } return fs.Remove(ctx, reqPath) } @@ -62,23 +64,24 @@ func Rename(ctx context.Context, oldPath, newPath string) error { if !user.CanRename() || !user.CanFTPManage() { return errs.PermissionDenied } + if err = MoveStage(srcPath, dstPath); !errors.Is(err, errs.ObjectNotFound) { + return err + } return fs.Rename(ctx, srcPath, dstBase) } else { if !user.CanFTPManage() || !user.CanMove() || (srcBase != dstBase && !user.CanRename()) { return errs.PermissionDenied } - if _, err = fs.Move(ctx, srcPath, dstDir); err != nil { - if srcBase != dstBase { - return err - } - if _, err1 := fs.Copy(ctx, srcPath, dstDir); err1 != nil { - return fmt.Errorf("failed move for %+v, and failed try copying for %+v", err, err1) - } - return nil + if err = MoveStage(srcPath, dstPath); !errors.Is(err, errs.ObjectNotFound) { + return err } if srcBase != dstBase { - return fs.Rename(ctx, stdpath.Join(dstDir, srcBase), dstBase) + err = fs.Rename(ctx, srcPath, dstBase, true) + if err != nil { + return err + } } - return nil + _, err = fs.Move(ctx, stdpath.Join(srcDir, dstBase), dstDir) + return err } } diff --git a/server/ftp/fsread.go b/server/ftp/fsread.go index 61244c01..9080bae1 100644 --- a/server/ftp/fsread.go +++ b/server/ftp/fsread.go @@ -63,19 +63,19 @@ func OpenDownload(ctx context.Context, reqPath string, offset int64) (*FileDownl func (f *FileDownloadProxy) Read(p []byte) (n int, err error) { n, err = f.File.Read(p) if err != nil { - return + return n, err } err = stream.ClientDownloadLimit.WaitN(f.ctx, n) - return + return n, err } func (f *FileDownloadProxy) ReadAt(p []byte, off int64) (n int, err error) { n, err = f.File.ReadAt(p, off) if err != nil { - return + return n, err } err = stream.ClientDownloadLimit.WaitN(f.ctx, n) - return + return n, err } func (f *FileDownloadProxy) Write(p []byte) (n int, err error) { @@ -95,7 +95,7 @@ func (o *OsFileInfoAdapter) Size() int64 { } func (o *OsFileInfoAdapter) Mode() fs2.FileMode { - var mode fs2.FileMode = 0755 + var mode fs2.FileMode = 0o755 if o.IsDir() { mode |= fs2.ModeDir } @@ -130,6 +130,9 @@ func Stat(ctx context.Context, path string) (os.FileInfo, error) { if !common.CanAccess(user, meta, reqPath, ctx.Value(conf.MetaPassKey).(string)) { return nil, errs.PermissionDenied } + if ret, err := StatStage(reqPath); !errors.Is(err, errs.ObjectNotFound) { + return ret, err + } obj, err := fs.Get(ctx, reqPath, &fs.GetArgs{}) if err != nil { return nil, err @@ -157,6 +160,13 @@ func List(ctx context.Context, path string) ([]os.FileInfo, error) { if err != nil { return nil, err } + uploading := ListStage(reqPath) + for _, o := range objs { + delete(uploading, o.GetName()) + } + for _, u := range uploading { + objs = append(objs, u) + } ret := make([]os.FileInfo, len(objs)) for i, obj := range objs { ret[i] = &OsFileInfoAdapter{obj: obj} diff --git a/server/ftp/fsup.go b/server/ftp/fsup.go index 7b6241ff..34fb36c6 100644 --- a/server/ftp/fsup.go +++ b/server/ftp/fsup.go @@ -3,6 +3,7 @@ package ftp import ( "bytes" "context" + "fmt" "io" "net/http" "os" @@ -62,10 +63,10 @@ func (f *FileUploadProxy) Read(p []byte) (n int, err error) { func (f *FileUploadProxy) Write(p []byte) (n int, err error) { n, err = f.buffer.Write(p) if err != nil { - return + return n, err } err = stream.ClientUploadLimit.WaitN(f.ctx, n) - return + return n, err } func (f *FileUploadProxy) Seek(offset int64, whence int) (int64, error) { @@ -89,6 +90,25 @@ func (f *FileUploadProxy) Close() error { if _, err := f.buffer.Seek(0, io.SeekStart); err != nil { return err } + user := f.ctx.Value(conf.UserKey).(*model.User) + sf, borrow, err := MakeStage(f.ctx, f.buffer, size, f.path, func(target string) { + ctx := context.WithValue(context.Background(), conf.UserKey, user) + dstDir, dstBase := stdpath.Split(target) + if dir == dstDir { + _ = fs.Rename(ctx, f.path, dstBase) + } else { + if name != dstBase { + e := fs.Rename(ctx, f.path, dstBase, true) + if e != nil { + return + } + } + _, _ = fs.Move(ctx, stdpath.Join(dir, dstBase), dstDir) + } + }) + if err != nil { + return fmt.Errorf("failed make stage for [%s]: %+v", f.path, err) + } if f.trunc { _ = fs.Remove(f.ctx, f.path) } @@ -100,10 +120,18 @@ func (f *FileUploadProxy) Close() error { }, Mimetype: contentType, WebPutAsTask: true, + Reader: f.buffer, } - s.SetTmpFile(f.buffer) - _, err = fs.PutAsTask(f.ctx, dir, s) - return err + s.Add(borrow) + task, err := fs.PutAsTask(f.ctx, dir, s) + if err != nil { + _ = s.Close() + return err + } + sf.SetRemoveCallback(func() { + fs.UploadTaskManager.Cancel(task.GetID()) + }) + return nil } type FileUploadWithLengthProxy struct { @@ -182,10 +210,10 @@ func (f *FileUploadWithLengthProxy) write(p []byte) (n int, err error) { func (f *FileUploadWithLengthProxy) Write(p []byte) (n int, err error) { n, err = f.write(p) if err != nil { - return + return n, err } err = stream.ClientUploadLimit.WaitN(f.ctx, n) - return + return n, err } func (f *FileUploadWithLengthProxy) Seek(offset int64, whence int) (int64, error) { @@ -214,6 +242,6 @@ func (f *FileUploadWithLengthProxy) Close() error { WebPutAsTask: false, Reader: bytes.NewReader(data), } - return fs.PutDirectly(f.ctx, dir, s, true) + return fs.PutDirectly(f.ctx, dir, s) } } diff --git a/server/ftp/upload_stage.go b/server/ftp/upload_stage.go new file mode 100644 index 00000000..df0b5083 --- /dev/null +++ b/server/ftp/upload_stage.go @@ -0,0 +1,269 @@ +package ftp + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/stream" + log "github.com/sirupsen/logrus" + "github.com/tchap/go-patricia/v2/patricia" +) + +var ( + stage *patricia.Trie + stageMutex = sync.Mutex{} + ErrStagePathConflict = errors.New("upload path conflict") + ErrStageMoved = errors.New("uploading file has been moved") +) + +func InitStage() { + if stage != nil { + return + } + stage = patricia.NewTrie(patricia.MaxPrefixPerNode(16)) +} + +type UploadingFile struct { + name string + size int64 + modTime time.Time + refCount int + currentPath string + softLinks []patricia.Prefix + mvCallback func(string) + rmCallback func() +} + +func (u *UploadingFile) SetRemoveCallback(rm func()) { + stageMutex.Lock() + defer stageMutex.Unlock() + u.rmCallback = rm +} + +type softLink struct { + target *UploadingFile +} + +func MakeStage(ctx context.Context, buffer *os.File, size int64, path string, mv func(string)) (*UploadingFile, *BorrowedFile, error) { + stageMutex.Lock() + defer stageMutex.Unlock() + prefix := patricia.Prefix(path) + f := &UploadingFile{ + name: buffer.Name(), + size: size, + modTime: time.Now(), + refCount: 1, + currentPath: path, + softLinks: []patricia.Prefix{}, + mvCallback: mv, + } + if !stage.Insert(prefix, f) { + return nil, nil, ErrStagePathConflict + } + log.Debugf("[ftp-stage] succeed to make [%s] stage", buffer.Name()) + return f, &BorrowedFile{ + file: buffer, + path: prefix, + ctx: ctx, + }, nil +} + +func Borrow(ctx context.Context, path string) (*BorrowedFile, error) { + stageMutex.Lock() + defer stageMutex.Unlock() + prefix := patricia.Prefix(path) + v := stage.Get(prefix) + if v == nil { + return nil, errs.ObjectNotFound + } + s, ok := v.(*UploadingFile) + if !ok { + s = v.(*softLink).target + } + if s.currentPath != path { + return nil, ErrStageMoved + } + borrowed, err := os.OpenFile(s.name, os.O_RDONLY, 0o644) + if err != nil { + return nil, fmt.Errorf("failed borrow [%s]: %+v", s.name, err) + } + s.refCount++ + log.Debugf("[ftp-stage] borrow [%s] succeed", s.name) + return &BorrowedFile{ + file: borrowed, + path: prefix, + ctx: ctx, + }, nil +} + +func drop(path patricia.Prefix) { + stageMutex.Lock() + defer stageMutex.Unlock() + v := stage.Get(path) + if v == nil { + return + } + s, ok := v.(*UploadingFile) + if !ok { + s = v.(*softLink).target + } + s.refCount-- + log.Debugf("[ftp-stage] dropped [%s]", s.name) + if s.refCount == 0 { + log.Debugf("[ftp-stage] there is no more reference to [%s], removing temp file", s.name) + err := os.RemoveAll(s.name) + if err != nil { + log.Errorf("[ftp-stage] failed to remove stage file [%s]: %+v", s.name, err) + } + for _, sl := range s.softLinks { + stage.Delete(sl) + } + stage.Delete(path) + if s.currentPath != string(path) { + if s.currentPath != "" { + go s.mvCallback(s.currentPath) + } + } + } +} + +func ListStage(path string) map[string]model.Obj { + stageMutex.Lock() + defer stageMutex.Unlock() + path = path + "/" + prefix := patricia.Prefix(path) + ret := make(map[string]model.Obj) + _ = stage.VisitSubtree(prefix, func(prefix patricia.Prefix, item patricia.Item) error { + visit := string(prefix) + visitSub := strings.TrimPrefix(visit, path) + name, _, nonDirect := strings.Cut(visitSub, "/") + if nonDirect { + return nil + } + f, ok := item.(*UploadingFile) + if !ok { + f = item.(*softLink).target + } + if f.currentPath == visit { + ret[name] = &model.Object{ + Path: visit, + Name: name, + Size: f.size, + Modified: f.modTime, + IsFolder: false, + } + } + return nil + }) + return ret +} + +func StatStage(path string) (os.FileInfo, error) { + stageMutex.Lock() + defer stageMutex.Unlock() + prefix := patricia.Prefix(path) + v := stage.Get(prefix) + if v == nil { + return nil, errs.ObjectNotFound + } + s, ok := v.(*UploadingFile) + if !ok { + s = v.(*softLink).target + } + if s.currentPath != path { + return nil, ErrStageMoved + } + return os.Stat(s.name) +} + +func MoveStage(from, to string) error { + stageMutex.Lock() + defer stageMutex.Unlock() + prefix := patricia.Prefix(from) + v := stage.Get(prefix) + if v == nil { + return errs.ObjectNotFound + } + s, ok := v.(*UploadingFile) + if !ok { + s = v.(*softLink).target + } + if s.currentPath != from { + return ErrStageMoved + } + slPrefix := patricia.Prefix(to) + sl := &softLink{target: s} + if !stage.Insert(slPrefix, sl) { + return ErrStagePathConflict + } + s.currentPath = to + s.softLinks = append(s.softLinks, slPrefix) + return nil +} + +func RemoveStage(path string) error { + stageMutex.Lock() + defer stageMutex.Unlock() + prefix := patricia.Prefix(path) + v := stage.Get(prefix) + if v == nil { + return errs.ObjectNotFound + } + s, ok := v.(*UploadingFile) + if !ok { + s = v.(*softLink).target + } + if s.currentPath != path { + return ErrStageMoved + } + s.currentPath = "" + if s.rmCallback != nil { + s.rmCallback() + } + return nil +} + +type BorrowedFile struct { + file *os.File + path patricia.Prefix + ctx context.Context +} + +func (f *BorrowedFile) Read(p []byte) (n int, err error) { + n, err = f.file.Read(p) + if err != nil { + return n, err + } + err = stream.ClientDownloadLimit.WaitN(f.ctx, n) + return n, err +} + +func (f *BorrowedFile) ReadAt(p []byte, off int64) (n int, err error) { + n, err = f.file.ReadAt(p, off) + if err != nil { + return n, err + } + err = stream.ClientDownloadLimit.WaitN(f.ctx, n) + return n, err +} + +func (f *BorrowedFile) Seek(offset int64, whence int) (int64, error) { + return f.file.Seek(offset, whence) +} + +func (f *BorrowedFile) Write(_ []byte) (n int, err error) { + return 0, errs.NotSupport +} + +func (f *BorrowedFile) Close() error { + err := f.file.Close() + drop(f.path) + return err +} diff --git a/server/handles/down.go b/server/handles/down.go index 157eeafc..db2aac4b 100644 --- a/server/handles/down.go +++ b/server/handles/down.go @@ -9,6 +9,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/fs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/net" @@ -49,13 +50,13 @@ func Down(c *gin.Context) { func Proxy(c *gin.Context) { rawPath := c.Request.Context().Value(conf.PathKey).(string) - //filename := stdpath.Base(rawPath) + filename := stdpath.Base(rawPath) storage, err := fs.GetStorage(rawPath, &fs.GetStoragesArgs{}) if err != nil { common.ErrorPage(c, err, 500) return } - if true { + if canProxy(storage, filename) { if _, ok := c.GetQuery("d"); !ok { if url := common.GenerateDownProxyURL(storage.GetStorage(), rawPath); url != "" { c.Redirect(302, url) @@ -114,9 +115,8 @@ func proxy(c *gin.Context, link *model.Link, file model.Obj, proxyRange bool) { link = common.ProxyRange(c, link, file.GetSize()) } Writer := &common.WrittenResponseWriter{ResponseWriter: c.Writer} - - //优先处理md文件 - if utils.Ext(file.GetName()) == "md" && setting.GetBool(conf.FilterReadMeScripts) { + raw, _ := strconv.ParseBool(c.DefaultQuery("raw", "false")) + if utils.Ext(file.GetName()) == "md" && setting.GetBool(conf.FilterReadMeScripts) && !raw { buf := bytes.NewBuffer(make([]byte, 0, file.GetSize())) w := &common.InterceptResponseWriter{ResponseWriter: Writer, Writer: buf} err = common.Proxy(w, c.Request, link, file) @@ -148,7 +148,7 @@ func proxy(c *gin.Context, link *model.Link, file model.Obj, proxyRange bool) { if Writer.IsWritten() { log.Errorf("%s %s local proxy error: %+v", c.Request.Method, c.Request.URL.Path, err) } else { - if statusCode, ok := errors.Unwrap(err).(net.ErrorHttpStatusCode); ok { + if statusCode, ok := errs.UnwrapOrSelf(err).(net.HttpStatusCodeError); ok { common.ErrorPage(c, err, int(statusCode), true) } else { common.ErrorPage(c, err, 500, true) @@ -173,5 +173,5 @@ func canProxy(storage driver.Driver, filename string) bool { if utils.SliceContains(conf.SlicesMap[conf.TextTypes], utils.Ext(filename)) { return true } - return false + return true } diff --git a/server/handles/fsmanage.go b/server/handles/fsmanage.go index f45da69b..3fe86726 100644 --- a/server/handles/fsmanage.go +++ b/server/handles/fsmanage.go @@ -386,7 +386,7 @@ func Link(c *gin.Context) { common.ErrorResp(c, err, 500) return } - if storage.Config().NoLinkURL || storage.Config().OnlyLinkMFile { + if storage.Config().NoLinkURL { common.SuccessResp(c, model.Link{ URL: fmt.Sprintf("%s/p%s?d&sign=%s", common.GetApiUrl(c), diff --git a/server/handles/fsread.go b/server/handles/fsread.go index b9a4f09d..6665094c 100644 --- a/server/handles/fsread.go +++ b/server/handles/fsread.go @@ -33,18 +33,19 @@ type DirReq struct { } type ObjResp struct { - Id string `json:"id"` - Path string `json:"path"` - Name string `json:"name"` - Size int64 `json:"size"` - IsDir bool `json:"is_dir"` - Modified time.Time `json:"modified"` - Created time.Time `json:"created"` - Sign string `json:"sign"` - Thumb string `json:"thumb"` - Type int `json:"type"` - HashInfoStr string `json:"hashinfo"` - HashInfo map[*utils.HashType]string `json:"hash_info"` + Id string `json:"id"` + Path string `json:"path"` + Name string `json:"name"` + Size int64 `json:"size"` + IsDir bool `json:"is_dir"` + Modified time.Time `json:"modified"` + Created time.Time `json:"created"` + Sign string `json:"sign"` + Thumb string `json:"thumb"` + Type int `json:"type"` + HashInfoStr string `json:"hashinfo"` + HashInfo map[*utils.HashType]string `json:"hash_info"` + MountDetails *model.StorageDetailsWithName `json:"mount_details,omitempty"` } type FsListResp struct { @@ -98,7 +99,10 @@ func FsList(c *gin.Context, req *ListReq, user *model.User) { common.ErrorStrResp(c, "Refresh without permission", 403) return } - objs, err := fs.List(c.Request.Context(), reqPath, &fs.ListArgs{Refresh: req.Refresh}) + objs, err := fs.List(c.Request.Context(), reqPath, &fs.ListArgs{ + Refresh: req.Refresh, + WithStorageDetails: !user.IsGuest() && !setting.GetBool(conf.HideStorageDetails), + }) if err != nil { common.ErrorResp(c, err, 500) return @@ -224,19 +228,21 @@ func toObjsResp(objs []model.Obj, parent string, encrypt bool) []ObjResp { var resp []ObjResp for _, obj := range objs { thumb, _ := model.GetThumb(obj) + mountDetails, _ := model.GetStorageDetails(obj) resp = append(resp, ObjResp{ - Id: obj.GetID(), - Path: obj.GetPath(), - Name: obj.GetName(), - Size: obj.GetSize(), - IsDir: obj.IsDir(), - Modified: obj.ModTime(), - Created: obj.CreateTime(), - HashInfoStr: obj.GetHash().String(), - HashInfo: obj.GetHash().Export(), - Sign: common.Sign(obj, parent, encrypt), - Thumb: thumb, - Type: utils.GetObjType(obj.GetName(), obj.IsDir()), + Id: obj.GetID(), + Path: obj.GetPath(), + Name: obj.GetName(), + Size: obj.GetSize(), + IsDir: obj.IsDir(), + Modified: obj.ModTime(), + Created: obj.CreateTime(), + HashInfoStr: obj.GetHash().String(), + HashInfo: obj.GetHash().Export(), + Sign: common.Sign(obj, parent, encrypt), + Thumb: thumb, + Type: utils.GetObjType(obj.GetName(), obj.IsDir()), + MountDetails: mountDetails, }) } return resp @@ -293,7 +299,9 @@ func FsGet(c *gin.Context, req *FsGetReq, user *model.User) { common.ErrorStrResp(c, "password is incorrect or you have no permission", 403) return } - obj, err := fs.Get(c.Request.Context(), reqPath, &fs.GetArgs{}) + obj, err := fs.Get(c.Request.Context(), reqPath, &fs.GetArgs{ + WithStorageDetails: !user.IsGuest() && !setting.GetBool(conf.HideStorageDetails), + }) if err != nil { common.ErrorResp(c, err, 500) return @@ -301,8 +309,8 @@ func FsGet(c *gin.Context, req *FsGetReq, user *model.User) { var rawURL string storage, err := fs.GetStorage(reqPath, &fs.GetStoragesArgs{}) - provider := "unknown" - if err == nil { + provider, ok := model.GetProvider(obj) + if !ok && err == nil { provider = storage.Config().Name } if !obj.IsDir() { @@ -350,20 +358,22 @@ func FsGet(c *gin.Context, req *FsGetReq, user *model.User) { } parentMeta, _ := op.GetNearestMeta(parentPath) thumb, _ := model.GetThumb(obj) + mountDetails, _ := model.GetStorageDetails(obj) common.SuccessResp(c, FsGetResp{ ObjResp: ObjResp{ - Id: obj.GetID(), - Path: obj.GetPath(), - Name: obj.GetName(), - Size: obj.GetSize(), - IsDir: obj.IsDir(), - Modified: obj.ModTime(), - Created: obj.CreateTime(), - HashInfoStr: obj.GetHash().String(), - HashInfo: obj.GetHash().Export(), - Sign: common.Sign(obj, parentPath, isEncrypt(meta, reqPath)), - Type: utils.GetFileType(obj.GetName()), - Thumb: thumb, + Id: obj.GetID(), + Path: obj.GetPath(), + Name: obj.GetName(), + Size: obj.GetSize(), + IsDir: obj.IsDir(), + Modified: obj.ModTime(), + Created: obj.CreateTime(), + HashInfoStr: obj.GetHash().String(), + HashInfo: obj.GetHash().Export(), + Sign: common.Sign(obj, parentPath, isEncrypt(meta, reqPath)), + Type: utils.GetFileType(obj.GetName()), + Thumb: thumb, + MountDetails: mountDetails, }, RawURL: rawURL, Readme: getReadme(meta, reqPath), diff --git a/server/handles/fsup.go b/server/handles/fsup.go index 087a58a9..71d9dbae 100644 --- a/server/handles/fsup.go +++ b/server/handles/fsup.go @@ -56,14 +56,17 @@ func FsStream(c *gin.Context) { } } dir, name := stdpath.Split(path) - sizeStr := c.GetHeader("Content-Length") - if sizeStr == "" { - sizeStr = "0" - } - size, err := strconv.ParseInt(sizeStr, 10, 64) - if err != nil { - common.ErrorResp(c, err, 400) - return + // 如果请求头 Content-Length 和 X-File-Size 都没有,则 size=-1,表示未知大小的流式上传 + size := c.Request.ContentLength + if size < 0 { + sizeStr := c.GetHeader("X-File-Size") + if sizeStr != "" { + size, err = strconv.ParseInt(sizeStr, 10, 64) + if err != nil { + common.ErrorResp(c, err, 400) + return + } + } } h := make(map[*utils.HashType]string) if md5 := c.GetHeader("X-File-Md5"); md5 != "" { diff --git a/server/handles/offline_download.go b/server/handles/offline_download.go index fb652734..9d3f2230 100644 --- a/server/handles/offline_download.go +++ b/server/handles/offline_download.go @@ -2,9 +2,10 @@ package handles import ( "strings" - + _115 "github.com/OpenListTeam/OpenList/v4/drivers/115" _115_open "github.com/OpenListTeam/OpenList/v4/drivers/115_open" + _123_open "github.com/OpenListTeam/OpenList/v4/drivers/123_open" "github.com/OpenListTeam/OpenList/v4/drivers/pikpak" "github.com/OpenListTeam/OpenList/v4/drivers/thunder" "github.com/OpenListTeam/OpenList/v4/drivers/thunder_browser" @@ -200,6 +201,52 @@ func Set115Open(c *gin.Context) { common.SuccessResp(c, "ok") } +type Set123OpenReq struct { + TempDir string `json:"temp_dir" form:"temp_dir"` + CallbackUrl string `json:"callback_url" form:"callback_url"` +} + +func Set123Open(c *gin.Context) { + var req Set123OpenReq + if err := c.ShouldBind(&req); err != nil { + common.ErrorResp(c, err, 400) + return + } + if req.TempDir != "" { + storage, _, err := op.GetStorageAndActualPath(req.TempDir) + if err != nil { + common.ErrorStrResp(c, "storage does not exists", 400) + return + } + if storage.Config().CheckStatus && storage.GetStorage().Status != op.WORK { + common.ErrorStrResp(c, "storage not init: "+storage.GetStorage().Status, 400) + return + } + if _, ok := storage.(*_123_open.Open123); !ok { + common.ErrorStrResp(c, "unsupported storage driver for offline download, only 123 Open is supported", 400) + return + } + } + items := []model.SettingItem{ + {Key: conf.Pan123OpenTempDir, Value: req.TempDir, Type: conf.TypeString, Group: model.OFFLINE_DOWNLOAD, Flag: model.PRIVATE}, + {Key: conf.Pan123OpenOfflineDownloadCallbackUrl, Value: req.CallbackUrl, Type: conf.TypeString, Group: model.OFFLINE_DOWNLOAD, Flag: model.PRIVATE}, + } + if err := op.SaveSettingItems(items); err != nil { + common.ErrorResp(c, err, 500) + return + } + _tool, err := tool.Tools.Get("123 Open") + if err != nil { + common.ErrorResp(c, err, 500) + return + } + if _, err := _tool.Init(); err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c, "ok") +} + type SetPikPakReq struct { TempDir string `json:"temp_dir" form:"temp_dir"` } @@ -413,7 +460,7 @@ func AddOfflineDownload(c *gin.Context) { if trimmedUrl == "" { continue } - + t, err := tool.AddURL(c, &tool.AddURLArgs{ URL: trimmedUrl, DstDirPath: reqPath, diff --git a/server/handles/sharing.go b/server/handles/sharing.go index 5b26fcf6..5a6ea265 100644 --- a/server/handles/sharing.go +++ b/server/handles/sharing.go @@ -195,6 +195,7 @@ func SharingArchiveList(c *gin.Context, req *ArchiveListReq) { func SharingDown(c *gin.Context) { sid := c.Request.Context().Value(conf.SharingIDKey).(string) path := c.Request.Context().Value(conf.PathKey).(string) + path = utils.FixAndCleanPath(path) pwd := c.Query("pwd") s, err := op.GetSharingById(sid) if err == nil { @@ -219,6 +220,13 @@ func SharingDown(c *gin.Context) { return } if setting.GetBool(conf.ShareForceProxy) || common.ShouldProxy(storage, stdpath.Base(actualPath)) { + if _, ok := c.GetQuery("d"); !ok { + if url := common.GenerateDownProxyURL(storage.GetStorage(), unwrapPath); url != "" { + c.Redirect(302, url) + _ = countAccess(c.ClientIP(), s) + return + } + } link, obj, err := op.Link(c.Request.Context(), storage, actualPath, model.LinkArgs{ Header: c.Request.Header, Type: c.Query("type"), @@ -252,6 +260,7 @@ func SharingArchiveExtract(c *gin.Context) { } sid := c.Request.Context().Value(conf.SharingIDKey).(string) path := c.Request.Context().Value(conf.PathKey).(string) + path = utils.FixAndCleanPath(path) pwd := c.Query("pwd") innerPath := utils.FixAndCleanPath(c.Query("inner")) archivePass := c.Query("pass") diff --git a/server/handles/storage.go b/server/handles/storage.go index 80951e54..e4b657cd 100644 --- a/server/handles/storage.go +++ b/server/handles/storage.go @@ -2,18 +2,67 @@ package handles import ( "context" + "errors" "github.com/OpenListTeam/OpenList/v4/internal/bootstrap" "strconv" + "sync" + "time" "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/db" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/internal/setting" "github.com/OpenListTeam/OpenList/v4/server/common" "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" ) +type StorageResp struct { + model.Storage + MountDetails *model.StorageDetails `json:"mount_details,omitempty"` +} + +func makeStorageResp(c *gin.Context, storages []model.Storage) []*StorageResp { + ret := make([]*StorageResp, len(storages)) + var wg sync.WaitGroup + for i, s := range storages { + ret[i] = &StorageResp{ + Storage: s, + MountDetails: nil, + } + if setting.GetBool(conf.HideStorageDetailsInManagePage) { + continue + } + d, err := op.GetStorageByMountPath(s.MountPath) + if err != nil { + continue + } + _, ok := d.(driver.WithDetails) + if !ok { + continue + } + wg.Add(1) + go func() { + defer wg.Done() + ctx, cancel := context.WithTimeout(c, time.Second*3) + defer cancel() + details, err := op.GetStorageDetails(ctx, d) + if err != nil { + if !errors.Is(err, errs.NotImplement) && !errors.Is(err, errs.StorageNotInit) { + log.Errorf("failed get %s details: %+v", s.MountPath, err) + } + return + } + ret[i].MountDetails = details + }() + } + wg.Wait() + return ret +} + func ListStorages(c *gin.Context) { var req model.PageReq if err := c.ShouldBind(&req); err != nil { @@ -28,7 +77,7 @@ func ListStorages(c *gin.Context) { return } common.SuccessResp(c, common.PageResp{ - Content: storages, + Content: makeStorageResp(c, storages), Total: total, }) } @@ -165,7 +214,7 @@ func LoadAllStorages(c *gin.Context) { common.ErrorResp(c, err, 500, true) return } - conf.StoragesLoaded = false + conf.ResetStoragesLoadSignal() go func(storages []model.Storage) { for _, storage := range storages { storageDriver, err := op.GetStorageByMountPath(storage.MountPath) @@ -185,7 +234,7 @@ func LoadAllStorages(c *gin.Context) { log.Infof("success load storage: [%s], driver: [%s]", storage.MountPath, storage.Driver) } - conf.StoragesLoaded = true + conf.SendStoragesLoadedSignal() }(storages) common.SuccessResp(c) } diff --git a/server/middlewares/check.go b/server/middlewares/check.go index a1011de3..c7203a49 100644 --- a/server/middlewares/check.go +++ b/server/middlewares/check.go @@ -22,9 +22,12 @@ func StoragesLoaded(c *gin.Context) { return } } - common.ErrorStrResp(c, "Loading storage, please wait", 500) - c.Abort() - return + select { + case <-conf.StoragesLoadSignal(): + case <-c.Request.Context().Done(): + c.Abort() + return + } } common.GinWithValue(c, conf.ApiUrlKey, common.GetApiUrlFromRequest(c.Request), diff --git a/server/router.go b/server/router.go index 2811afa4..3d21abc5 100644 --- a/server/router.go +++ b/server/router.go @@ -170,6 +170,7 @@ func admin(g *gin.RouterGroup) { setting.POST("/set_transmission", handles.SetTransmission) setting.POST("/set_115", handles.Set115) setting.POST("/set_115_open", handles.Set115Open) + setting.POST("/set_123_open", handles.Set123Open) setting.POST("/set_pikpak", handles.SetPikPak) setting.POST("/set_thunder", handles.SetThunder) setting.POST("/set_thunderx", handles.SetThunderX) diff --git a/server/sftp.go b/server/sftp.go index 0f9d9125..055f7973 100644 --- a/server/sftp.go +++ b/server/sftp.go @@ -5,6 +5,7 @@ import ( "net/http" "time" + "github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/op" @@ -23,10 +24,11 @@ type SftpDriver struct { } func NewSftpDriver() (*SftpDriver, error) { + ftp.InitStage() sftp.InitHostKey() return &SftpDriver{ proxyHeader: http.Header{ - "User-Agent": {setting.GetStr(conf.FTPProxyUserAgent)}, + "User-Agent": {base.UserAgent}, }, }, nil } @@ -35,10 +37,14 @@ func (d *SftpDriver) GetConfig() *sftpd.Config { if d.config != nil { return d.config } + var pwdAuth func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) = nil + if !setting.GetBool(conf.SFTPDisablePasswordLogin) { + pwdAuth = d.PasswordAuth + } serverConfig := ssh.ServerConfig{ NoClientAuth: true, NoClientAuthCallback: d.NoClientAuth, - PasswordCallback: d.PasswordAuth, + PasswordCallback: pwdAuth, PublicKeyCallback: d.PublicKeyAuth, AuthLogCallback: d.AuthLogCallback, BannerCallback: d.GetBanner, @@ -50,7 +56,7 @@ func (d *SftpDriver) GetConfig() *sftpd.Config { ServerConfig: serverConfig, HostPort: conf.Conf.SFTP.Listen, ErrorLogFunc: utils.Log.Error, - //DebugLogFunc: utils.Log.Debugf, + // DebugLogFunc: utils.Log.Debugf, } return d.config } diff --git a/server/webdav/webdav.go b/server/webdav/webdav.go index b6f7cdac..4612665b 100644 --- a/server/webdav/webdav.go +++ b/server/webdav/webdav.go @@ -14,6 +14,7 @@ import ( "net/url" "os" "path" + "strconv" "strings" "time" @@ -271,7 +272,7 @@ func (h *Handler) handleGetHeadPost(w http.ResponseWriter, r *http.Request) (sta } err = common.Proxy(w, r, link, fi) if err != nil { - if statusCode, ok := errors.Unwrap(err).(net.ErrorHttpStatusCode); ok { + if statusCode, ok := errs.UnwrapOrSelf(err).(net.HttpStatusCodeError); ok { return int(statusCode), err } return http.StatusInternalServerError, fmt.Errorf("webdav proxy error: %+v", err) @@ -341,9 +342,19 @@ func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request) (status int, if err != nil { return http.StatusForbidden, err } + size := r.ContentLength + if size < 0 { + sizeStr := r.Header.Get("X-File-Size") + if sizeStr != "" { + size, err = strconv.ParseInt(sizeStr, 10, 64) + if err != nil { + return http.StatusBadRequest, err + } + } + } obj := model.Object{ Name: path.Base(reqPath), - Size: r.ContentLength, + Size: size, Modified: h.getModTime(r), Ctime: h.getCreateTime(r), }