Skip to content

Commit 9c7ad84

Browse files
committed
feat(seed): add advanced transfer seed support (OSS/torrent/CAS)
Add unified sharing-seed format library (openlist-sharing-seed v1), standard BT torrent v1 with x-openlist/x-cas extensions, and exact legacy-compatible CAS Base64 payload. Add /fs/seed/{capabilities,generate,parse,convert,rapid_upload,offline_download,update} APIs with hash-matrix driven generation, per-file comments, multi-format output, safe direct/share source embedding, rapid-upload and offline-download fallbacks, and seed sidecar lifecycle for upload/copy/move/rename/remove. Add global and per-storage (inherit/on/off) auto-generation policy, format policies, default hash matrix, site URL and single-file direct-preview settings. Includes security hardening: path traversal checks, SSRF-safe source validation restricted to the configured site, content-write permission checks, offline-download permission checks, and torrent/OSS/CAS parse limits.
1 parent a5e5048 commit 9c7ad84

13 files changed

Lines changed: 2328 additions & 37 deletions

File tree

internal/bootstrap/data/setting.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,12 @@ func InitialSettings() []model.SettingItem {
187187
{Key: conf.HandleHookAfterWriting, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PRIVATE},
188188
{Key: conf.HandleHookRateLimit, Value: "0", Type: conf.TypeNumber, Group: model.GLOBAL, Flag: model.PRIVATE},
189189
{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 ._)`},
190+
{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`},
191+
{Key: conf.SeedDefaultHashMatrix, Value: "md5,sha1,sha256", Type: conf.TypeString, Group: model.GLOBAL, Flag: model.PRIVATE},
192+
{Key: conf.SeedFormatPolicies, Value: `{"oss":"off","torrent":"off","cas":"off"}`, Type: conf.TypeText, Group: model.GLOBAL, Flag: model.PRIVATE},
193+
{Key: conf.SeedDefaultFormat, Value: "oss", Type: conf.TypeSelect, Options: "oss,torrent,cas", Group: model.GLOBAL, Flag: model.PRIVATE},
194+
{Key: conf.SeedSingleDirectPreview, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PUBLIC},
195+
{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`},
190196

191197
// single settings
192198
{Key: conf.Token, Value: token, Type: conf.TypeString, Group: model.SINGLE, Flag: model.PRIVATE},

internal/conf/const.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ const (
6060
HandleHookRateLimit = "handle_hook_rate_limit"
6161
IgnoreSystemFiles = "ignore_system_files"
6262

63+
// transfer seeds
64+
SeedSiteURL = "seed_site_url"
65+
SeedDefaultHashMatrix = "seed_default_hash_matrix"
66+
SeedFormatPolicies = "seed_format_policies"
67+
SeedDefaultFormat = "seed_default_format"
68+
SeedSingleDirectPreview = "seed_single_direct_preview"
69+
SeedAutoGeneratePolicy = "seed_auto_generate_policy"
70+
6371
// index
6472
SearchIndex = "search_index"
6573
AutoUpdateIndex = "auto_update_index"

internal/model/storage.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type Storage struct {
1919
Disabled bool `json:"disabled"` // if disabled
2020
DisableIndex bool `json:"disable_index"`
2121
EnableSign bool `json:"enable_sign"`
22+
SeedPolicy string `json:"seed_policy" gorm:"default:inherit"`
2223
Sort
2324
Proxy
2425
}

internal/op/driver.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,14 @@ func getMainItems(config driver.Config) []driver.Item {
173173
Default: "false",
174174
Required: true,
175175
})
176+
items = append(items, driver.Item{
177+
Name: "seed_policy",
178+
Type: conf.TypeSelect,
179+
Options: "inherit,on,off",
180+
Default: "inherit",
181+
Required: true,
182+
Help: "Override automatic transfer-seed generation for this storage",
183+
})
176184
return items
177185
}
178186
func getAdditionalItems(t reflect.Type, defaultRoot string) []driver.Item {

pkg/torrent/bencode.go

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -145,15 +145,24 @@ func bencodeEncodeOrderedDict(w io.Writer, d OrderedDict) error {
145145

146146
// BencodeDecode 从字节数组解码 bencode 数据
147147
func BencodeDecode(data []byte) (interface{}, error) {
148+
if int64(len(data)) > DefaultMaxSeedSize {
149+
return nil, fmt.Errorf("bencode: input exceeds %d bytes", DefaultMaxSeedSize)
150+
}
148151
reader := bytes.NewReader(data)
149-
val, err := bencodeDecodeValue(reader)
152+
val, err := bencodeDecodeValue(reader, 0)
150153
if err != nil {
151154
return nil, err
152155
}
156+
if reader.Len() != 0 {
157+
return nil, fmt.Errorf("bencode: trailing data")
158+
}
153159
return val, nil
154160
}
155161

156-
func bencodeDecodeValue(r *bytes.Reader) (interface{}, error) {
162+
func bencodeDecodeValue(r *bytes.Reader, depth int) (interface{}, error) {
163+
if depth > DefaultParseLimits().MaxDepth {
164+
return nil, fmt.Errorf("bencode: nesting depth exceeds limit")
165+
}
157166
b, err := r.ReadByte()
158167
if err != nil {
159168
return nil, err
@@ -163,9 +172,9 @@ func bencodeDecodeValue(r *bytes.Reader) (interface{}, error) {
163172
case b == 'i':
164173
return bencodeDecodeInt(r)
165174
case b == 'l':
166-
return bencodeDecodeList(r)
175+
return bencodeDecodeList(r, depth+1)
167176
case b == 'd':
168-
return bencodeDecodeDict(r)
177+
return bencodeDecodeDict(r, depth+1)
169178
case b >= '0' && b <= '9':
170179
r.UnreadByte()
171180
return bencodeDecodeString(r)
@@ -218,41 +227,47 @@ func bencodeDecodeString(r *bytes.Reader) ([]byte, error) {
218227
return data, nil
219228
}
220229

221-
func bencodeDecodeList(r *bytes.Reader) ([]interface{}, error) {
230+
func bencodeDecodeList(r *bytes.Reader, depth int) ([]interface{}, error) {
222231
var list []interface{}
223232
for {
233+
if len(list) >= DefaultMaxSeedFiles*4 {
234+
return nil, fmt.Errorf("bencode: list item limit exceeded")
235+
}
224236
b, err := r.ReadByte()
225237
if err != nil {
226238
return nil, err
227239
}
228240
if b == 'e' {
229241
return list, nil
230242
}
231-
r.UnreadByte()
232-
val, err := bencodeDecodeValue(r)
243+
_ = r.UnreadByte()
244+
val, err := bencodeDecodeValue(r, depth)
233245
if err != nil {
234246
return nil, err
235247
}
236248
list = append(list, val)
237249
}
238250
}
239251

240-
func bencodeDecodeDict(r *bytes.Reader) (map[string]interface{}, error) {
252+
func bencodeDecodeDict(r *bytes.Reader, depth int) (map[string]interface{}, error) {
241253
dict := make(map[string]interface{})
242254
for {
255+
if len(dict) >= DefaultMaxSeedFiles*4 {
256+
return nil, fmt.Errorf("bencode: dictionary item limit exceeded")
257+
}
243258
b, err := r.ReadByte()
244259
if err != nil {
245260
return nil, err
246261
}
247262
if b == 'e' {
248263
return dict, nil
249264
}
250-
r.UnreadByte()
265+
_ = r.UnreadByte()
251266
keyBytes, err := bencodeDecodeString(r)
252267
if err != nil {
253268
return nil, err
254269
}
255-
val, err := bencodeDecodeValue(r)
270+
val, err := bencodeDecodeValue(r, depth)
256271
if err != nil {
257272
return nil, err
258273
}

pkg/torrent/generate.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package torrent
22

33
import (
4+
"fmt"
45
"io"
56
"os"
7+
"path"
68
"strings"
79
)
810

@@ -121,3 +123,27 @@ func GenerateFromFileWithCAS(filePath string) ([]byte, error) {
121123

122124
return GenerateFromReaderWithCAS(f, info.Name(), info.Size(), DefaultPieceSize)
123125
}
126+
127+
// GenerateSeedFromReader computes the complete OSS hash matrix in one stream pass.
128+
func GenerateSeedFromReader(reader io.Reader, filePath string, expectedSize, pieceSize int64, createdBy string) (*Seed, error) {
129+
if pieceSize <= 0 {
130+
pieceSize = DefaultPieceSize
131+
}
132+
if err := validateRelativeSeedPath(filePath); err != nil {
133+
return nil, err
134+
}
135+
hw := NewHashWriter(pieceSize, pieceSize)
136+
if _, err := CopyAndHash(nil, reader, hw); err != nil {
137+
return nil, err
138+
}
139+
hw.Finish()
140+
if expectedSize >= 0 && hw.GetTotalWritten() != expectedSize {
141+
return nil, fmt.Errorf("stream size mismatch: read %d bytes, expected %d", hw.GetTotalWritten(), expectedSize)
142+
}
143+
seed := NewSeed(path.Base(filePath), createdBy, pieceSize)
144+
seed.Files = []SeedFile{hw.BuildSeedFile(filePath, "")}
145+
if err := ValidateSeed(seed, DefaultParseLimits()); err != nil {
146+
return nil, err
147+
}
148+
return seed, nil
149+
}

pkg/torrent/hash_writer.go

Lines changed: 83 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package torrent
33
import (
44
"crypto/md5"
55
"crypto/sha1"
6+
"crypto/sha256"
67
"encoding/hex"
78
"fmt"
89
"hash"
@@ -15,10 +16,15 @@ import (
1516
type HashWriter struct {
1617
// 整文件 MD5
1718
fileMD5 hash.Hash
19+
// fileSHA1 and fileSHA256 complete the portable full-file matrix.
20+
fileSHA1 hash.Hash
21+
fileSHA256 hash.Hash
1822
// 当前分片 MD5
1923
sliceMD5 hash.Hash
20-
// 当前 piece 的 SHA-1
21-
pieceSHA1 hash.Hash
24+
// Per-piece hashers are updated in the same pass as whole-file hashes.
25+
pieceMD5 hash.Hash
26+
pieceSHA1 hash.Hash
27+
pieceSHA256 hash.Hash
2228

2329
// 分片大小(默认 10MB)
2430
sliceSize int64
@@ -34,8 +40,12 @@ type HashWriter struct {
3440

3541
// 每个分片的 MD5(大写十六进制)
3642
sliceMD5Hexs []string
37-
// 所有 piece 的 SHA-1 哈希拼接
43+
// all standard BitTorrent SHA-1 piece hashes concatenated
3844
pieceHashes []byte
45+
// portable per-file piece matrix
46+
pieceMD5Hexs []string
47+
pieceSHA1Hexs []string
48+
pieceSHA256Hexs []string
3949
}
4050

4151
// NewHashWriter 创建一个新的 HashWriter
@@ -49,11 +59,15 @@ func NewHashWriter(sliceSize, pieceSize int64) *HashWriter {
4959
pieceSize = DefaultPieceSize
5060
}
5161
return &HashWriter{
52-
fileMD5: md5.New(),
53-
sliceMD5: md5.New(),
54-
pieceSHA1: sha1.New(),
55-
sliceSize: sliceSize,
56-
pieceSize: pieceSize,
62+
fileMD5: md5.New(),
63+
fileSHA1: sha1.New(),
64+
fileSHA256: sha256.New(),
65+
sliceMD5: md5.New(),
66+
pieceMD5: md5.New(),
67+
pieceSHA1: sha1.New(),
68+
pieceSHA256: sha256.New(),
69+
sliceSize: sliceSize,
70+
pieceSize: pieceSize,
5771
}
5872
}
5973

@@ -76,12 +90,14 @@ func (hw *HashWriter) Write(p []byte) (n int, err error) {
7690

7791
chunk := p[offset : offset+int(canWrite)]
7892

79-
// 写入整文件 MD5
80-
hw.fileMD5.Write(chunk)
81-
// 写入当前分片 MD5
82-
hw.sliceMD5.Write(chunk)
83-
// 写入当前 piece SHA-1
84-
hw.pieceSHA1.Write(chunk)
93+
// Write all whole-file and boundary-specific hashes in one pass.
94+
_, _ = hw.fileMD5.Write(chunk)
95+
_, _ = hw.fileSHA1.Write(chunk)
96+
_, _ = hw.fileSHA256.Write(chunk)
97+
_, _ = hw.sliceMD5.Write(chunk)
98+
_, _ = hw.pieceMD5.Write(chunk)
99+
_, _ = hw.pieceSHA1.Write(chunk)
100+
_, _ = hw.pieceSHA256.Write(chunk)
85101

86102
hw.sliceWritten += canWrite
87103
hw.pieceWritten += canWrite
@@ -112,8 +128,16 @@ func (hw *HashWriter) finishSlice() {
112128

113129
// finishPiece 完成当前 piece 的 SHA-1 计算
114130
func (hw *HashWriter) finishPiece() {
115-
hw.pieceHashes = append(hw.pieceHashes, hw.pieceSHA1.Sum(nil)...)
131+
md5Sum := hw.pieceMD5.Sum(nil)
132+
sha1Sum := hw.pieceSHA1.Sum(nil)
133+
sha256Sum := hw.pieceSHA256.Sum(nil)
134+
hw.pieceMD5Hexs = append(hw.pieceMD5Hexs, hex.EncodeToString(md5Sum))
135+
hw.pieceSHA1Hexs = append(hw.pieceSHA1Hexs, hex.EncodeToString(sha1Sum))
136+
hw.pieceSHA256Hexs = append(hw.pieceSHA256Hexs, hex.EncodeToString(sha256Sum))
137+
hw.pieceHashes = append(hw.pieceHashes, sha1Sum...)
138+
hw.pieceMD5.Reset()
116139
hw.pieceSHA1.Reset()
140+
hw.pieceSHA256.Reset()
117141
hw.pieceWritten = 0
118142
}
119143

@@ -134,6 +158,50 @@ func (hw *HashWriter) GetFileMD5() string {
134158
return strings.ToUpper(hex.EncodeToString(hw.fileMD5.Sum(nil)))
135159
}
136160

161+
// GetFileSHA1 returns the lowercase whole-file SHA-1 digest.
162+
func (hw *HashWriter) GetFileSHA1() string {
163+
return hex.EncodeToString(hw.fileSHA1.Sum(nil))
164+
}
165+
166+
// GetFileSHA256 returns the lowercase whole-file SHA-256 digest.
167+
func (hw *HashWriter) GetFileSHA256() string {
168+
return hex.EncodeToString(hw.fileSHA256.Sum(nil))
169+
}
170+
171+
// GetPieceMD5s returns independent per-file MD5 piece hashes.
172+
func (hw *HashWriter) GetPieceMD5s() []string {
173+
return append([]string(nil), hw.pieceMD5Hexs...)
174+
}
175+
176+
// GetPieceSHA1s returns independent per-file SHA-1 piece hashes.
177+
func (hw *HashWriter) GetPieceSHA1s() []string {
178+
return append([]string(nil), hw.pieceSHA1Hexs...)
179+
}
180+
181+
// GetPieceSHA256s returns independent per-file SHA-256 piece hashes.
182+
func (hw *HashWriter) GetPieceSHA256s() []string {
183+
return append([]string(nil), hw.pieceSHA256Hexs...)
184+
}
185+
186+
// BuildSeedFile exports all hashes accumulated during this single stream pass.
187+
func (hw *HashWriter) BuildSeedFile(filePath string, modified string) SeedFile {
188+
return SeedFile{
189+
Path: filePath,
190+
Size: hw.totalWritten,
191+
Modified: modified,
192+
Hashes: SeedHashes{
193+
MD5: strings.ToLower(hw.GetFileMD5()),
194+
SHA1: hw.GetFileSHA1(),
195+
SHA256: hw.GetFileSHA256(),
196+
Pieces: &SeedPieceHashes{
197+
MD5: hw.GetPieceMD5s(),
198+
SHA1: hw.GetPieceSHA1s(),
199+
SHA256: hw.GetPieceSHA256s(),
200+
},
201+
},
202+
}
203+
}
204+
137205
// GetSliceMD5s 获取所有分片的 MD5 列表
138206
func (hw *HashWriter) GetSliceMD5s() []string {
139207
return hw.sliceMD5Hexs

0 commit comments

Comments
 (0)