26.9:直链下载 + 过期回收 + SHA512 去重 + 防盗链 + 媒体预览 + 文件夹上传提示
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:
2026-09-08 04:54:05 +08:00
parent 84df9996cb
commit 7a9015aad0
25 changed files with 1259 additions and 15 deletions
+143
View File
@@ -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>