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:
@@ -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` 会剥离文件名中的路径分隔符,多级路径无法成体保存。
|
||||
|
||||
@@ -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`(引用计数开关)。
|
||||
|
||||
@@ -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 | 登录失败计数 |
|
||||
|
||||
|
||||
@@ -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: [管理后台]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")), "/")
|
||||
|
||||
@@ -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 分钟;不超过分享剩余时效)"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Vendored
+1
-1
@@ -27,7 +27,7 @@
|
||||
background: #0b0f1a;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-DuYxbxXK.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BumeFNdu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CtyCxWf5.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function getMetadata(code: string): Promise<ShareMetadata> {
|
||||
remainingDownloads:
|
||||
(pick<number>(raw, ['remaining_downloads', 'remainingDownloads', 'expired_count', 'expiredCount']) as number) ??
|
||||
null,
|
||||
downloadUrl: (pick<string>(raw, ['download_url', 'downloadUrl']) as string) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface ShareMetadata {
|
||||
expiredCount: number | null
|
||||
usedCount: number
|
||||
remainingDownloads: number | null
|
||||
/** 26.9:select 接口返回的下载地址(代理路径或 S3 预签名直链) */
|
||||
downloadUrl?: string | null
|
||||
}
|
||||
|
||||
export interface ShareTextResult {
|
||||
|
||||
@@ -40,6 +40,12 @@ const acceptHint = computed(() => {
|
||||
function accept(f: File | null | undefined): void {
|
||||
error.value = ''
|
||||
if (!f) return
|
||||
// 26.9:拒绝文件夹条目(webkitRelativePath 非空 = 来自文件夹选择)
|
||||
if ((f as File & { webkitRelativePath?: string }).webkitRelativePath) {
|
||||
error.value = t('drop.folderHint')
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
if (props.maxSize && f.size > props.maxSize) {
|
||||
error.value = t('drop.tooLarge', { size: formatBytes(f.size), limit: formatBytes(props.maxSize) })
|
||||
emit('update:modelValue', null)
|
||||
@@ -48,9 +54,25 @@ function accept(f: File | null | undefined): void {
|
||||
emit('update:modelValue', f)
|
||||
}
|
||||
|
||||
/** 26.9:拖拽条目里是否夹带目录(dataTransfer.files 对目录为空,须用 entries 探测) */
|
||||
function hasDirectory(items: DataTransferItemList | null | undefined): boolean {
|
||||
if (!items) return false
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const entry = items[i]?.webkitGetAsEntry?.()
|
||||
if (entry?.isDirectory) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent): void {
|
||||
dragover.value = false
|
||||
if (props.disabled) return
|
||||
// 26.9:拖入目录 → 明确提示"建议压缩后上传"(替代此前的静默忽略)
|
||||
if (hasDirectory(e.dataTransfer?.items)) {
|
||||
error.value = t('drop.folderHint')
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
accept(e.dataTransfer?.files?.[0])
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ export default {
|
||||
close: 'Close',
|
||||
actions: 'Actions',
|
||||
all: 'All',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
days: 'days',
|
||||
query: 'Query',
|
||||
reset: 'Reset',
|
||||
previousPage: 'Previous',
|
||||
@@ -144,6 +147,8 @@ export default {
|
||||
loadingText: 'Fetching content…',
|
||||
copyContent: 'Copy content',
|
||||
downloadTxt: 'Download as .txt',
|
||||
previewImage: 'Image preview',
|
||||
previewAudio: 'Audio playback',
|
||||
downloaded: 'Download complete',
|
||||
downloadFailed: 'Download failed, please retry',
|
||||
copied: 'Content copied',
|
||||
@@ -170,6 +175,7 @@ export default {
|
||||
remove: 'Remove',
|
||||
tooLarge: 'File size {size} exceeds the limit {limit}',
|
||||
typeHint: 'Allowed types: {types}',
|
||||
folderHint: 'Folder upload is not supported. Please compress it into an archive first.',
|
||||
},
|
||||
docs: {
|
||||
searchPlaceholder: 'Search documentation…',
|
||||
@@ -362,6 +368,24 @@ export default {
|
||||
uploadMinuteHint: 'Min 1, max {max}',
|
||||
uploadRate: 'Upload bandwidth (optional)',
|
||||
uploadRateHint: '0 = unlimited; MB/s; range 0~1024. Live-effective (admin reads latest KV each request)',
|
||||
sectionRecycle: 'Recycle & Download Safety',
|
||||
recycleHint: 'Expired-share recycling, retention limit, content dedup, direct download and hotlink protection',
|
||||
recycleEnabled: 'Auto-recycle expired',
|
||||
recycleEnabledHint: 'Periodically delete expired/exhausted shares together with their stored objects',
|
||||
recycleInterval: 'Recycle scan interval',
|
||||
recycleIntervalHint: '1~1440 minutes; accessing an expired share also triggers immediate recycling',
|
||||
retentionDays: 'Max retention (days)',
|
||||
retentionDaysHint: 'Shares older than this many days are recycled automatically; 0 = unlimited',
|
||||
dedupEnabled: 'SHA512 dedup',
|
||||
dedupEnabledHint: 'Identical files are stored once (multiple shares reference the same object)',
|
||||
directDownload: 'Direct download',
|
||||
directDownloadHint: '302 redirect to a presigned URL on S3-like engines; bytes bypass this server',
|
||||
directLinkExpire: 'Direct link TTL',
|
||||
directLinkExpireHint: '1~60 minutes (never longer than the share remains valid)',
|
||||
hotlinkEnabled: 'Hotlink protection',
|
||||
hotlinkEnabledHint: 'Validates Referer against a whitelist; empty Referer (direct visit) is allowed',
|
||||
hotlinkWhitelist: 'Hotlink whitelist',
|
||||
hotlinkWhitelistHint: 'Comma-separated domains, supports *.example.com; empty = same-site only',
|
||||
downloadRate: 'Download bandwidth (optional)',
|
||||
downloadRateHint: '0 = unlimited; MB/s; range 0~1024. S3 presigned direct upload cannot be throttled server-side',
|
||||
// —— v3: friendly units + storage engine ——
|
||||
|
||||
@@ -47,6 +47,9 @@ export default {
|
||||
close: '关闭',
|
||||
actions: '操作',
|
||||
all: '全部',
|
||||
enabled: '开启',
|
||||
disabled: '关闭',
|
||||
days: '天',
|
||||
query: '查询',
|
||||
reset: '重置',
|
||||
previousPage: '上一页',
|
||||
@@ -140,6 +143,8 @@ export default {
|
||||
loadingText: '正在获取内容…',
|
||||
copyContent: '复制内容',
|
||||
downloadTxt: '下载为 .txt',
|
||||
previewImage: '图片预览',
|
||||
previewAudio: '音频播放',
|
||||
downloaded: '下载完成',
|
||||
downloadFailed: '下载失败,请重试',
|
||||
copied: '内容已复制',
|
||||
@@ -166,6 +171,7 @@ export default {
|
||||
remove: '移除',
|
||||
tooLarge: '文件大小 {size} 超过限制 {limit}',
|
||||
typeHint: '仅支持 {types}',
|
||||
folderHint: '暂不支持文件夹上传,建议压缩后上传',
|
||||
},
|
||||
docs: {
|
||||
searchPlaceholder: '检索文档内容…',
|
||||
@@ -356,6 +362,24 @@ export default {
|
||||
uploadMinuteHint: '最小 1,最大 {max}',
|
||||
uploadRate: '上行带宽(可选)',
|
||||
uploadRateHint: '0 = 不限速;单位 MB/s;范围 0~1024。修改后立即生效(管理端读最新 KV)',
|
||||
sectionRecycle: '回收与下载安全',
|
||||
recycleHint: '过期分享自动回收、存储时长上限、内容去重、直链下载与防盗链',
|
||||
recycleEnabled: '过期自动回收',
|
||||
recycleEnabledHint: '开启后定时清理已过期/次数耗尽的分享(含其存储对象)',
|
||||
recycleInterval: '回收扫描间隔',
|
||||
recycleIntervalHint: '范围 1~1440 分钟;取件时发现过期也会立即触发回收',
|
||||
retentionDays: '最长存储时长',
|
||||
retentionDaysHint: '上传超过该天数的分享将被自动回收;0 = 不限制',
|
||||
dedupEnabled: 'SHA512 内容去重',
|
||||
dedupEnabledHint: '相同内容的文件仅存储一份(多分享引用同一对象)',
|
||||
directDownload: '直链下载',
|
||||
directDownloadHint: 'S3 等对象存储引擎时 302 跳转到限时直链,文件不经过本站带宽',
|
||||
directLinkExpire: '直链有效期',
|
||||
directLinkExpireHint: '范围 1~60 分钟(不超过分享剩余时效)',
|
||||
hotlinkEnabled: '下载防盗链',
|
||||
hotlinkEnabledHint: '校验 Referer 白名单;空 Referer(直接访问)放行',
|
||||
hotlinkWhitelist: '防盗链白名单',
|
||||
hotlinkWhitelistHint: '逗号分隔域名,支持 *.example.com;空 = 仅本站域名放行',
|
||||
downloadRate: '下行带宽(可选)',
|
||||
downloadRateHint: '0 = 不限速;单位 MB/s;范围 0~1024。S3 预签名直传(客户端→S3)无法限速',
|
||||
// —— v3:单位友好化 + 存储引擎 ——
|
||||
|
||||
@@ -107,6 +107,35 @@ const remaining = computed(() => {
|
||||
if (m.remainingDownloads === null || m.remainingDownloads === undefined) return t('pickup.remainingUnlimited')
|
||||
return m.remainingDownloads < 0 ? t('pickup.remainingUnlimited') : t('pickup.remainingCount', { n: m.remainingDownloads })
|
||||
})
|
||||
|
||||
// ===== 26.9:媒体内联预览(图片/音频)=====
|
||||
const IMAGE_EXT = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'avif']
|
||||
const AUDIO_EXT = ['mp3', 'wav', 'ogg', 'oga', 'm4a', 'flac', 'aac', 'opus']
|
||||
const IMAGE_PREVIEW_MAX = 30 * 1024 * 1024
|
||||
const AUDIO_PREVIEW_MAX = 100 * 1024 * 1024
|
||||
|
||||
const mediaKind = computed<'image' | 'audio' | null>(() => {
|
||||
const m = meta.value
|
||||
if (!m || m.isText) return null
|
||||
const name = (m.name || '').toLowerCase()
|
||||
const dot = name.lastIndexOf('.')
|
||||
if (dot < 0) return null
|
||||
const ext = name.slice(dot + 1)
|
||||
if (IMAGE_EXT.includes(ext) && m.size <= IMAGE_PREVIEW_MAX) return 'image'
|
||||
if (AUDIO_EXT.includes(ext) && m.size <= AUDIO_PREVIEW_MAX) return 'audio'
|
||||
return null
|
||||
})
|
||||
|
||||
/** 媒体源:select 返回的 download_url(代理路径或 S3 直链);缺失则不预览 */
|
||||
const mediaSrc = computed(() => {
|
||||
if (!mediaKind.value) return ''
|
||||
const url = meta.value?.downloadUrl
|
||||
if (!url) return ''
|
||||
return url.startsWith('/') ? new URL(url, location.origin).toString() : url
|
||||
})
|
||||
|
||||
const previewFailed = ref(false)
|
||||
watch([mediaSrc, () => meta.value?.code], () => (previewFailed.value = false))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -161,6 +190,27 @@ const remaining = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 26.9:图片/音频内联预览(加载失败自动隐藏回退下载) -->
|
||||
<div v-if="mediaSrc && !previewFailed" class="media-preview">
|
||||
<p class="hint" style="margin: 0 0 8px">
|
||||
{{ mediaKind === 'image' ? t('pickup.previewImage') : t('pickup.previewAudio') }}
|
||||
</p>
|
||||
<img
|
||||
v-if="mediaKind === 'image'"
|
||||
:src="mediaSrc"
|
||||
:alt="meta.name"
|
||||
style="max-width: 100%; max-height: 360px; border-radius: 10px; display: block; margin: 0 auto"
|
||||
@error="previewFailed = true"
|
||||
/>
|
||||
<audio
|
||||
v-else
|
||||
controls
|
||||
:src="mediaSrc"
|
||||
style="width: 100%"
|
||||
@error="previewFailed = true"
|
||||
></audio>
|
||||
</div>
|
||||
|
||||
<div v-if="downloadPercent !== null" style="margin: 16px 0 6px">
|
||||
<div class="progress"><i :style="{ width: `${downloadPercent}%` }"></i></div>
|
||||
<p class="hint">{{ t('pickup.downloading', { percent: downloadPercent }) }}</p>
|
||||
|
||||
@@ -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<void> {
|
||||
// 26.9:带宽(KV 字节/秒 → UI MB/s,0 保留为不限)
|
||||
form.uploadRate = Math.round(toNum(pick<unknown>(cfg, ['upload_rate', 'uploadRate']), 0) / MB_BYTES)
|
||||
form.downloadRate = Math.round(toNum(pick<unknown>(cfg, ['download_rate', 'downloadRate']), 0) / MB_BYTES)
|
||||
// 26.9:回收与下载安全(秒 → 分钟)
|
||||
form.recycle_enabled = toNum(pick<unknown>(cfg, ['recycle_enabled']), 1) === 1
|
||||
form.recycle_interval_min = Math.max(1, Math.round(toNum(pick<unknown>(cfg, ['recycle_interval']), 1800) / 60))
|
||||
form.retention_days = toNum(pick<unknown>(cfg, ['retention_days']), 0)
|
||||
form.dedup_enabled = toNum(pick<unknown>(cfg, ['dedup_enabled']), 1) === 1
|
||||
form.direct_download = toNum(pick<unknown>(cfg, ['direct_download']), 1) === 1
|
||||
form.direct_link_expire_min = Math.max(1, Math.round(toNum(pick<unknown>(cfg, ['direct_link_expire']), 900) / 60))
|
||||
form.hotlink_enabled = toNum(pick<unknown>(cfg, ['hotlink_enabled']), 0) === 1
|
||||
form.hotlink_whitelist = String(pick<string>(cfg, ['hotlink_whitelist']) ?? '')
|
||||
// v3:当前引擎(get 的 _engine_hint.storage_backend 或公开 config 的 storage_engine)
|
||||
const hint = pick<Record<string, unknown>>(cfg, ['_engine_hint'])
|
||||
const backend = hint ? String(pick<string>(hint, ['storage_backend']) ?? '') : ''
|
||||
@@ -239,6 +260,15 @@ async function save(): Promise<void> {
|
||||
// 26.9:带宽(MB/s → 字节/秒;负数与 NaN 归 0)
|
||||
upload_rate: clamp(Number(form.uploadRate) || 0, 0, RATE_MAX_MB) * MB_BYTES,
|
||||
download_rate: clamp(Number(form.downloadRate) || 0, 0, RATE_MAX_MB) * MB_BYTES,
|
||||
// 26.9:回收与下载安全(分钟 → 秒;开关 0/1)
|
||||
recycle_enabled: form.recycle_enabled ? 1 : 0,
|
||||
recycle_interval: clamp((Number(form.recycle_interval_min) || 30) * 60, 60, 86400),
|
||||
retention_days: clamp(Number(form.retention_days) || 0, 0, RETENTION_DAYS_MAX),
|
||||
dedup_enabled: form.dedup_enabled ? 1 : 0,
|
||||
direct_download: form.direct_download ? 1 : 0,
|
||||
direct_link_expire: clamp((Number(form.direct_link_expire_min) || 15) * 60, 60, 3600),
|
||||
hotlink_enabled: form.hotlink_enabled ? 1 : 0,
|
||||
hotlink_whitelist: form.hotlink_whitelist.trim().slice(0, HOTLINK_WHITELIST_MAX),
|
||||
})
|
||||
toast.success(t('admin.settings.saved'))
|
||||
await config.load() // 立即刷新导航 Logo / favicon / 站点名称 / 背景 / 页脚 / 通知
|
||||
@@ -268,6 +298,14 @@ function restoreDefaults(): void {
|
||||
form.uploadMinute = 1
|
||||
form.uploadRate = 0
|
||||
form.downloadRate = 0
|
||||
form.recycle_enabled = true
|
||||
form.recycle_interval_min = 30
|
||||
form.retention_days = 0
|
||||
form.dedup_enabled = true
|
||||
form.direct_download = true
|
||||
form.direct_link_expire_min = 15
|
||||
form.hotlink_enabled = false
|
||||
form.hotlink_whitelist = ''
|
||||
// v3:单位状态复位
|
||||
saveTimeValue.value = 0
|
||||
saveTimeUnit.value = 'day'
|
||||
@@ -785,6 +823,111 @@ onMounted(load)
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3 class="card-title">{{ t('admin.settings.sectionRecycle') }}</h3>
|
||||
<p class="card-sub">{{ t('admin.settings.recycleHint') }}</p>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="set-recycle-enabled">{{ t('admin.settings.recycleEnabled') }}</label>
|
||||
<select id="set-recycle-enabled" v-model="form.recycle_enabled" class="input">
|
||||
<option :value="true">{{ t('common.enabled') }}</option>
|
||||
<option :value="false">{{ t('common.disabled') }}</option>
|
||||
</select>
|
||||
<p class="hint">{{ t('admin.settings.recycleEnabledHint') }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="set-recycle-interval">{{ t('admin.settings.recycleInterval') }}</label>
|
||||
<div class="unit-row">
|
||||
<input
|
||||
id="set-recycle-interval"
|
||||
v-model.number="form.recycle_interval_min"
|
||||
class="input"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="1440"
|
||||
step="1"
|
||||
/>
|
||||
<span class="unit-suffix">min</span>
|
||||
</div>
|
||||
<p class="hint">{{ t('admin.settings.recycleIntervalHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row" style="margin-top: 14px">
|
||||
<div class="field">
|
||||
<label for="set-retention-days">{{ t('admin.settings.retentionDays') }}</label>
|
||||
<div class="unit-row">
|
||||
<input
|
||||
id="set-retention-days"
|
||||
v-model.number="form.retention_days"
|
||||
class="input"
|
||||
type="number"
|
||||
:min="0"
|
||||
:max="3650"
|
||||
step="1"
|
||||
/>
|
||||
<span class="unit-suffix">{{ t('common.days') }}</span>
|
||||
</div>
|
||||
<p class="hint">{{ t('admin.settings.retentionDaysHint') }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="set-dedup-enabled">{{ t('admin.settings.dedupEnabled') }}</label>
|
||||
<select id="set-dedup-enabled" v-model="form.dedup_enabled" class="input">
|
||||
<option :value="true">{{ t('common.enabled') }}</option>
|
||||
<option :value="false">{{ t('common.disabled') }}</option>
|
||||
</select>
|
||||
<p class="hint">{{ t('admin.settings.dedupEnabledHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row" style="margin-top: 14px">
|
||||
<div class="field">
|
||||
<label for="set-direct-download">{{ t('admin.settings.directDownload') }}</label>
|
||||
<select id="set-direct-download" v-model="form.direct_download" class="input">
|
||||
<option :value="true">{{ t('common.enabled') }}</option>
|
||||
<option :value="false">{{ t('common.disabled') }}</option>
|
||||
</select>
|
||||
<p class="hint">{{ t('admin.settings.directDownloadHint') }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="set-direct-expire">{{ t('admin.settings.directLinkExpire') }}</label>
|
||||
<div class="unit-row">
|
||||
<input
|
||||
id="set-direct-expire"
|
||||
v-model.number="form.direct_link_expire_min"
|
||||
class="input"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="60"
|
||||
step="1"
|
||||
/>
|
||||
<span class="unit-suffix">min</span>
|
||||
</div>
|
||||
<p class="hint">{{ t('admin.settings.directLinkExpireHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row" style="margin-top: 14px">
|
||||
<div class="field">
|
||||
<label for="set-hotlink-enabled">{{ t('admin.settings.hotlinkEnabled') }}</label>
|
||||
<select id="set-hotlink-enabled" v-model="form.hotlink_enabled" class="input">
|
||||
<option :value="true">{{ t('common.enabled') }}</option>
|
||||
<option :value="false">{{ t('common.disabled') }}</option>
|
||||
</select>
|
||||
<p class="hint">{{ t('admin.settings.hotlinkEnabledHint') }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="set-hotlink-whitelist">{{ t('admin.settings.hotlinkWhitelist') }}</label>
|
||||
<textarea
|
||||
id="set-hotlink-whitelist"
|
||||
v-model="form.hotlink_whitelist"
|
||||
class="input"
|
||||
rows="2"
|
||||
maxlength="2048"
|
||||
placeholder="a.com, b.org"
|
||||
></textarea>
|
||||
<p class="hint">{{ t('admin.settings.hotlinkWhitelistHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="save-row">
|
||||
<button class="btn" type="submit" :disabled="saving">
|
||||
<span v-if="saving" class="spin" aria-hidden="true"></span>
|
||||
|
||||
Reference in New Issue
Block a user