26.9:直链下载 + 过期回收 + SHA512 去重 + 防盗链 + 媒体预览 + 文件夹上传提示
CI 测试 / go vet + go test (push) Failing after 5s
CI 测试 / go vet + go test (push) Failing after 5s
- 对象存储直链:S3 引擎 302 到限时预签名 URL(有效期钳位分享剩余时效),失败自动回落代理 - 过期回收:janitor 定时扫描 + 取件惰性回收 + 管理端手动触发(POST /admin/recycle/run), retention_days 最长存储时长;删除走引用计数(去重对象安全) - SHA512 内容去重:三条上传链路落库后计算哈希,命中即复用旧对象并删除本次副本 - 下载防盗链:Referer 白名单(同源/空 Referer/通配域名放行),挂 /share/download - 取件页图片/音频内联预览(下载地址直连,加载失败回退下载按钮) - 文件夹上传:拖拽目录明确提示"建议压缩后上传"(webkitGetAsEntry 探测) - 管理端设置卡「回收与下载安全」8 个新配置键(KVSchema + configKeys + UI + i18n) - 前端产物重建并同步 server/web/dist 与 web-embed - 文档:10-config 配置表、03-file-share 直链/防盗链/文件夹章节、07-admin 回收端点、openapi
This commit is contained in:
@@ -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{}
|
||||
|
||||
@@ -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 {
|
||||
// 成功:清理分片与记录(走归属引擎)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// —— 分片上传 ——
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user