mirror of
https://github.com/OpenListTeam/OpenList.git
synced 2026-10-10 13:03:09 +08:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+18
-8
@@ -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"])}
|
||||
|
||||
+137
-13
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user