From 2e6dd00d910892c7b814757d068c258eb4c878b2 Mon Sep 17 00:00:00 2001 From: PIKACHUIM Date: Tue, 8 Sep 2026 15:35:43 +0800 Subject: [PATCH] feat(seed): channel update, share validity and CAS direct access Record successful saves as channels and failures as missing_channels when update_channel is set. Return share_status during edit by validating openlist-share sources. Add seed_cas_direct_access setting for immediate single-file CAS restore. Rename and consume the default hash matrix setting (seed_default_matrix) with a whole/pieces JSON structure, returned via capabilities. --- internal/bootstrap/data/setting.go | 3 +- internal/conf/const.go | 3 +- pkg/torrent/torrent.go | 26 +++-- server/handles/torrent.go | 150 ++++++++++++++++++++++++++--- 4 files changed, 159 insertions(+), 23 deletions(-) diff --git a/internal/bootstrap/data/setting.go b/internal/bootstrap/data/setting.go index b7ac612ed..f462ec55b 100644 --- a/internal/bootstrap/data/setting.go +++ b/internal/bootstrap/data/setting.go @@ -188,10 +188,11 @@ func InitialSettings() []model.SettingItem { {Key: conf.HandleHookRateLimit, Value: "0", Type: conf.TypeNumber, Group: model.GLOBAL, Flag: model.PRIVATE}, {Key: conf.IgnoreSystemFiles, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PRIVATE, Help: `When enabled, ignores common system files during upload (.DS_Store, desktop.ini, Thumbs.db, and files starting with ._)`}, {Key: conf.SeedSiteURL, Value: "", Type: conf.TypeString, Group: model.GLOBAL, Flag: model.PRIVATE, Help: `Public base URL embedded in generated transfer seed sources when configured`}, - {Key: conf.SeedDefaultHashMatrix, Value: "md5,sha1,sha256", Type: conf.TypeString, Group: model.GLOBAL, Flag: model.PRIVATE}, + {Key: conf.SeedDefaultMatrix, Value: `{"md5":{"whole":true,"pieces":false},"sha1":{"whole":true,"pieces":false},"sha256":{"whole":true,"pieces":false}}`, Type: conf.TypeText, Group: model.GLOBAL, Flag: model.PRIVATE, Help: `Default right-click hash matrix for transfer seed generation`}, {Key: conf.SeedFormatPolicies, Value: `{"oss":"off","torrent":"off","cas":"off"}`, Type: conf.TypeText, Group: model.GLOBAL, Flag: model.PRIVATE}, {Key: conf.SeedDefaultFormat, Value: "oss", Type: conf.TypeSelect, Options: "oss,torrent,cas", Group: model.GLOBAL, Flag: model.PRIVATE}, {Key: conf.SeedSingleDirectPreview, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PUBLIC}, + {Key: conf.SeedCASDirectAccess, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PUBLIC, Help: `When opening a single-file CAS seed, immediately rapid-upload it into the same folder and preview the restored file`}, {Key: conf.SeedAutoGeneratePolicy, Value: "off", Type: conf.TypeSelect, Options: "off,on", Group: model.GLOBAL, Flag: model.PRIVATE, Help: `Global upload sidecar policy; storage-specific inheritance can be layered without changing the safe default`}, // single settings diff --git a/internal/conf/const.go b/internal/conf/const.go index b2711ed26..4db9dd1bb 100644 --- a/internal/conf/const.go +++ b/internal/conf/const.go @@ -62,10 +62,11 @@ const ( // transfer seeds SeedSiteURL = "seed_site_url" - SeedDefaultHashMatrix = "seed_default_hash_matrix" + SeedDefaultMatrix = "seed_default_matrix" SeedFormatPolicies = "seed_format_policies" SeedDefaultFormat = "seed_default_format" SeedSingleDirectPreview = "seed_single_direct_preview" + SeedCASDirectAccess = "seed_cas_direct_access" SeedAutoGeneratePolicy = "seed_auto_generate_policy" // index diff --git a/pkg/torrent/torrent.go b/pkg/torrent/torrent.go index 283c956ca..54a9bfea7 100644 --- a/pkg/torrent/torrent.go +++ b/pkg/torrent/torrent.go @@ -68,14 +68,15 @@ type SeedChannel struct { // SeedFile describes one relative file in a sharing seed. type SeedFile struct { - Path string `json:"path"` - Size int64 `json:"size"` - Modified string `json:"modified,omitempty"` - Comment string `json:"comment,omitempty"` - Hashes SeedHashes `json:"hashes"` - Sources []SeedSource `json:"sources,omitempty"` - CASSliceMD5 string `json:"cas_slice_md5,omitempty"` - CASCreateTime string `json:"cas_create_time,omitempty"` + Path string `json:"path"` + Size int64 `json:"size"` + Modified string `json:"modified,omitempty"` + Comment string `json:"comment,omitempty"` + Hashes SeedHashes `json:"hashes"` + Sources []SeedSource `json:"sources,omitempty"` + CASSliceMD5 string `json:"cas_slice_md5,omitempty"` + CASCreateTime string `json:"cas_create_time,omitempty"` + MissingChannels []string `json:"missing_channels,omitempty"` } // SeedHashes contains whole-file and optional per-piece hashes. @@ -684,6 +685,11 @@ func ValidateSeed(seed *Seed, limits ParseLimits) error { return fmt.Errorf("file %q has an invalid source URL", file.Path) } } + for _, channel := range file.MissingChannels { + if strings.TrimSpace(channel) == "" || strings.ContainsAny(channel, "/\\\x00") { + return fmt.Errorf("file %q has an invalid missing channel", file.Path) + } + } } for _, channel := range seed.Channels { if strings.TrimSpace(channel.Driver) == "" { @@ -1171,6 +1177,9 @@ func seedToBencode(seed *Seed) map[string]interface{} { if file.CASCreateTime != "" { item["cas_create_time"] = file.CASCreateTime } + if len(file.MissingChannels) > 0 { + item["missing_channels"] = stringsToInterfaces(file.MissingChannels) + } sources := make([]interface{}, 0, len(file.Sources)) for _, source := range file.Sources { s := map[string]interface{}{"type": source.Type, "url": source.URL} @@ -1246,6 +1255,7 @@ func seedFromBencode(value interface{}) (*Seed, error) { file := SeedFile{ Path: bString(item["path"]), Size: bInt(item["size"]), Modified: bString(item["modified"]), Comment: bString(item["comment"]), CASSliceMD5: bString(item["cas_slice_md5"]), CASCreateTime: bString(item["cas_create_time"]), + MissingChannels: bStrings(item["missing_channels"]), } if hashes, ok := item["hashes"].(map[string]interface{}); ok { file.Hashes = SeedHashes{MD5: bString(hashes["md5"]), SHA1: bString(hashes["sha1"]), SHA256: bString(hashes["sha256"])} diff --git a/server/handles/torrent.go b/server/handles/torrent.go index cd2492ac6..360b898af 100644 --- a/server/handles/torrent.go +++ b/server/handles/torrent.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/base64" + "encoding/json" "fmt" "io" "net/url" @@ -541,6 +542,7 @@ type SeedQuickSaveReq struct { DeletePolicy string `json:"delete_policy"` Overwrite bool `json:"overwrite"` TransitPath string `json:"transit_path"` + UpdateChannel bool `json:"update_channel"` Options map[string]any `json:"options"` } @@ -739,8 +741,24 @@ func normalizedSeedFormats(req SeedGenerateReq) ([]string, error) { return result, nil } +func seedMatrixEmpty(matrix SeedHashMatrix) bool { + return !matrix.MD5.Whole && !matrix.MD5.Pieces && !matrix.SHA1.Whole && !matrix.SHA1.Pieces && !matrix.SHA256.Whole && !matrix.SHA256.Pieces +} + +// loadSeedDefaultMatrix parses the configured default right-click hash matrix. +func loadSeedDefaultMatrix() SeedHashMatrix { + var matrix SeedHashMatrix + if raw := strings.TrimSpace(setting.GetStr(conf.SeedDefaultMatrix)); raw != "" { + _ = json.Unmarshal([]byte(raw), &matrix) + } + return matrix +} + func normalizedSeedMatrix(matrix SeedHashMatrix, formats []string) SeedHashMatrix { - if !matrix.MD5.Whole && !matrix.MD5.Pieces && !matrix.SHA1.Whole && !matrix.SHA1.Pieces && !matrix.SHA256.Whole && !matrix.SHA256.Pieces { + if seedMatrixEmpty(matrix) { + matrix = loadSeedDefaultMatrix() + } + if seedMatrixEmpty(matrix) { matrix = SeedHashMatrix{ MD5: SeedHashSelection{Whole: true, Pieces: true}, SHA1: SeedHashSelection{Whole: true, Pieces: true}, SHA256: SeedHashSelection{Whole: true, Pieces: true}, @@ -1111,6 +1129,7 @@ func SeedCapabilities(c *gin.Context) { common.SuccessResp(c, gin.H{ "formats": gin.H{"oss": true, "torrent": true, "cas": len(files) == 1}, "files": files, "existing_hashes": existingHashes, "estimated_traffic": estimatedTraffic, + "default_matrix": loadSeedDefaultMatrix(), }) return } @@ -1375,30 +1394,39 @@ func UpdateSeed(c *gin.Context) { } result["path"] = stdpath.Join(outputPath, fileName) } + shareStatus := checkSeedShareValidity(seed) + if len(shareStatus) > 0 { + result["share_status"] = shareStatus + } common.SuccessResp(c, result) } // saveSeedFilesToPath saves selected seed files into one target directory. -func saveSeedFilesToPath(c *gin.Context, user *model.User, seed *torrent.Seed, req SeedQuickSaveReq, dstPath string, rapidOnly bool) ([]gin.H, error) { +func saveSeedFilesToPath(c *gin.Context, user *model.User, seed *torrent.Seed, req SeedQuickSaveReq, dstPath string, rapidOnly bool) ([]gin.H, string, string, error) { results := make([]gin.H, 0, len(seed.Files)) fullPath, err := user.JoinPath(dstPath) if err != nil { - return nil, err + return nil, "", "", err } meta, err := op.GetNearestMeta(fullPath) if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { - return nil, err + return nil, "", "", err } if (!user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(meta, fullPath)) || !common.CanWrite(user, meta, fullPath) { - return nil, errs.PermissionDenied + return nil, "", "", errs.PermissionDenied } storage, actualPath, err := op.GetStorageAndActualPath(fullPath) if err != nil { - return nil, err + return nil, "", "", err } dstDir, err := op.Get(c.Request.Context(), storage, actualPath) if err != nil || !dstDir.IsDir() { - return nil, errs.NotFolder + return nil, "", "", errs.NotFolder + } + driverName, mountPath := "", "" + if mounted := storage.GetStorage(); mounted != nil { + driverName = strings.TrimSpace(mounted.Driver) + mountPath = strings.TrimSpace(mounted.MountPath) } selected := make(map[string]struct{}, len(req.Files)) for _, name := range req.Files { @@ -1407,7 +1435,7 @@ func saveSeedFilesToPath(c *gin.Context, user *model.User, seed *torrent.Seed, r selectedIndexes := make(map[int]struct{}, len(req.SelectedFiles)) for _, index := range req.SelectedFiles { if index < 0 || index >= len(seed.Files) { - return nil, fmt.Errorf("selected_files contains an invalid index") + return nil, "", "", fmt.Errorf("selected_files contains an invalid index") } selectedIndexes[index] = struct{}{} } @@ -1470,7 +1498,7 @@ func saveSeedFilesToPath(c *gin.Context, user *model.User, seed *torrent.Seed, r } results = append(results, result) } - return results, nil + return results, driverName, mountPath, nil } // resolveSeedTransitPath extracts the optional transit destination for a relayed save. @@ -1492,7 +1520,7 @@ func transferSeedViaTransit(c *gin.Context, user *model.User, seed *torrent.Seed common.ErrorResp(c, errs.PermissionDenied, 403) return } - transitResults, err := saveSeedFilesToPath(c, user, seed, req, transitPath, false) + transitResults, _, _, err := saveSeedFilesToPath(c, user, seed, req, transitPath, false) if err != nil { common.ErrorResp(c, err, seedSaveErrorCode(err)) return @@ -1554,6 +1582,94 @@ func seedSaveErrorCode(err error) int { return 500 } +// checkSeedShareValidity reports whether each openlist-share source still resolves to a valid sharing. +func checkSeedShareValidity(seed *torrent.Seed) gin.H { + status := make(gin.H) + for _, file := range seed.Files { + for _, source := range file.Sources { + if source.Type != "openlist-share" || strings.TrimSpace(source.ShareID) == "" { + continue + } + shareID := strings.TrimSpace(source.ShareID) + if _, checked := status[shareID]; checked { + continue + } + valid := false + if sharing, err := op.GetSharingById(shareID, true); err == nil && sharing != nil { + valid = sharing.Valid() + } + status[shareID] = valid + } + } + return status +} + +// applyChannelUpdate reflects successful saves as channels and failed saves as missing channels. +func applyChannelUpdate(seed *torrent.Seed, driverName, mountPath string, results []gin.H) { + if driverName == "" { + return + } + successByPath := make(map[string]bool) + for _, res := range results { + filePath, _ := res["path"].(string) + if filePath == "" { + continue + } + method, _ := res["method"].(string) + successByPath[filePath] = method != "unavailable" + } + anySuccess := false + for _, ok := range successByPath { + if ok { + anySuccess = true + break + } + } + if anySuccess { + exists := false + for _, channel := range seed.Channels { + if channel.Driver == driverName { + exists = true + break + } + } + if !exists { + seed.Channels = append(seed.Channels, torrent.SeedChannel{Driver: driverName, MountPath: mountPath}) + } + } + for i := range seed.Files { + file := &seed.Files[i] + ok, tracked := successByPath[file.Path] + if !tracked { + continue + } + if ok { + file.MissingChannels = removeString(file.MissingChannels, driverName) + } else if !containsString(file.MissingChannels, driverName) { + file.MissingChannels = append(file.MissingChannels, driverName) + } + } +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func removeString(values []string, target string) []string { + result := values[:0] + for _, value := range values { + if value != target { + result = append(result, value) + } + } + return result +} + // QuickSaveSeed imports selected files without downloading when the target supports it. func QuickSaveSeed(c *gin.Context) { user := c.Request.Context().Value(conf.UserKey).(*model.User) @@ -1562,7 +1678,7 @@ func QuickSaveSeed(c *gin.Context) { common.ErrorResp(c, err, 400) return } - _, seed, _, err := decodeSeedData(req.SeedDataReq) + _, seed, format, err := decodeSeedData(req.SeedDataReq) if err != nil { common.ErrorResp(c, err, 400) return @@ -1572,12 +1688,20 @@ func QuickSaveSeed(c *gin.Context) { transferSeedViaTransit(c, user, seed, req, transitPath) return } - results, err := saveSeedFilesToPath(c, user, seed, req, req.Path, rapidOnly) + results, driverName, mountPath, err := saveSeedFilesToPath(c, user, seed, req, req.Path, rapidOnly) if err != nil { common.ErrorResp(c, err, seedSaveErrorCode(err)) return } - common.SuccessResp(c, gin.H{"results": results}) + resp := gin.H{"results": results} + if req.UpdateChannel { + applyChannelUpdate(seed, driverName, mountPath, results) + if data, encodeErr := torrent.EncodeSeed(seed, format); encodeErr == nil { + resp["seed_data"] = base64.StdEncoding.EncodeToString(data) + resp["seed"] = seed + } + } + common.SuccessResp(c, resp) } func firstUsableSeedSource(file torrent.SeedFile) string {