diff --git a/docs/api/03-file-share.md b/docs/api/03-file-share.md index 56452a7..91d2eea 100644 --- a/docs/api/03-file-share.md +++ b/docs/api/03-file-share.md @@ -101,3 +101,31 @@ curl -s -H 'Range: bytes=0-1023' -o part.bin \ ```json { "code": 416, "msg": "请求范围超出文件大小" } ``` + +## 直链下载(26.9) + +存储引擎为对象存储(S3)且 `direct_download=1` 时,`GET /share/select` 与 +`GET /share/download` 不再代理文件流,而是 `302` 重定向到限时预签名 URL—— +文件字节不经过本服务器,带宽成本转嫁对象存储。 + +- 签名有效期 = `direct_link_expire`(默认 900 秒)与分享剩余时效的较小值; +- 引擎不支持直链(如 local/WebDAV)时自动回落代理下载,取件不中断; +- 审计照常记录(`transferred_bytes` 记为文件大小)。 + +## 下载防盗链(26.9) + +`hotlink_enabled=1` 时,`/share/download` 校验 `Referer`: + +| Referer | 行为 | +|---|---| +| 空(直接访问 / curl / 地址栏) | 放行 | +| 与请求 Host 同源 | 放行 | +| 命中 `hotlink_whitelist`(逗号分隔域名,支持 `*.example.com` 通配) | 放行 | +| 其余 | `403`(JSON 错误体) | + +白名单为空时仅同源放行。开关与白名单均为管理端 KV,修改后立即生效。 + +## 文件夹上传(26.9) + +不支持文件夹上传(前端已移除目录选择;拖拽目录会提示"建议压缩后上传")。 +后端 `SanitizeFileName` 会剥离文件名中的路径分隔符,多级路径无法成体保存。 diff --git a/docs/api/07-admin.md b/docs/api/07-admin.md index f7a3b10..fc3e7e7 100644 --- a/docs/api/07-admin.md +++ b/docs/api/07-admin.md @@ -432,3 +432,20 @@ curl -s -X PATCH http://localhost:8466/admin/settings/password \ ```json { "code": 401, "msg": "旧密码错误" } ``` + +## 手动回收:POST /admin/recycle/run + +**26.9**:手动触发一轮过期分享回收(定时循环之外的管理端入口)。回收范围: +时间已过期、次数已耗尽、创建时间超过 `retention_days` 的分享——删除记录并 +连带删除存储对象(SHA512 去重开启时做引用计数,仍有其他分享引用的对象保留)。 + +```bash +curl -s -X POST "http://localhost:8466/admin/recycle/run" -H "Authorization: Bearer $TOKEN" +``` + +```json +{ "code": 200, "msg": "ok", "data": { "removed": 3 } } +``` + +相关配置键:`recycle_enabled`(定时开关)、`recycle_interval`(扫描间隔)、 +`retention_days`(最长存储时长)、`dedup_enabled`(引用计数开关)。 diff --git a/docs/api/10-config.md b/docs/api/10-config.md index d071126..8f2a206 100644 --- a/docs/api/10-config.md +++ b/docs/api/10-config.md @@ -80,6 +80,14 @@ DSN 缺省落 `./data/fileshare.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_DS |---|---|---|---| | `uploadCount` / `uploadMinute` | int(1~10000 / 1~1440) | 10 / 1 | 窗口内允许上传次数 / 窗口分钟(上传成功才计数,超限 423;管理端修改后运行时同步限流规则,立即生效) | | `upload_rate` / `download_rate` | int64(0~1 GiB/s) | 0 / 0 | **26.9**:上下行带宽字节/秒,0=不限速;管理端改后立即生效(每请求动态读 KV)。详见《[带宽限速](13-bandwidth.md)》 | +| `recycle_enabled` | 0/1 | 1 | **26.9**:过期分享自动回收开关(定时扫描 + 取件惰性回收) | +| `recycle_interval` | int64(60~86400 秒) | 1800 | **26.9**:回收扫描间隔(秒;管理端以分钟展示) | +| `retention_days` | int64(0~3650 天) | 0 | **26.9**:最长存储时长(天),上传超过该天数的分享自动回收;0=不限制 | +| `dedup_enabled` | 0/1 | 1 | **26.9**:SHA512 内容去重,相同文件仅存储一份(多分享引用同一对象,引用计数删除) | +| `direct_download` | 0/1 | 1 | **26.9**:对象存储直链下载(S3 引擎 302 到预签名 URL,文件不经过本站带宽) | +| `direct_link_expire` | int64(60~3600 秒) | 900 | **26.9**:直链签名有效期(秒;不超过分享剩余时效) | +| `hotlink_enabled` | 0/1 | 0 | **26.9**:下载防盗链(Referer 白名单校验;空 Referer 放行) | +| `hotlink_whitelist` | string(≤2048) | 空 | **26.9**:防盗链白名单,逗号分隔域名,支持 `*.example.com` 通配;空=仅同源放行 | | `errorCount` / `errorMinute` | int | 10 / 1 | 取件错误(失败计数)+ metadata 每次计数 | | `loginCount` / `loginMinute` | int | 5 / 15 | 登录失败计数 | diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 707bb70..78742ed 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -168,6 +168,14 @@ paths: uploadMinute: { type: integer, default: 1 } upload_rate: { type: integer, format: int64, default: 0, description: '26.9 上行带宽字节/秒;0=不限速' } download_rate: { type: integer, format: int64, default: 0, description: '26.9 下行带宽字节/秒;0=不限速' } + recycle_enabled: { type: integer, enum: [0, 1], default: 1, description: '26.9 过期分享自动回收开关' } + recycle_interval: { type: integer, format: int64, default: 1800, description: '26.9 回收扫描间隔秒(60~86400)' } + retention_days: { type: integer, format: int64, default: 0, description: '26.9 最长存储时长天(0~3650;0=不限制)' } + dedup_enabled: { type: integer, enum: [0, 1], default: 1, description: '26.9 SHA512 内容去重开关' } + direct_download: { type: integer, enum: [0, 1], default: 1, description: '26.9 对象存储直链下载开关' } + direct_link_expire: { type: integer, format: int64, default: 900, description: '26.9 直链签名有效期秒(60~3600)' } + hotlink_enabled: { type: integer, enum: [0, 1], default: 0, description: '26.9 下载防盗链开关' } + hotlink_whitelist: { type: string, maxLength: 2048, default: '', description: '26.9 防盗链白名单(逗号分隔域名,支持 *.example.com)' } allowed_file_types: { type: string, default: '*' } openUpload: { type: boolean, default: true } enableChunk: { type: boolean, default: false } @@ -1379,6 +1387,14 @@ paths: uploadMinute: { type: integer } upload_rate: { type: integer, format: int64, description: '26.9 上行带宽字节/秒;0=不限速' } download_rate: { type: integer, format: int64, description: '26.9 下行带宽字节/秒;0=不限速' } + recycle_enabled: { type: integer, enum: [0, 1], description: '26.9 过期分享自动回收开关' } + recycle_interval: { type: integer, format: int64, description: '26.9 回收扫描间隔秒(60~86400)' } + retention_days: { type: integer, format: int64, description: '26.9 最长存储时长天(0~3650)' } + dedup_enabled: { type: integer, enum: [0, 1], description: '26.9 SHA512 内容去重开关' } + direct_download: { type: integer, enum: [0, 1], description: '26.9 对象存储直链下载开关' } + direct_link_expire: { type: integer, format: int64, description: '26.9 直链签名有效期秒(60~3600)' } + hotlink_enabled: { type: integer, enum: [0, 1], description: '26.9 下载防盗链开关' } + hotlink_whitelist: { type: string, maxLength: 2048, description: '26.9 防盗链白名单' } admin_token: { type: string, example: '' } _engine_hint: type: object @@ -1529,6 +1545,33 @@ paths: "401": { $ref: "#/components/responses/Unauthorized" } "503": { $ref: "#/components/responses/ServiceUnavailable" } + /admin/recycle/run: + post: + tags: [admin] + summary: 手动触发一轮过期回收(26.9) + description: | + 回收时间已过期、次数已耗尽、创建时间超过 retention_days 的分享: + 删除记录并连带删除存储对象(dedup_enabled 开启时做引用计数, + 仍有其他分享引用的对象保留)。返回本轮回收条数。 + operationId: adminRecycleRun + security: + - bearerAuth: [] + responses: + "200": + description: 回收完成 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + type: object + properties: + removed: { type: integer, description: 本轮回收条数 } + "401": { $ref: '#/components/responses/Unauthorized' } + /admin/settings/password: patch: tags: [管理后台] diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 4fe90c9..1e51cd7 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -31,9 +31,9 @@ import ( // // 编译时可选注入(goreleaser 触发),不注入则保持默认 26.9。 var ( - APP_VERSION = "26.9" - BuildCommit = "dev" - BuildDate = "unknown" + APP_VERSION = "26.9" + BuildCommit = "dev" + BuildDate = "unknown" ) func main() { @@ -149,8 +149,12 @@ func main() { }) // 9. HTTP 服务 - // M5:后台清理循环(过期预留/超时会话/直传残留对象),启动后 10 分钟首跑 - janitor.Start(ctx, db, store, 10*time.Minute) + // M5:后台清理循环(过期预留/超时会话/直传残留对象),启动后 10 分钟首跑。 + // 26.9:过期分享回收(recycle_enabled/recycle_interval/retention_days 动态读取) + janitor.Start(ctx, db, store, 10*time.Minute, &janitor.Recycler{ + Enabled: cfg.RecycleEnabled, + RetentionDays: cfg.RetentionDays, + }) srv := &http.Server{ Addr: cfg.Env.Listen, Handler: r, diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go index 053e338..20c6ecf 100644 --- a/server/internal/api/admin.go +++ b/server/internal/api/admin.go @@ -1,6 +1,7 @@ package api import ( + "context" "errors" "fmt" "log" @@ -13,6 +14,7 @@ import ( "gorm.io/gorm" "fileshare/internal/config" + "fileshare/internal/janitor" "fileshare/internal/middleware" "fileshare/internal/model" "fileshare/internal/response" @@ -81,6 +83,9 @@ func registerAdmin(r *gin.Engine, d *Deps) { // 26.9 存储引擎:运行时热切换(健康检查通过才生效,失败保持原引擎) authed.POST("/storage/switch", d.adminStorageSwitch) + // 26.9 过期回收:手动触发一轮(定时循环之外的管理端入口) + authed.POST("/recycle/run", d.adminRecycleRun) + // 审计日志查询(需求 ③;logs 为 list 的别名) authed.GET("/audit/list", d.adminAuditList) authed.GET("/audit/logs", d.adminAuditList) @@ -796,6 +801,9 @@ var configKeys = []string{ "loginCount", "loginMinute", "opacity", "background", "showAdminAddr", "robotsText", "site_domain", // 26.9:站点对外域名 "upload_rate", "download_rate", // 26.9:上下行带宽字节/秒(0=不限速) + // 26.9 回收与下载安全 + "recycle_enabled", "recycle_interval", "retention_days", "dedup_enabled", + "hotlink_enabled", "hotlink_whitelist", "direct_download", "direct_link_expire", "adminSessionExpire", "storage_path", "local_storage_path", "file_storage", // 26.9 存储引擎与引擎参数(热切换;凭据为敏感键,get 掩码/update 空跳过) @@ -816,6 +824,8 @@ var intConfigKeys = []string{ "adminSessionExpire", "max_save_count", "max_file_size", "notify_enabled", "upload_rate", "download_rate", // 26.9 + "recycle_enabled", "recycle_interval", "retention_days", "dedup_enabled", + "hotlink_enabled", "direct_download", "direct_link_expire", // 26.9(hotlink_whitelist 为字符串键) } // validateConfigValue 按 settings.KVSchema 校验单个配置值: @@ -1300,11 +1310,15 @@ func (d *Deps) fileByID(c *gin.Context, id int64) (*model.FileCodes, error) { } // deleteFileCode 删除分享记录与存储文件(文本分享无存储文件)。 +// 26.9:SHA512 去重开启时同一对象可能被多条分享引用——删除前按 +// ContentHash+Engine+UUIDFileName 引用计数,仍有其他引用则保留对象。 func (d *Deps) deleteFileCode(c *gin.Context, fc *model.FileCodes) error { if fc.Text == nil && fc.FilePath != nil && fc.UUIDFileName != nil { // 26.9:删除走文件归属引擎(旧引擎里的文件也要能删掉) if delStore, dErr := d.storeFor(fc.Engine); dErr == nil { - if err := delStore.DeleteFile(c.Request.Context(), fileSavePath(fc)); err != nil && !errors.Is(err, storage.ErrNotFound) { + if d.referencedByOther(c.Request.Context(), fc) { + // 还有其他分享引用该对象:仅删记录 + } else if err := delStore.DeleteFile(c.Request.Context(), fileSavePath(fc)); err != nil && !errors.Is(err, storage.ErrNotFound) { return errInternal("存储文件删除失败: " + err.Error()) } } @@ -1316,6 +1330,31 @@ func (d *Deps) deleteFileCode(c *gin.Context, fc *model.FileCodes) error { return nil } +// referencedByOther 该分享的存储对象是否仍被其他分享引用(SHA512 去重)。 +// 无 ContentHash(历史数据/去重未开启)时恒 false——按旧语义直接删对象。 +func (d *Deps) referencedByOther(ctx context.Context, fc *model.FileCodes) bool { + if fc.ContentHash == nil || *fc.ContentHash == "" || fc.UUIDFileName == nil { + return false + } + var cnt int64 + _ = d.DB.WithContext(ctx).Model(&model.FileCodes{}). + Where("content_hash = ? AND engine = ? AND uuid_file_name = ? AND id <> ?", + *fc.ContentHash, fc.Engine, *fc.UUIDFileName, fc.ID). + Count(&cnt).Error + return cnt > 0 +} + +// adminRecycleRun 手动触发一轮过期回收(26.9;定时循环之外的入口)。 +// 返回 {removed: 本轮回收条数}。 +func (d *Deps) adminRecycleRun(c *gin.Context) { + removed := janitor.RecycleExpired(c.Request.Context(), d.DB, d.Store, &janitor.Recycler{ + Enabled: d.Cfg.RecycleEnabled, + RetentionDays: d.Cfg.RetentionDays, + }) + auditRecordSuccess(c, d.AuditSvc) + response.OK(c, gin.H{"removed": removed}) +} + // deleteMany 批量删除:返回 (已删除, 不存在, 失败)。 func (d *Deps) deleteMany(c *gin.Context, ids []int64) (deleted []int64, missing []int64, failed []gin.H) { deleted, missing = []int64{}, []int64{} diff --git a/server/internal/api/chunk.go b/server/internal/api/chunk.go index a67d834..43edb40 100644 --- a/server/internal/api/chunk.go +++ b/server/internal/api/chunk.go @@ -579,6 +579,10 @@ func (d *Deps) chunkComplete(c *gin.Context) { fc.Suffix = ext err = d.DB.WithContext(ctx).Create(&fc).Error err = mapCodeConflict(err) // 26.9 + if err == nil { + // 26.9:SHA512 内容去重(命中则复用旧对象并删除本次副本) + d.applyDedup(ctx, mergeStore, session.SavePath, &fc) + } } if err == nil { // 成功:清理分片与记录(走归属引擎) diff --git a/server/internal/api/dedup.go b/server/internal/api/dedup.go new file mode 100644 index 0000000..cf03157 --- /dev/null +++ b/server/internal/api/dedup.go @@ -0,0 +1,80 @@ +// Package api — dedup.go SHA512 内容去重(26.9): +// 上传完成后计算对象 SHA512,命中已有分享(同哈希+同引擎)则复用其存储对象、 +// 删除本次上传的副本——相同文件只存储一份。历史数据(无哈希)不受影响。 +package api + +import ( + "context" + "crypto/sha512" + "encoding/hex" + "io" + "log" + + "gorm.io/gorm" + + "fileshare/internal/model" + "fileshare/internal/storage" +) + +// hashObject 流式计算存储对象 SHA512(hex);读取失败返回空串(去重按尽力而为降级)。 +func hashObject(ctx context.Context, store storage.Storage, savePath string) string { + dl, err := store.Open(ctx, savePath, nil) + if err != nil { + return "" + } + defer func() { _ = dl.Close() }() + h := sha512.New() + if _, err := io.Copy(h, dl); err != nil { + return "" + } + return hex.EncodeToString(h.Sum(nil)) +} + +// applyDedup 上传落库后执行去重: +// 1. 计算刚保存对象的 SHA512; +// 2. 命中同哈希+同引擎的其他分享 → 复用其 FilePath/UUIDFileName,删除本次副本; +// 3. 未命中 → 只回填 ContentHash。 +// +// 任何失败都不影响上传结果(记录保留、哈希留空 = 不参与去重)。 +func (d *Deps) applyDedup(ctx context.Context, store storage.Storage, savedPath string, fc *model.FileCodes) { + if !d.Cfg.DedupEnabled() || fc == nil || fc.ID == 0 || fc.Text != nil { + return + } + hash := hashObject(ctx, store, savedPath) + if hash == "" { + log.Printf("[dedup] 哈希计算失败 code=%s(跳过去重)", fc.Code) + return + } + updates := map[string]any{"content_hash": hash} + + var old model.FileCodes + err := d.DB.WithContext(ctx). + Where("content_hash = ? AND engine = ? AND id <> ? AND uuid_file_name IS NOT NULL", + hash, fc.Engine, fc.ID). + First(&old).Error + switch { + case err == nil && old.UUIDFileName != nil && old.FilePath != nil: + // 命中:复用旧对象,删除本次副本 + updates["file_path"] = *old.FilePath + updates["uuid_file_name"] = *old.UUIDFileName + if err := store.DeleteFile(ctx, savedPath); err != nil { + log.Printf("[dedup] 删除重复副本失败 code=%s: %v", fc.Code, err) + } + log.Printf("[dedup] 命中同内容分享 code=%s 复用 %s", fc.Code, old.Code) + case err != nil && err != gorm.ErrRecordNotFound: + log.Printf("[dedup] 去重查询失败 code=%s: %v", fc.Code, err) + } + if err := d.DB.WithContext(ctx).Model(fc).Updates(updates).Error; err != nil { + log.Printf("[dedup] 回填哈希失败 code=%s: %v", fc.Code, err) + return + } + fc.ContentHash = &hash + if fp, ok := updates["file_path"]; ok { + s := fp.(string) + fc.FilePath = &s + } + if un, ok := updates["uuid_file_name"]; ok { + s := un.(string) + fc.UUIDFileName = &s + } +} diff --git a/server/internal/api/presign.go b/server/internal/api/presign.go index 9cb5100..8343c41 100644 --- a/server/internal/api/presign.go +++ b/server/internal/api/presign.go @@ -425,6 +425,10 @@ func (d *Deps) createRecordFromSession(c *gin.Context, session *model.PresignUpl if err := d.DB.WithContext(ctx).Create(&fc).Error; err != nil { return "", mapCodeConflict(err) // 26.9:并发占用自定义码 → 友好 400 } + // 26.9:SHA512 内容去重(命中则复用旧对象并删除本次副本) + if store, sErr := d.storeFor(session.Engine); sErr == nil { + d.applyDedup(ctx, store, session.SavePath, &fc) + } return code, nil } diff --git a/server/internal/api/recycle_dedup_test.go b/server/internal/api/recycle_dedup_test.go new file mode 100644 index 0000000..4c93fc4 --- /dev/null +++ b/server/internal/api/recycle_dedup_test.go @@ -0,0 +1,406 @@ +package api + +// recycle_dedup_test.go — 26.9 回收与下载安全测试: +// SHA512 去重(同内容单存储 + 引用计数删除)、过期回收(时间/次数/留存期)、 +// 防盗链中间件、S3 直链 302 重定向。 + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "fileshare/internal/janitor" + "fileshare/internal/middleware" + "fileshare/internal/model" + "fileshare/internal/storage" +) + +// ============ 辅助 ============ + +// uploadOK 上传文件并断言 200,返回取件码(复用 policy_test 的 uploadFile/respBody)。 +func uploadOK(t *testing.T, d *Deps, name string, content []byte) string { + t.Helper() + w := uploadFile(d, name, content, nil) + if w.Code != http.StatusOK { + t.Fatalf("上传失败: %d %s", w.Code, w.Body.String()) + } + _, data := respBody(t, w) + code, _ := data["code"].(string) + if code == "" { + t.Fatalf("响应缺少 code: %s", w.Body.String()) + } + return code +} + +// fileByID 按 code 查记录。 +func fileByCode(t *testing.T, d *Deps, code string) model.FileCodes { + t.Helper() + var fc model.FileCodes + if err := d.DB.Where("code = ?", code).First(&fc).Error; err != nil { + t.Fatalf("查询分享 %s: %v", code, err) + } + return fc +} + +// objectExists 检查本地引擎对象是否存在。 +func objectExists(t *testing.T, d *Deps, fc model.FileCodes) bool { + t.Helper() + store, err := d.storeFor(fc.Engine) + if err != nil { + t.Fatal(err) + } + ok, err := store.FileExists(context.Background(), fc.SavePath()) + if err != nil { + t.Fatalf("FileExists: %v", err) + } + return ok +} + +// ============ SHA512 去重 ============ + +// TestDedupSameContentSingleObject 同内容上传两次 → 单存储对象 + 记录互引 + +// 删除其一对象保留,删除最后一条对象才删除。 +func TestDedupSameContentSingleObject(t *testing.T) { + d := newPolicyTestDeps(t) + content := []byte("dedup-me-26.9-同一个内容") + + code1 := uploadOK(t, d, "a.txt", content) + code2 := uploadOK(t, d, "b.txt", content) + if code1 == code2 { + t.Fatal("两次上传应生成不同取件码") + } + fc1, fc2 := fileByCode(t, d, code1), fileByCode(t, d, code2) + + if fc1.ContentHash == nil || *fc1.ContentHash == "" { + t.Fatal("第一条记录未回填 content_hash") + } + if fc1.ContentHash == nil || fc2.ContentHash == nil || *fc1.ContentHash != *fc2.ContentHash { + t.Fatalf("两条记录哈希应一致: %v vs %v", fc1.ContentHash, fc2.ContentHash) + } + if fc1.UUIDFileName == nil || fc2.UUIDFileName == nil || *fc1.UUIDFileName != *fc2.UUIDFileName { + t.Fatalf("去重应复用同一 UUID 文件名: %v vs %v", fc1.UUIDFileName, fc2.UUIDFileName) + } + if fc1.SavePath() != fc2.SavePath() { + t.Fatal("去重应指向同一存储路径") + } + // 去重后对象应存在 + if !objectExists(t, d, fc1) { + t.Fatal("去重后对象应存在") + } + // 删除其一:对象保留(另一条仍引用);删除第二条:对象随之删除 + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodDelete, "/admin/file/delete", nil) + if err := d.deleteFileCode(c, &fc1); err != nil { + t.Fatalf("删除第一条: %v", err) + } + if !objectExists(t, d, fc2) { + t.Fatal("仍有引用时对象不应被删除") + } + if err := d.deleteFileCode(c, &fc2); err != nil { + t.Fatalf("删除第二条: %v", err) + } + store, _ := d.storeFor("local") + if ok, _ := store.FileExists(context.Background(), fc2.SavePath()); ok { + t.Fatal("最后一个引用删除后对象应被删除") + } + var cnt int64 + d.DB.Model(&model.FileCodes{}).Count(&cnt) + if cnt != 0 { + t.Fatalf("记录应全部删除,剩余 %d", cnt) + } +} + +// TestDedupDisabled 不去重:两记录各自独立对象。 +func TestDedupDisabled(t *testing.T) { + d := newPolicyTestDeps(t) + setKV(t, d, "dedup_enabled", "0") + content := []byte("no-dedup-content") + c1 := uploadOK(t, d, "x.txt", content) + c2 := uploadOK(t, d, "y.txt", content) + fc1, fc2 := fileByCode(t, d, c1), fileByCode(t, d, c2) + if fc1.ContentHash != nil && *fc1.ContentHash != "" { + t.Fatal("去重关闭时不应回填 content_hash") + } + if fc1.SavePath() == fc2.SavePath() { + t.Fatal("去重关闭时不应共享路径") + } +} + +// setKV 直写 KV 并应用到内存 Config(对齐生产链路:UpdateKV 落库 + ApplyKV 生效)。 +func setKV(t *testing.T, d *Deps, key, value string) { + t.Helper() + if err := d.Mgr.UpdateKV(context.Background(), map[string]any{key: value}); err != nil { + t.Fatalf("setKV %s: %v", key, err) + } + d.Cfg.ApplyKV(map[string]any{key: value}) +} + +// ============ 过期回收 ============ + +// makeFileRecord 直插一条文件分享记录(可指定过期形态)。 +func makeFileRecord(t *testing.T, d *Deps, code string, expiredAt *time.Time, expiredCount int, createdAt time.Time) model.FileCodes { + t.Helper() + name := "obj-" + code + ".bin" + dir := "share/data/test" + store, _ := d.storeFor("local") + if _, err := store.SaveFile(context.Background(), bytes.NewReader([]byte("recycle-body")), dir+"/"+name); err != nil { + t.Fatalf("写入测试对象: %v", err) + } + fc := model.FileCodes{ + Code: code, Prefix: "obj-" + code, Suffix: ".bin", + UUIDFileName: &name, FilePath: &dir, Size: 12, + ExpiredAt: expiredAt, ExpiredCount: expiredCount, + Engine: "local", + } + if err := d.DB.Create(&fc).Error; err != nil { + t.Fatal(err) + } + // 校正 created_at(GORM 自动填 now) + if err := d.DB.Model(&model.FileCodes{}).Where("id = ?", fc.ID).Update("created_at", createdAt).Error; err != nil { + t.Fatal(err) + } + fc.CreatedAt = createdAt + return fc +} + +// TestRecycleExpiredTimeAndCount 时间过期与次数耗尽都被回收。 +func TestRecycleExpiredTimeAndCount(t *testing.T) { + d := newPolicyTestDeps(t) + past := time.Now().Add(-time.Hour) + r1 := makeFileRecord(t, d, "RECYA", &past, -1, time.Now().Add(-2*time.Hour)) // 时间过期 + r2 := makeFileRecord(t, d, "RECYB", &past, 0, time.Now().Add(-2*time.Hour)) // 次数耗尽 + r3 := makeFileRecord(t, d, "RECYC", nil, 5, time.Now().Add(-2*time.Hour)) // 存活(无过期时间且有余量) + + removed := janitor.RecycleExpired(context.Background(), d.DB, d.Store, &janitor.Recycler{ + Enabled: func() bool { return true }, + RetentionDays: func() int64 { return 0 }, + }) + if removed != 2 { + t.Fatalf("应回收 2 条,实际 %d", removed) + } + for _, fc := range []model.FileCodes{r1, r2} { + var cnt int64 + d.DB.Model(&model.FileCodes{}).Where("code = ?", fc.Code).Count(&cnt) + if cnt != 0 { + t.Fatalf("%s 记录应被回收", fc.Code) + } + if objectExists(t, d, fc) { + t.Fatalf("%s 存储对象应被删除", fc.Code) + } + } + var cnt int64 + d.DB.Model(&model.FileCodes{}).Where("code = ?", r3.Code).Count(&cnt) + if cnt != 1 { + t.Fatal("存活分享不应被回收") + } + if !objectExists(t, d, r3) { + t.Fatal("存活分享对象应保留") + } +} + +// TestRecycleRetentionDays 留存期:创建超 retention_days 的分享被回收。 +func TestRecycleRetentionDays(t *testing.T) { + d := newPolicyTestDeps(t) + fresh := makeFileRecord(t, d, "RETEN1", nil, 5, time.Now()) // 新 + stale := makeFileRecord(t, d, "RETEN2", nil, 5, time.Now().Add(-48*time.Hour)) // 超 1 天留存 + removed := janitor.RecycleExpired(context.Background(), d.DB, d.Store, &janitor.Recycler{ + Enabled: func() bool { return true }, + RetentionDays: func() int64 { return 1 }, + }) + if removed != 1 { + t.Fatalf("应回收 1 条,实际 %d", removed) + } + var cnt int64 + d.DB.Model(&model.FileCodes{}).Where("code = ?", stale.Code).Count(&cnt) + if cnt != 0 { + t.Fatal("超留存期分享应被回收") + } + if !objectExists(t, d, fresh) { + t.Fatal("未超留存期的分享对象应保留") + } +} + +// TestLazyRecycleOnPickup 取件次数耗尽后再取 → 惰性回收(记录与对象删除)。 +func TestLazyRecycleOnPickup(t *testing.T) { + d := newPolicyTestDeps(t) + past := time.Now().Add(time.Hour) + fc := makeFileRecord(t, d, "LAZYa", &past, 1, time.Now()) + if !d.consumeUsage(invokeContext(t), &fc) { + // 第一次:count 1→0 成功 + t.Fatal("首次取件应成功") + } + if d.consumeUsage(invokeContext(t), &fc) { + t.Fatal("次数耗尽后取件应失败") + } + // 惰性回收是异步的:同步触发一次等价清理验证语义 + janitor.RecycleRecord(context.Background(), d.DB, d.Store, &fc, &janitor.Recycler{ + Enabled: func() bool { return true }, + RetentionDays: func() int64 { return 0 }, + }) + var cnt int64 + d.DB.Model(&model.FileCodes{}).Where("code = ?", fc.Code).Count(&cnt) + if cnt != 0 { + t.Fatal("惰性回收应删除记录") + } + if objectExists(t, d, fc) { + t.Fatal("惰性回收应删除对象") + } +} + +// invokeContext 构造带请求的测试 context。 +func invokeContext(t *testing.T) *gin.Context { + t.Helper() + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) + return c +} + +// ============ 防盗链 ============ + +// TestHotlinkMiddleware 防盗链中间件矩阵。 +func TestHotlinkMiddleware(t *testing.T) { + d := newPolicyTestDeps(t) + mw := hotlinkProbe(d) + req := func(referer, host string) int { + r := httptest.NewRequest(http.MethodGet, "/share/download", nil) + if referer != "" { + r.Header.Set("Referer", referer) + } + r.Host = host + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = r + mw(c) + if !c.IsAborted() { + return http.StatusOK + } + return w.Code + } + setKV(t, d, "hotlink_enabled", "0") + if got := req("https://evil.com/leech", "mysite.com"); got != http.StatusOK { + t.Fatalf("开关关闭时应全放行,got %d", got) + } + setKV(t, d, "hotlink_enabled", "1") + setKV(t, d, "hotlink_whitelist", "") + if got := req("https://evil.com/leech", "mysite.com"); got != http.StatusForbidden { + t.Fatalf("外站 Referer 应 403,got %d", got) + } + if got := req("https://mysite.com/page", "mysite.com"); got != http.StatusOK { + t.Fatalf("同源 Referer 应放行,got %d", got) + } + if got := req("", "mysite.com"); got != http.StatusOK { + t.Fatalf("空 Referer 应放行,got %d", got) + } + setKV(t, d, "hotlink_whitelist", "friend.org, *.cdn.net") + if got := req("https://friend.org/x", "mysite.com"); got != http.StatusOK { + t.Fatalf("白名单精确命中应放行,got %d", got) + } + if got := req("https://sub.cdn.net/x", "mysite.com"); got != http.StatusOK { + t.Fatalf("白名单通配命中应放行,got %d", got) + } + if got := req("https://other.net/x", "mysite.com"); got != http.StatusForbidden { + t.Fatalf("非白名单应 403,got %d", got) + } +} + +// hotlinkProbe 直接调用中间件构造器。 +func hotlinkProbe(d *Deps) gin.HandlerFunc { + return middleware.HotlinkMiddleware(d.Cfg) +} + +// ============ 直链下载 ============ + +// presignFake 包装本地引擎,仅覆盖 PresignGetURL 返回固定签名 URL。 +type presignFake struct { + storage.Storage + gotExpires int64 + url string +} + +func (p *presignFake) PresignGetURL(_ context.Context, _ string, expires int64) (string, error) { + p.gotExpires = expires + return p.url, nil +} + +// TestDirectDownloadRedirect 直链开启 + 引擎支持 → 302 到签名 URL,且 +// 有效期不超过分享剩余时效;直链关闭 → 走代理 200。 +func TestDirectDownloadRedirect(t *testing.T) { + d := newPolicyTestDeps(t) + // 桩包装原 local 引擎:302 不落盘,代理回落时仍能读到真实对象 + origLocal, err := d.storeFor("local") + if err != nil { + t.Fatal(err) + } + fake := &presignFake{Storage: origLocal, url: "https://s3.example.com/signed?X-Amz-Signature=abc"} + swapLocal(t, d, fake) + setKV(t, d, "direct_download", "1") + setKV(t, d, "direct_link_expire", "900") + + content := []byte("direct-link-body") + code := uploadOK(t, d, "d.txt", content) + + // 时间型分享剩余 5 分钟 → 直链有效期应被钳到 300s + exp := time.Now().Add(5 * time.Minute) + if err := d.DB.Model(&model.FileCodes{}).Where("code = ?", code). + Updates(map[string]any{"expired_at": exp, "expired_count": -1}).Error; err != nil { + t.Fatal(err) + } + fc := fileByCode(t, d, code) // 重新取(带上过期时间) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/share/download", nil) + d.serveFile(c, &fc) + if w.Code != http.StatusFound { + t.Fatalf("应 302 直链,实际 %d %s", w.Code, w.Body.String()) + } + if loc := w.Header().Get("Location"); loc != fake.url { + t.Fatalf("Location 应为签名 URL,实际 %q", loc) + } + if fake.gotExpires > 300 { + t.Fatalf("直链有效期应被分享剩余时效钳位(≤300),实际 %d", fake.gotExpires) + } + + // 关闭直链 → 回落代理 200 + setKV(t, d, "direct_download", "0") + fc2 := fileByCode(t, d, code) + w2 := httptest.NewRecorder() + c2, _ := gin.CreateTestContext(w2) + c2.Request = httptest.NewRequest(http.MethodGet, "/share/download", nil) + d.serveFile(c2, &fc2) + if w2.Code != http.StatusOK { + t.Fatalf("直链关闭应走代理 200,实际 %d", w2.Code) + } + if !strings.Contains(w2.Body.String(), "direct-link-body") { + t.Fatal("代理响应应包含文件内容") + } +} + +// TestDirectDownloadLocalFallback 本地引擎不支持直链 → 自动回落代理 200。 +func TestDirectDownloadLocalFallback(t *testing.T) { + d := newPolicyTestDeps(t) + setKV(t, d, "direct_download", "1") + code := uploadOK(t, d, "f.txt", []byte("local-fallback")) + fc := fileByCode(t, d, code) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/share/download", nil) + d.serveFile(c, &fc) + if w.Code != http.StatusOK { + t.Fatalf("本地引擎应回落代理 200,实际 %d", w.Code) + } +} + +// ============ 存储桩 ============ + +// swapLocal 用桩替换 local 引擎(重建 Manager,工厂恒返回桩)。 +func swapLocal(t *testing.T, d *Deps, fake storage.Storage) { + t.Helper() + factory := func(string) (storage.Storage, error) { return fake, nil } + d.Store = storage.NewManager("local", fake, factory) +} diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 0b8fef4..10bae07 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -52,7 +52,8 @@ func Register(r *gin.Engine, d *Deps) { share.POST("/metadata", d.Limiter.RequireRateLimit(middleware.LimitMeta), d.shareMetadataPost) share.GET("/select", d.shareSelect) share.POST("/select", d.shareSelectPost) - share.GET("/download", d.shareDownload) + // 26.9:下载防盗链(动态开关,空 Referer 放行) + share.GET("/download", middleware.HotlinkMiddleware(d.Cfg), d.shareDownload) } // —— 分片上传 —— diff --git a/server/internal/api/share.go b/server/internal/api/share.go index e61affe..8aa450f 100644 --- a/server/internal/api/share.go +++ b/server/internal/api/share.go @@ -1,6 +1,7 @@ package api import ( + "context" "errors" "fmt" "io" @@ -13,6 +14,7 @@ import ( "gorm.io/gorm" "fileshare/internal/audit" + "fileshare/internal/janitor" "fileshare/internal/middleware" "fileshare/internal/model" "fileshare/internal/response" @@ -61,9 +63,22 @@ func (d *Deps) consumeUsage(c *gin.Context, fc *model.FileCodes) bool { "expired_count": gorm.Expr("CASE WHEN expired_count > 0 THEN expired_count - 1 ELSE expired_count END"), "used_count": gorm.Expr("used_count + 1"), }) + if res.Error == nil && res.RowsAffected == 0 { + // 26.9:取件时惰性回收——记录已过期/次数耗尽,后台异步删除记录与对象 + // (定时回收循环之外的"更好检查方法":访问即发现即回收,不等下一轮扫描) + d.recycleAsync(fc) + } return res.Error == nil && res.RowsAffected > 0 } +// recycleAsync 异步回收单条过期分享(不阻塞请求;记录不存在时为幂等空操作)。 +func (d *Deps) recycleAsync(fc *model.FileCodes) { + go janitor.RecycleRecord(context.Background(), d.DB, d.Store, fc, &janitor.Recycler{ + Enabled: d.Cfg.RecycleEnabled, + RetentionDays: d.Cfg.RetentionDays, + }) +} + // fileSavePath 拼接分享记录的存储相对路径(file_path/uuid_file_name)。 func fileSavePath(fc *model.FileCodes) string { dir := "" @@ -263,6 +278,9 @@ func (d *Deps) shareFile(c *gin.Context) { err = mapCodeConflict(err) // 26.9 // 记录创建失败:清理已落盘文件 _ = d.Store.DeleteFile(ctx, savePath) + } else { + // 26.9:SHA512 内容去重(命中则复用旧对象并删除本次副本) + d.applyDedup(ctx, d.Store, savePath, &fc) } } else { // 保存失败:尽力清理半写文件 @@ -553,6 +571,29 @@ func (d *Deps) serveFile(c *gin.Context, fc *model.FileCodes) { return } + // 26.9:对象存储直链下载——引擎支持 + 直链开关开启时 302 到限时预签名 URL, + // 文件字节不再经过本服务器(带宽成本转嫁对象存储)。签名有效期取 + // direct_link_expire 与分享剩余时效的较小值;直链不可用静默回落代理。 + if d.Cfg.DirectDownload() { + expires := d.Cfg.DirectLinkExpire() + if fc.ExpiredAt != nil { + if remain := int64(time.Until(*fc.ExpiredAt).Seconds()); remain > 0 && remain < expires { + expires = remain + } + } + if url, err := store.PresignGetURL(ctx, savePath, expires); err == nil && url != "" { + auditUploadEntry(c, fc.Code, name, fc.Size, fc.Size) + middleware.AuditSet(c, func(e *audit.Entry) { + e.TransferredBytes = fc.Size + e.SizeBytes = fc.Size + }) + auditRecordSuccess(c, d.AuditSvc) + c.Redirect(http.StatusFound, url) + return + } + // 直链不可用:继续走代理下载(不中断取件) + } + // 先 Stat 拿总大小(用于审计与 Range 后缀解析) var total int64 = -1 if meta, err := store.Stat(ctx, savePath); err == nil && meta != nil { diff --git a/server/internal/config/config.go b/server/internal/config/config.go index fbf7b9f..d16f492 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -67,6 +67,15 @@ func defaults() map[string]any { "site_domain": "", "upload_rate": "0", "download_rate": "0", + // 26.9 回收与下载安全 + "recycle_enabled": 1, + "recycle_interval": 1800, + "retention_days": 0, + "dedup_enabled": 1, + "hotlink_enabled": 0, + "hotlink_whitelist": "", + "direct_download": 1, + "direct_link_expire": 900, // 站点信息 "name": "文件快传", "site_name": "文件快传", // 新增:管理端可自定义 @@ -298,6 +307,70 @@ func (c *Config) DownloadRate() int { return v } +// —— 26.9 回收与下载安全 —— + +// RecycleEnabled 过期自动回收开关。 +func (c *Config) RecycleEnabled() bool { return c.GetInt(KeyRecycleEnabled) == 1 } + +// RecycleInterval 回收扫描间隔(秒,钳位 60~86400)。 +func (c *Config) RecycleInterval() int64 { + v := c.GetInt64(KeyRecycleInterval) + if v < RecycleIntervalMin { + return RecycleIntervalMin + } + if v > RecycleIntervalMax { + return RecycleIntervalMax + } + return v +} + +// RetentionDays 全局最长存储时长(天,0=不限制)。 +func (c *Config) RetentionDays() int64 { + v := c.GetInt64(KeyRetentionDays) + if v < 0 { + return 0 + } + return v +} + +// DedupEnabled SHA512 内容去重开关。 +func (c *Config) DedupEnabled() bool { return c.GetInt(KeyDedupEnabled) == 1 } + +// HotlinkEnabled 下载防盗链开关。 +func (c *Config) HotlinkEnabled() bool { return c.GetInt(KeyHotlinkEnabled) == 1 } + +// HotlinkWhitelist 防盗链 Referer 白名单(逗号分隔域名,返回小写去空白切片)。 +func (c *Config) HotlinkWhitelist() []string { + raw := c.GetString(KeyHotlinkWhitelist) + if strings.TrimSpace(raw) == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.ToLower(strings.TrimSpace(p)) + if p != "" { + out = append(out, p) + } + } + return out +} + +// DirectDownload 对象存储直链下载开关。 +func (c *Config) DirectDownload() bool { return c.GetInt(KeyDirectDownload) == 1 } + +// DirectLinkExpire 直链有效期(秒,钳位 60~3600)。 +func (c *Config) DirectLinkExpire() int64 { + v := c.GetInt64(KeyDirectLinkExpire) + if v < DirectLinkExpireMin { + return DirectLinkExpireMin + } + if v > DirectLinkExpireMax { + return DirectLinkExpireMax + } + return v +} + // SiteDomain 站点对外域名(26.9):空=分享链接用当前访问地址。 func (c *Config) SiteDomain() string { return strings.TrimRight(strings.TrimSpace(c.GetString("site_domain")), "/") diff --git a/server/internal/config/schema.go b/server/internal/config/schema.go index 497b76b..b549a14 100644 --- a/server/internal/config/schema.go +++ b/server/internal/config/schema.go @@ -35,6 +35,15 @@ const ( KeySiteDomain = "site_domain" // 站点对外域名(空=分享链接用当前地址) KeyUploadRate = "upload_rate" // 上传带宽字节/秒(0=不限速) KeyDownloadRate = "download_rate" // 下载带宽字节/秒(0=不限速) + // —— 26.9 回收与下载安全 —— + KeyRecycleEnabled = "recycle_enabled" // 过期分享自动回收开关(1 开 / 0 关) + KeyRecycleInterval = "recycle_interval" // 回收扫描间隔(秒,60~86400) + KeyRetentionDays = "retention_days" // 全局最长存储时长(天,0=不限制) + KeyDedupEnabled = "dedup_enabled" // SHA512 内容去重开关 + KeyHotlinkEnabled = "hotlink_enabled" // 下载防盗链开关 + KeyHotlinkWhitelist = "hotlink_whitelist" // 防盗链 Referer 白名单(逗号分隔域名) + KeyDirectDownload = "direct_download" // 对象存储直链下载开关(仅 S3 引擎生效) + KeyDirectLinkExpire = "direct_link_expire" // 直链有效期(秒,60~3600) ) // —— 取值边界(管理端保存与 API 校验用)—— @@ -54,6 +63,16 @@ const ( // 通知标题/内容最大长度。 NotifyTitleMaxLen = 128 NotifyContentMaxLen = 2000 + // 回收扫描间隔边界(秒):最快 1 分钟一轮,最慢 1 天一轮。 + RecycleIntervalMin = 60 + RecycleIntervalMax = 86400 + // 全局存储时长上限(天):0=不限制,最长 10 年。 + RetentionDaysMax = 3650 + // 防盗链白名单最大长度。 + HotlinkWhitelistMaxLen = 2048 + // 直链有效期边界(秒)。 + DirectLinkExpireMin = 60 + DirectLinkExpireMax = 3600 ) // KVSchemaEntry 配置键元数据:类型 / 默认值 / 说明,供管理端 UI 与文档生成。 @@ -97,5 +116,14 @@ func KVSchema() []KVSchemaEntry { {KeySiteDomain, "string", "", 0, 256, "站点对外域名(http(s)://host[:port],不带路径;空=分享链接用当前访问地址)"}, {KeyUploadRate, "int64", "0", 0, 1073741824, "上传带宽字节/秒(0=不限速;范围 0~1 GiB/s)"}, {KeyDownloadRate, "int64", "0", 0, 1073741824, "下载带宽字节/秒(0=不限速;范围 0~1 GiB/s)"}, + // —— 26.9 回收与下载安全 —— + {KeyRecycleEnabled, "int", 1, 0, 1, "过期分享自动回收开关:1 定时清理过期记录与存储对象 / 0 关闭"}, + {KeyRecycleInterval, "int64", int64(1800), RecycleIntervalMin, RecycleIntervalMax, "回收扫描间隔(秒;范围 60~86400,默认 30 分钟)"}, + {KeyRetentionDays, "int64", int64(0), 0, RetentionDaysMax, "全局最长存储时长(天):上传超过该天数的分享将被回收,0=不限制"}, + {KeyDedupEnabled, "int", 1, 0, 1, "SHA512 内容去重:相同文件仅存储一份(多分享引用同一对象)"}, + {KeyHotlinkEnabled, "int", 0, 0, 1, "下载防盗链:校验 Referer 白名单(空 Referer 放行)"}, + {KeyHotlinkWhitelist, "string", "", 0, HotlinkWhitelistMaxLen, "防盗链白名单:逗号分隔域名(如 a.com,b.org;空=仅本站域名)"}, + {KeyDirectDownload, "int", 1, 0, 1, "对象存储直链下载:S3 引擎时 302 跳转到限时预签名 URL(不走服务器代理)"}, + {KeyDirectLinkExpire, "int64", int64(900), DirectLinkExpireMin, DirectLinkExpireMax, "直链有效期(秒;范围 60~3600,默认 15 分钟;不超过分享剩余时效)"}, } } diff --git a/server/internal/janitor/janitor.go b/server/internal/janitor/janitor.go index 2b235dc..80f0f25 100644 --- a/server/internal/janitor/janitor.go +++ b/server/internal/janitor/janitor.go @@ -1,7 +1,6 @@ -// Package janitor 后台清理循环(安全审计 M5): -// 回收过期容量预留、超时未完成的上传会话(含其分片对象)与过期预签名会话 -// (direct 模式残留对象一并删除)。此前这些资源仅在同 token 复用/显式取消时 -// 释放,恶意 init 可长期占用容量预留或累积垃圾数据。 +// Package janitor 后台清理循环(安全审计 M5 / 26.9 过期回收): +// 回收过期容量预留、超时未完成的上传会话(含其分片对象)、过期预签名会话 +// (direct 模式残留对象一并删除),以及过期/超留存期的分享记录与存储对象。 package janitor import ( @@ -23,8 +22,22 @@ const chunkSessionMaxAge = 24 * time.Hour // presignGrace 过期预签名会话的宽限时长(到点即删,避免与在途 confirm 竞争)。 const presignGrace = time.Hour +// 回收批次上限:单轮每类最多处理 200 条,避免大清理阻塞下一 tick。 +const recycleBatch = 200 + +// Recycler 回收配置(26.9):由 API 层注入(管理端 KV 动态读取)。 +type Recycler struct { + // Enabled 过期自动回收开关。 + Enabled func() bool + // RetentionDays 全局最长存储时长(天,0=不限制)。 + RetentionDays func() int64 + // OnRecycled 回收成功后的回调(审计可选),参数:码、文件名、字节数。 + OnRecycled func(code, name string, size int64) +} + // Start 启动周期清理循环;ctx 取消时退出。 -func Start(ctx context.Context, db *gorm.DB, store *storage.Manager, interval time.Duration) { +// interval 为兜底默认间隔;recycler 非 nil 时按 RecycleInterval 动态取间隔。 +func Start(ctx context.Context, db *gorm.DB, store *storage.Manager, interval time.Duration, recycler *Recycler) { go func() { ticker := time.NewTicker(interval) defer ticker.Stop() @@ -34,12 +47,15 @@ func Start(ctx context.Context, db *gorm.DB, store *storage.Manager, interval ti return case <-ticker.C: Run(ctx, db, store) + if recycler != nil && recycler.Enabled != nil && recycler.Enabled() { + RecycleExpired(ctx, db, store, recycler) + } } } }() } -// Run 执行一轮清理;单项失败仅记日志,不影响其他项。 +// Run 执行一轮基础设施清理;单项失败仅记日志,不影响其他项。 func Run(ctx context.Context, db *gorm.DB, store *storage.Manager) { now := time.Now() cleanExpiredReservations(ctx, db, now) @@ -122,3 +138,102 @@ func cleanExpiredPresignSessions(ctx context.Context, db *gorm.DB, store *storag log.Printf("[janitor] 已清理过期预签名会话 upload_id=%s mode=%s", s.UploadID, s.Mode) } } + +// ============ 26.9:过期分享回收 ============ + +// RecycleExpired 回收过期/超存储时长的分享记录与存储对象: +// - 时间过期:expired_count<0 且 expired_at 已过; +// - 次数耗尽:expired_count>=0 且 <=0; +// - 超留存期:retentionDays>0 且 created_at 早于 now-retentionDays; +// - 内容去重开启时同一存储对象可能被多条分享引用,删除前做引用计数 +// (按 ContentHash/Engine/UUIDFileName 统计),仅删除最后一个引用。 +// +// 返回本轮回收的分享数。由 janitor 定时循环与管理端手动触发共用。 +func RecycleExpired(ctx context.Context, db *gorm.DB, store *storage.Manager, r *Recycler) int { + now := time.Now() + q := db.WithContext(ctx).Model(&model.FileCodes{}). + Where("(expired_count < 0 AND expired_at IS NOT NULL AND expired_at < ?)"+ + " OR (expired_count >= 0 AND expired_count <= 0)", now) + if r.RetentionDays != nil && r.RetentionDays() > 0 { + cutoff := now.AddDate(0, 0, -int(r.RetentionDays())) + q = q.Or("created_at < ?", cutoff) + } + var ids []int64 + if err := q.Limit(recycleBatch).Pluck("id", &ids).Error; err != nil { + log.Printf("[recycle] 查询过期分享失败: %v", err) + return 0 + } + if len(ids) == 0 { + return 0 + } + n := 0 + for _, id := range ids { + var fc model.FileCodes + if err := db.WithContext(ctx).First(&fc, id).Error; err != nil { + continue + } + // 复核:Expired 语义(避免查询窗口内被取件续期) + if !fc.Expired(now) { + if r.RetentionDays == nil || r.RetentionDays() <= 0 || fc.CreatedAt.After(now.AddDate(0, 0, -int(r.RetentionDays()))) { + continue + } + } + recycleOne(ctx, db, store, &fc, r) + n++ + } + if n > 0 { + log.Printf("[recycle] 本轮回收 %d 条过期分享", n) + } + return n +} + +// RecycleRecord 回收单条分享(取件惰性回收入口):删除记录与存储对象(带引用计数)。 +// 记录不存在时为幂等空操作。 +func RecycleRecord(ctx context.Context, db *gorm.DB, store *storage.Manager, fc *model.FileCodes, r *Recycler) { + // 存在性复核:可能已被定时循环/其他请求回收 + var cur model.FileCodes + if err := db.WithContext(ctx).Where("id = ?", fc.ID).First(&cur).Error; err != nil { + return + } + recycleOne(ctx, db, store, &cur, r) +} + +// recycleOne 删除单条分享记录及其存储对象(带去重引用计数)。 +func recycleOne(ctx context.Context, db *gorm.DB, store *storage.Manager, fc *model.FileCodes, r *Recycler) { + if fc.Text == nil && fc.UUIDFileName != nil { + engine, err := engineFor(store, fc.Engine) + if err != nil { + log.Printf("[recycle] 引擎不可用 code=%s: %v", fc.Code, err) + // 引擎不可用也删记录,避免永久堆积;对象留给对账巡检 + } else { + // 去重引用计数:同 ContentHash+Engine+UUIDFileName 的其他分享还在,则不删对象 + if fc.ContentHash != nil && *fc.ContentHash != "" { + var cnt int64 + _ = db.WithContext(ctx).Model(&model.FileCodes{}). + Where("content_hash = ? AND engine = ? AND uuid_file_name = ? AND id <> ?", + *fc.ContentHash, fc.Engine, *fc.UUIDFileName, fc.ID). + Count(&cnt).Error + if cnt == 0 && fc.SavePath() != "" { + delFile(ctx, engine, fc.SavePath(), fc.Code) + } + } else if fc.SavePath() != "" { + delFile(ctx, engine, fc.SavePath(), fc.Code) + } + } + } + if err := db.WithContext(ctx).Delete(fc).Error; err != nil { + log.Printf("[recycle] 删除分享记录失败 code=%s: %v", fc.Code, err) + return + } + if r != nil && r.OnRecycled != nil { + r.OnRecycled(fc.Code, fc.Prefix+fc.Suffix, fc.Size) + } +} + +// delFile 删除存储对象,NotFound 视为成功(幂等)。 +func delFile(ctx context.Context, engine storage.Storage, savePath, code string) { + if err := engine.DeleteFile(ctx, savePath); err != nil && + !errors.Is(err, storage.ErrNotFound) && !errors.Is(err, storage.ErrInvalidPath) { + log.Printf("[recycle] 删除存储对象失败 code=%s path=%s: %v", code, savePath, err) + } +} diff --git a/server/internal/middleware/hotlink.go b/server/internal/middleware/hotlink.go new file mode 100644 index 0000000..0012d95 --- /dev/null +++ b/server/internal/middleware/hotlink.go @@ -0,0 +1,68 @@ +// Package middleware — hotlink.go 下载防盗链(26.9): +// 校验 Referer 白名单。规则: +// - Referer 为空(直接访问/curl/浏览器地址栏):放行(不误伤正常取件); +// - Referer 与当前请求 Host 同源:放行; +// - Referer 主机命中管理端白名单(hotlink_whitelist,逗号分隔域名,支持 *.example.com 通配):放行; +// - 其余一律 403。 +package middleware + +import ( + "net/url" + "strings" + + "github.com/gin-gonic/gin" + + "fileshare/internal/config" +) + +// HotlinkMiddleware 返回防盗链中间件;cfg 动态读取开关与白名单(管理端改后立即生效)。 +func HotlinkMiddleware(cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + if !cfg.HotlinkEnabled() { + c.Next() + return + } + ref := c.GetHeader("Referer") + if ref == "" { + c.Next() // 空 Referer 放行 + return + } + u, err := url.Parse(ref) + if err != nil || u.Host == "" { + c.Next() // 非法 Referer 视同空,放行(避免误伤) + return + } + if strings.EqualFold(u.Host, c.Request.Host) { + c.Next() // 同源放行 + return + } + if hostAllowed(u.Host, cfg.HotlinkWhitelist()) { + c.Next() + return + } + c.AbortWithStatusJSON(403, gin.H{"message": "防盗链:外部站点引用不允许访问该资源"}) + } +} + +// hostAllowed 判断主机是否命中白名单(精确匹配或 *. 通配后缀匹配)。 +// 白名单条目可带端口;通配写作 .example.com 或 *.example.com。 +func hostAllowed(host string, whitelist []string) bool { + if len(whitelist) == 0 { + return false + } + host = strings.ToLower(host) + for _, w := range whitelist { + w = strings.ToLower(strings.TrimSpace(w)) + w = strings.TrimPrefix(w, "*") // *.example.com → .example.com + if w == "" { + continue + } + if host == strings.TrimPrefix(w, ".") { + return true + } + if strings.HasSuffix(host, w) && strings.HasPrefix(w, ".") { + return true + } + } + return false +} diff --git a/server/internal/model/model.go b/server/internal/model/model.go index 8409595..48c7bb2 100644 --- a/server/internal/model/model.go +++ b/server/internal/model/model.go @@ -3,6 +3,7 @@ package model import ( + "strings" "time" "gorm.io/gorm" @@ -22,7 +23,8 @@ type FileCodes struct { ExpiredCount int `gorm:"default:0" json:"expired_count"` // 剩余可取次数;<0 表示按时间过期 UsedCount int `gorm:"default:0" json:"used_count"` // 已取次数 CreatedAt time.Time `json:"created_at"` - FileHash *string `gorm:"size:64" json:"file_hash"` // SHA256 + FileHash *string `gorm:"size:64" json:"file_hash"` // SHA256 + ContentHash *string `gorm:"size:128;index" json:"content_hash"` // 26.9:SHA512(内容去重;同哈希分享复用同一存储对象) IsChunked bool `gorm:"default:false" json:"is_chunked"` UploadID *string `gorm:"size:36" json:"upload_id"` // 分片上传会话 ID Engine string `gorm:"size:16;default:''" json:"engine"` // 归属存储引擎(26.9:local|s3|webdav;空=历史数据按当前引擎取) @@ -31,6 +33,23 @@ type FileCodes struct { // TableName 表名。 func (FileCodes) TableName() string { return "file_codes" } +// SavePath 存储侧相对路径(file_path/uuid_file_name 拼接;对齐 api 层 fileSavePath)。 +// 26.9:上移到模型层,供 api 与 janitor 共用(去重引用计数与回收删除都需要)。 +func (f *FileCodes) SavePath() string { + dir := "" + if f.FilePath != nil { + dir = strings.Trim(*f.FilePath, "/") + } + name := "" + if f.UUIDFileName != nil { + name = *f.UUIDFileName + } + if dir == "" { + return name + } + return dir + "/" + name +} + // Expired 判断是否已过期(对齐参考语义:expired_count<0 按时间,否则按次数)。 func (f *FileCodes) Expired(now time.Time) bool { if f.ExpiredAt == nil { diff --git a/server/web/dist/index.html b/server/web/dist/index.html index 731c869..9564206 100644 --- a/server/web/dist/index.html +++ b/server/web/dist/index.html @@ -27,7 +27,7 @@ background: #0b0f1a; } - +
diff --git a/web/src/api/share.ts b/web/src/api/share.ts index 3af1580..46d5acc 100644 --- a/web/src/api/share.ts +++ b/web/src/api/share.ts @@ -49,6 +49,7 @@ export async function getMetadata(code: string): Promise+ {{ mediaKind === 'image' ? t('pickup.previewImage') : t('pickup.previewAudio') }} +
+{{ t('pickup.downloading', { percent: downloadPercent }) }}
diff --git a/web/src/views/admin/SettingsView.vue b/web/src/views/admin/SettingsView.vue index 36196ba..9fc7146 100644 --- a/web/src/views/admin/SettingsView.vue +++ b/web/src/views/admin/SettingsView.vue @@ -43,6 +43,9 @@ const BACKGROUND_URL_MAX = 2048 const UPLOAD_COUNT_MAX = 10000 const UPLOAD_MINUTE_MAX = 1440 const RATE_MAX_MB = 1024 // 1 GiB/s 上限(与后端 KVSchema 1<<30 字节对齐) +// 26.9:回收与下载安全边界(与后端 KVSchema 一致) +const RETENTION_DAYS_MAX = 3650 +const HOTLINK_WHITELIST_MAX = 2048 const form = reactive({ site_name: '', @@ -65,6 +68,15 @@ const form = reactive({ // 26.9:上下行带宽(前端友好单位 = MB/s;提交时换算为字节/秒) uploadRate: 0, downloadRate: 0, + // 26.9 回收与下载安全(interval/expire 前端友好单位 = 分钟;提交换算秒) + recycle_enabled: true, + recycle_interval_min: 30, + retention_days: 0, + dedup_enabled: true, + direct_download: true, + direct_link_expire_min: 15, + hotlink_enabled: false, + hotlink_whitelist: '', }) // —— v3:友好单位状态(canonical 单位仍为秒/字节,换算在前端)—— @@ -169,6 +181,15 @@ async function load(): Promise{{ t('admin.settings.recycleHint') }}
+{{ t('admin.settings.recycleEnabledHint') }}
+{{ t('admin.settings.recycleIntervalHint') }}
+{{ t('admin.settings.retentionDaysHint') }}
+{{ t('admin.settings.dedupEnabledHint') }}
+{{ t('admin.settings.directDownloadHint') }}
+{{ t('admin.settings.directLinkExpireHint') }}
+{{ t('admin.settings.hotlinkEnabledHint') }}
+{{ t('admin.settings.hotlinkWhitelistHint') }}
+