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,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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user