Compare commits

..
17 Commits
Author SHA1 Message Date
SKYMirror 7a9015aad0 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
2026-09-08 04:54:05 +08:00
SKYMirror 84df9996cb 26.9:版本号统一 + CI 精简 + 前端产物重建
CI 测试 / go vet + go test (push) Successful in 49s
- 全项目版本号统一:v3.x 迭代号(26.9/26.9/26.9/26.9 及裸 v2/v3)→ 26.9,
  覆盖 Go 注释 / 文档 / openapi.yaml / README×4 / 前端源码(80+ 处)
- v31_test.go 更名 custom_code_test.go;TestV2AccessorDefaults → TestKVAccessorDefaults
- docs/api/00-overview.md 更新日志合并为单条 26.9 条目(修复错位拼接)
- .goreleaser.yaml 头部注释与实际一致(Pro 2.18.1 / GITEA_TOKEN / semver tag 要求)
- CI:release-image.yml → ci.yml,仅保留 vet+test 门禁;
  镜像发布移交 GoReleaser Pro(原 build-push 的 tag 校验与 26.9 版本方案冲突,历史 9 次失败)
- 前端重建:server/web/dist 与 web-embed 同步(docs 文案嵌入更新)
2026-09-08 03:16:51 +08:00
SKYMirror 27432218c4 release: GoReleaser Pro v2.18.1 发布配置
Release 镜像 / 测试(推送前置门禁) (push) Successful in 44s
Release 镜像 / 多架构构建并推送 ACR (push) Failing after 1s
- .goreleaser.yaml:5 平台二进制(linux/darwin/windows × amd64/arm64)+
  tar.gz/zip 双格式归档 + 双架构 Docker 镜像(ACR registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare)
  + 多架构 manifest(版本号 + latest)+ Gitea Release 发布
- main.go:APP_VERSION 从 const 改 var,支持 -ldflags -X 注入发布版本;
  新增 BuildCommit/BuildDate 注入点
- dist/ 加入 .gitignore
2026-09-08 02:28:41 +08:00
SKYMirror 00a8c16ab7 26.9:上下行带宽限速 + 品牌更新 + 产品修复
亮点
- 26.9 带宽限速(upload_rate/download_rate,字节/秒,0=不限速;管理端立即生效)
  - middleware/bandwidth.go 时间窗对齐 sleep 算法 + 单元测试(400KB@100KB/s 4.00s 精度)
  - 下载:serveFile 包裹 storage.ReadCloser(统一覆盖 local/webdav/s3 代理下载)
  - 上传:UploadBandwidthMiddleware 包裹 Request.Body(shareFile/chunk/presign proxy)
  - S3 预签名直传不可服务端限速——UI/文档明示
  - 后台 SettingsView 增加 MB/s 友好输入;i18n zh-CN/en-US 双语
- 26.9 文档:新增 docs/api/13-bandwidth.md 专题;10-config 指针;00-overview changelog
  与限流表带宽行;openapi.yaml 三处 schema + description 更新
- README.md / web/README.md / server/README.md / deploy/README.md 全部覆盖

品牌(26.9 收尾)
- 文件快递柜 → 文件快传(前端/后端默认值/文档/产物/运行 KV)
- 「复制取件码」按钮删除;取件码块点击即复制(保持原尺寸)
- 「复制链接」→「复制链接和提取码」(一并复制链接和提取码)

产品修复(26.9)
- 文本分享 Content-Type text/plain + urlencoded body:前端显式声明 urlencoded 头根治;
  后端 bindJSONOrForm 兜底兼容 text/plain + JSON/urlencoded 嗅探
- Docker 部署文档校对到 26.9 现状(热切换 + 端口/卷/健康检查)
2026-09-06 10:20:04 +08:00
SKYMirror 113c514531 CI:build-push 内创建 multiarch builder(docker-container 驱动)
Release 镜像 / 测试(推送前置门禁) (push) Successful in 43s
Release 镜像 / 多架构构建并推送 ACR (push) Successful in 2m35s
2026-09-05 07:50:28 +08:00
SKYMirror 2196ee20a2 CI:删除编辑残留的残缺步骤头(导致 invalid run/uses syntax)
Release 镜像 / 测试(推送前置门禁) (push) Successful in 53s
Release 镜像 / 多架构构建并推送 ACR (push) Failing after 3s
2026-09-05 07:48:26 +08:00
SKYMirror f050944866 CI:基础镜像改走 ACR 私有镜像(runner 网络 docker.io DNS 污染,直连不可达);APK/NPM 全部国内源;Dockerfile 基础镜像参数化
Release 镜像 / 测试(推送前置门禁) (push) Successful in 38s
Release 镜像 / 多架构构建并推送 ACR (push) Failing after 0s
2026-09-05 07:43:40 +08:00
SKYMirror 5bc7d5ee20 CI:buildx create 用兼容参数 --config 并不再吞错(docker:27 内置 buildx 不认 --buildkitd-config-file)
Release 镜像 / 测试(推送前置门禁) (push) Successful in 1m5s
Release 镜像 / 多架构构建并推送 ACR (push) Failing after 5s
2026-09-05 07:12:16 +08:00
SKYMirror 2b37d46e83 CI:buildx 回到 docker-container 驱动并通过 buildkitd 配置注入国内 mirror;Dockerfile 支持 NPM_REGISTRY 参数(CI 走 npmmirror)
Release 镜像 / 测试(推送前置门禁) (push) Successful in 45s
Release 镜像 / 多架构构建并推送 ACR (push) Failing after 2s
2026-09-05 07:09:47 +08:00
SKYMirror 9931db2670 CI:buildx 改用 dind 内置 default builder(继承 registry mirror;docker-container 驱动不继承)
Release 镜像 / 测试(推送前置门禁) (push) Successful in 7m0s
Release 镜像 / 多架构构建并推送 ACR (push) Failing after 3s
2026-09-05 07:01:11 +08:00
SKYMirror 46319cc99e CI:dind daemon 配置国内 registry mirror(docker.io 直连被墙导致拉 node 基础镜像 EOF)
Release 镜像 / 测试(推送前置门禁) (push) Successful in 1m35s
Release 镜像 / 多架构构建并推送 ACR (push) Failing after 15s
2026-09-05 06:57:02 +08:00
SKYMirror 15b2bdf9da CI:build-push 改用 dind 容器(自起 daemon,不依赖宿主 sock),修默认镜像拉取失败;补 outputs.version
Release 镜像 / 测试(推送前置门禁) (push) Successful in 1m40s
Release 镜像 / 多架构构建并推送 ACR (push) Failing after 13m14s
2026-09-05 06:40:39 +08:00
SKYMirror 27b8b75136 CI 修复:clone URL 自适应 http/https(GITHUB_SERVER_URL 为内网地址时的 scheme 处理)
Release 镜像 / 测试(推送前置门禁) (push) Successful in 47s
Release 镜像 / 多架构构建并推送 ACR (push) Failing after 1s
2026-09-05 06:36:17 +08:00
SKYMirror 6f1a925833 26.9:品牌统一(fileshare)+ 版本号改为日期式
Release 镜像 / 测试(推送前置门禁) (push) Failing after 12s
Release 镜像 / 多架构构建并推送 ACR (push) Skipped
- 数据库默认文件 filecodebox.db → fileshare.db(config.go 默认值与全部文档/编排同步)
- Go module filecodebox → fileshare(全部 import 同步,build/vet/test 全绿)
- 应用版本 APP_VERSION 2.5.6 → 26.9(health 接口已验证返回 26.9)
- deploy 编排统一:compose 项目名、Postgres 默认凭据、minio 桶名、env 注释
- JWT issuer、存储临时目录前缀、web 包名同步 fileshare
- CI:镜像 tag 以 APP_VERSION 为唯一版本源,main/tag 推送即发布
  ${VER} + latest;tag 触发时校验 tag 名与 APP_VERSION 一致,防错版
- 本地开发库文件已改名 fileshare.db(含 -shm/-wal 清理)
2026-09-05 06:32:18 +08:00
SKYMirror b5f06d09b3 CI:Gitea Actions 工作流(零第三方 action,国内网络自包含)
Release 镜像 / 测试(推送前置门禁) (push) Failing after 13s
Release 镜像 / 多架构构建并推送 ACR (push) Skipped
- push/PR 触发:后端 gofmt/vet/test/build + health 冒烟,
  前端 npm ci/类型检查/构建 + 站内文档嵌入校验
- main/tag v* 触发 Release:先跑测试门禁,
  再 buildx 多架构(amd64/arm64)构建推送阿里云 ACR(provenance/sbom 关闭,
  规避 ACR 不识别 OCI empty manifest 的问题)
- 检出用 GITHUB_TOKEN 自克隆,不依赖 github.com 的外部 action
2026-09-05 06:07:46 +08:00
SKYMirror 7cfa330751 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	README.md
2026-09-05 04:27:29 +08:00
SKYMirror 7f060dd0e4 26.9(安全审计修复版)
Go 1.27.1 (Gin+GORM) + Vue 3 文件快传服务:

- 安全审计全部修复(docs/security-audit-2026-09-05.md):
  bcrypt 密码哈希与自动升级、presign 直传服务端大小/内容校验、
  全局请求体上限、依赖升级(govulncheck 0 命中)、janitor 后台清理、
  管理端审计动作落库、/admin CORS 收紧、通知内容白名单净化、
  会话默认 7 天、限流缓存故障降级、robots.txt 端点等
- 前端:取件链接复制修复(不再重复拼接提取码)、markdown 净化器加固
- Redis 支持库号(FCB_REDIS_DB / redis://…/db URL)
- 文档:docs/api/* 与 openapi.yaml 同步最新行为(robots.txt、
  提码 5 位起、chunk 32MiB 上限、admin 审计动作等)

验证:gofmt/go vet/go test 全绿;二进制端到端冒烟通过
2026-09-05 04:22:41 +08:00
105 changed files with 2224 additions and 3368 deletions
+42
View File
@@ -0,0 +1,42 @@
name: CI 测试
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
workflow_dispatch:
# 镜像发布不再走 CIGoReleaser Pro(本地 goreleaser release --clean
# 负责多平台归档 + Gitea Release + ACR 双架构镜像(见 .goreleaser.yaml)。
# 本工作流只做推送/PR 前置测试门禁。
jobs:
test:
name: go vet + go test
runs-on: ubuntu-latest
container:
image: golang:1.27.1-alpine
timeout-minutes: 30
steps:
- name: 安装工具并检出
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
apk add --no-cache git curl bash >/dev/null
SCHEME="${GITHUB_SERVER_URL%%://*}"
SRV="${GITHUB_SERVER_URL#*://}"
echo "clone from ${SCHEME}://${SRV}"
git clone --depth=1 --branch "$GITHUB_REF_NAME" \
"${SCHEME}://oauth2:${GITHUB_TOKEN}@${SRV}/${GITHUB_REPOSITORY}.git" .
- name: go vet + go test
working-directory: server
env:
GOCACHE: /tmp/.gocache
GOMODCACHE: /tmp/.gomodcache
CGO_ENABLED: "0"
run: |
go vet ./...
go test ./... -count=1
-102
View File
@@ -1,102 +0,0 @@
name: Release 镜像
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
env:
REGISTRY: registry.cn-hangzhou.aliyuncs.com
IMAGE: registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare
jobs:
test:
name: 测试(推送前置门禁)
runs-on: ubuntu-latest
container:
image: golang:1.27.1-alpine
timeout-minutes: 30
steps:
- name: 安装工具并检出
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
apk add --no-cache git curl bash >/dev/null
git clone --depth=1 --branch "$GITHUB_REF_NAME" \
"https://oauth2:${GITHUB_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" .
- name: go vet + go test
working-directory: server
env:
GOCACHE: /tmp/.gocache
GOMODCACHE: /tmp/.gomodcache
CGO_ENABLED: "0"
run: |
go vet ./...
go test ./... -count=1
build-push:
name: 多架构构建并推送 ACR
needs: test
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: 安装工具并检出
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
apk add --no-cache git curl bash >/dev/null
git clone --depth=1 --branch "$GITHUB_REF_NAME" \
"https://oauth2:${GITHUB_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" .
# 嵌入产物同步(保证二进制内前端与仓库一致)
if [ -f web-embed/index.html ]; then :; fi
- name: 安装 Docker CLI(挂宿主 daemon
run: apk add --no-cache docker-cli buildx >/dev/null
- name: 计算 tag 与平台
id: meta
env:
REF: ${{ gitea.ref }}
run: |
case "$REF" in
refs/tags/v*)
VER="${REF#refs/tags/v}"
echo "tags=${IMAGE}:${VER} ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "发布 tag: ${VER} + latest" ;;
*)
echo "tags=${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "main 构建: latest" ;;
esac
- name: 登录阿里云 ACR
env:
ACR_USER: ${{ secrets.ACR_USERNAME }}
ACR_PASS: ${{ secrets.ACR_PASSWORD }}
run: |
PASS_LEN=${#ACR_PASS}
echo "ACR 用户: $ACR_USER (密码 ${PASS_LEN} 位)"
printf '%s' "$ACR_PASS" | docker login "$REGISTRY" -u "$ACR_USER" --password-stdin
- name: 多架构构建并推送
env:
TAGS: ${{ steps.meta.outputs.tags }}
# provenance/sbom 必须关:阿里云 ACR 不识别 OCI empty manifestattestation),
# 否则报 "denied: unknown manifest class for application/vnd.oci.empty.v1+json"
run: |
ARGS=""
for t in $TAGS; do ARGS="$ARGS -t $t"; done
docker buildx build \
--builder default \
--platform linux/amd64,linux/arm64 \
--provenance=false --sbom=false \
--push \
-f deploy/Dockerfile \
$ARGS \
.
- name: 校验远程 manifest(双架构)
run: docker buildx imagetools inspect "${IMAGE}:latest" | grep -E "linux/amd64|linux/arm64"
+1
View File
@@ -35,3 +35,4 @@ deploy/.env
# 会话/协作目录(工具私有,非项目源码)
.agent-teams/
dist/
+193
View File
@@ -0,0 +1,193 @@
# 文件快传 GoReleaser 配置(Pro 2.18.1,二进制在 ~/Code/Releaser/goreleaser
#
# 用法:
# goreleaser release --snapshot --clean --skip=publish # 本地试跑,产出 ./dist
# goreleaser release --clean # 正式发布(需 semver tag
#
# tag 必须是语义化版本(goreleaser 强制),如 v26.9.0(→ 版本 26.9.0),
# 并与 server/cmd/server/main.go 的 APP_VERSION 保持一致。
#
# 发布所需环境变量(不入库):
# GITEA_TOKEN Gitea Personal Access Tokenrelease + 资产上传)
# BUILDX_CONFIG=/tmp/buildx-config 可选:buildx 状态目录重定向(受限环境)
# DOCKER_CONFIG=/tmp/docker-config 可选:docker 配置重定向(manifest 需写 ~/.docker 时)
# ACR 登录:docker login registry.cn-hangzhou.aliyuncs.com(镜像推送用本机 docker 凭证)
#
# 产物:
# - 归档:linux/darwin/windows × amd64/arm64tar.gz + zip)→ Gitea Release
# - 镜像:registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{版本}-amd64/-arm64
# + 多架构 manifest {版本} 与 latest → 阿里云 ACR
version: 2
project_name: fileshare
# 全局环境(goreleaser 会注入到所有 go build 子进程)。
# 把 GOMODCACHE 写死在 /tmp/.../goreleaser-pkg-mod,避开工作区下 server/.gomodcache
# 被 docker extra_files 拷进 context 时 macOS provenance xattr 触发的权限拒绝。
env:
- GOCACHE=/tmp/goreleaser-go-build
- GOMODCACHE=/tmp/goreleaser-pkg-mod
- GOSUMDB=off
- GOPROXY=https://goproxy.cn,direct
# 模块/入口:GoReleaser v2 不再在项目根找 main,须显式指到 server/cmd/server。
builds:
- id: server
main: ./cmd/server
dir: ./server
binary: fileshare-server
env:
- CGO_ENABLED=0
# GOCACHE/GOMODCACHE 由顶层 env: 注入
- GOSUMDB=off
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
# Windows ARM64 极少用,跳过以节省 CI 时间
ignore:
- goos: windows
goarch: arm64
flags:
- -trimpath
ldflags:
- -s -w
# 把版本号注入 main.APP_VERSIONconst → var),发布时由 .Version 提供
- -X 'main.APP_VERSION={{ .Version }}'
- -X 'main.BuildCommit={{ .ShortCommit }}'
- -X 'main.BuildDate={{ .Date }}'
mod_timestamp: '{{ .CommitTimestamp }}'
archives:
- id: default
formats:
- tar.gz
- zip
name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
files:
- LICENSE*
- README*
- src: ./deploy/.env.example
dst: deploy
# Changelog:从 git tag 之间收集提交,按 kind 分组
changelog:
use: git
sort: asc
filters:
exclude:
- '^Merge '
- '^ci:'
- '^chore:'
- '^docs:'
- '^test:'
groups:
- title: '新功能'
regexp: '^.*?(feat|feature)(\(.+\))?!?:\s*.+'
order: 0
- title: '修复'
regexp: '^.*?(fix|bug)(\(.+\))?!?:\s*.+'
order: 1
- title: '性能 / 重构'
regexp: '^.*?(perf|refactor|revert)(\(.+\))?!?:\s*.+'
order: 2
- title: '构建 / CI'
regexp: '^.*?(build|ci)(\(.+\))?!?:\s*.+'
order: 3
- title: '其它'
order: 999
# ─────────────────────────────────────────────
# Docker 镜像(多架构)
# 使用现成的 deploy/Dockerfile(前端用 web-embed 预构建产物;缺失时容器内重建)
# extra_files 不列 .gocache/.gomodcachegoreleaser 默认仍会拷 . 下的隐藏目录;
# 已在 .dockerignore 排除,并由项目根 .dockerignore 屏蔽)
# ─────────────────────────────────────────────
dockers:
- id: server-amd64
use: buildx
goos: linux
goarch: amd64
dockerfile: deploy/Dockerfile
image_templates:
- 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{{ .Version }}-amd64'
build_flag_templates:
- --platform=linux/amd64
- --build-arg=APK_MIRROR=https://mirrors.aliyun.com
- --build-arg=NPM_REGISTRY=https://registry.npmmirror.com
- --label=org.opencontainers.image.title=fileshare
- --label=org.opencontainers.image.version={{ .Version }}
- --label=org.opencontainers.image.revision={{ .ShortCommit }}
- --label=org.opencontainers.image.source=https://git.skymirror.top/SKYMirrorPublicTeam/FileShare
- --label=org.opencontainers.image.licenses=MIT
extra_files:
- server
- deploy
- web-embed
- web
- server/go.mod
- server/go.sum
- id: server-arm64
use: buildx
goos: linux
goarch: arm64
dockerfile: deploy/Dockerfile
image_templates:
- 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{{ .Version }}-arm64'
build_flag_templates:
- --platform=linux/arm64
- --build-arg=APK_MIRROR=https://mirrors.aliyun.com
- --build-arg=NPM_REGISTRY=https://registry.npmmirror.com
- --label=org.opencontainers.image.title=fileshare
- --label=org.opencontainers.image.version={{ .Version }}
- --label=org.opencontainers.image.revision={{ .ShortCommit }}
- --label=org.opencontainers.image.source=https://git.skymirror.top/SKYMirrorPublicTeam/FileShare
- --label=org.opencontainers.image.licenses=MIT
extra_files:
- server
- deploy
- web-embed
- web
- server/go.mod
- server/go.sum
# 多架构 manifestamd64 / arm64 都推成功后合并成单一 manifest + latest 别名)
docker_manifests:
- name_template: 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{{ .Version }}'
image_templates:
- 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{{ .Version }}-amd64'
- 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{{ .Version }}-arm64'
- name_template: 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:latest'
image_templates:
- 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{{ .Version }}-amd64'
- 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{{ .Version }}-arm64'
# Gitea 服务器(token 走 GITEA_TOKEN env
gitea_urls:
api: https://git.skymirror.top/api/v1
# 不推 Homebrew / nfpm / snap / flatpak
nfpms: []
brews: []
snapcrafts: []
aurs: []
scoops: []
chocolateys: []
# Gitea Release(把 archives 上传 + 创建 Release
release:
gitea:
owner: SKYMirrorPublicTeam
name: FileShare
draft: false
prerelease: 'auto'
name_template: '{{ .ProjectName }} {{ .Version }}'
header: |
## 文件快传 {{ .Version }}
完整变更列表见 [CHANGELOG](https://git.skymirror.top/SKYMirrorPublicTeam/FileShare/src/tag/{{ .Tag }}).
ids:
- default
+17 -9
View File
@@ -2,9 +2,13 @@
文件快传
数据库**默认 SQLite 零依赖**modernc.org/sqlite 纯 Go 驱动,数据文件 `./data/filecodebox.db`),
数据库**默认 SQLite 零依赖**modernc.org/sqlite 纯 Go 驱动,数据文件 `./data/fileshare.db`),
可选切换 Postgres`FCB_DB_DRIVER=postgres` + DSN);Redis 为**可选**增强(未配置时自动降级为进程内存缓存)。
存储引擎支持 **本地 / S3 / WebDAV**(运行时热切换,健康检查通过才生效;WebDAV 重点优化:流式、Range、重试、连接复用)。
26.9 起支持**上下行带宽限速**(`upload_rate` / `download_rate` 字节/秒,0=不限速,管理端改后立即生效)。
**26.9 新增**:上下行带宽限速(见 [专题](docs/api/13-bandwidth.md))· 站点对外域名、自定义提取码(26.9)·
本地/S3/WebDAV 存储引擎 26.9 运行时热切换。
| 目录 | 说明 |
|---|---|
@@ -17,7 +21,7 @@
## 默认 Logo 与自定义
- 页面导航 Logo:本地资源 `web/src/assets/brand/logo.svg`
- favicon / 备用 Logo:本地资源 `web/src/assets/brand/favicon.png`v2 起不再使用远程 URL 默认值)
- favicon / 备用 Logo:本地资源 `web/src/assets/brand/favicon.png`26.9 起不再使用远程 URL 默认值)
管理端自定义 Logo 三步:
@@ -37,7 +41,7 @@ docker compose up -d --build
```
- **数据库双路径**:默认 SQLite 零依赖——`docker compose up -d --build` 即可(无需 postgres profile
数据落 `serverdata``/app/data/filecodebox.db`);Postgres 模式——`.env`
数据落 `serverdata``/app/data/fileshare.db`);Postgres 模式——`.env`
`FCB_DB_DRIVER=postgres``FCB_DB_DSN``docker compose --profile postgres up -d --build`
- Redis 可选:`--profile redis` 并在 `.env``FCB_REDIS_ADDR=redis:6379`;未配置时自动降级为内存缓存。
- 存储引擎切换:`.env``FCB_STORAGE_ENGINE=s3|webdav` 并带对应 profile 启动:
@@ -51,7 +55,7 @@ docker compose up -d --build
```bash
# 1) 后端(:8466)——默认 SQLite 零依赖,无需任何数据库
cd server
go run ./cmd/server # 数据落 ./data/filecodebox.dbgo test ./... 运行单测
go run ./cmd/server # 数据落 ./data/fileshare.dbgo test ./... 运行单测
# 2) 后端 Postgres 模式(可选)
docker run -d --name fcb-pg -p 5432:5432 \
@@ -76,7 +80,9 @@ npm run build # 产出 web/dist/,构建时按 deploy/Dockerfil
| 内容 | 入口 |
|---|---|
| API 操作文档(概述/认证/分享/分片/预签名/管理后台/审计/存储/配置/错误码/Logo | 站内 `/docs`,源文件 `docs/api/*.md` |
| API 操作文档(概述/认证/分享/分片/预签名/管理后台/审计/存储/配置/错误码/Logo/
**带宽限速** | 站内 `/docs`,源文件 [docs/api/](docs/api/) |
| ↳ **26.9 带宽限速**`upload_rate` / `download_rate`,管理端改后立即生效) | [docs/api/13-bandwidth.md](docs/api/13-bandwidth.md) |
| OpenAPI 3.0 规范 + Swagger UI | 站内 `/openapi`,源文件 `docs/openapi.yaml` |
| 后端设计契约 | [server/README.md](server/README.md) |
| 前端说明与渲染约定 | [web/README.md](web/README.md) |
@@ -88,8 +94,8 @@ npm run build # 产出 web/dist/,构建时按 deploy/Dockerfil
| 变量 | 必需 | 默认 | 说明 |
|---|---|---|---|
| `FCB_DB_DRIVER` | ❌ | `sqlite` | 数据库驱动:`sqlite` \| `postgres`v2 需求 ⑧) |
| `FCB_DB_DSN` | 视驱动 | - | postgres:连接串(**必需**);sqlite:文件路径(可空,默认 `./data/filecodebox.db` |
| `FCB_DB_DRIVER` | ❌ | `sqlite` | 数据库驱动:`sqlite` \| `postgres`26.9 需求 ⑧) |
| `FCB_DB_DSN` | 视驱动 | - | postgres:连接串(**必需**);sqlite:文件路径(可空,默认 `./data/fileshare.db` |
| `FCB_REDIS_ADDR` | ❌ | 空 | 为空时缓存降级为内存实现;支持 `redis://[:password@]host:port[/db]` / `rediss://` URL 形式 |
| `FCB_REDIS_DB` | ❌ | `0` | Redis 逻辑库号 0-15URL 显式 `/N` 时以 URL 为准) |
| `FCB_LISTEN` | ❌ | `:8466` | 监听地址 |
@@ -109,14 +115,16 @@ npm run build # 产出 web/dist/,构建时按 deploy/Dockerfil
### 运行时配置(settings KV,管理端可改)
站点信息(`site_name``logo_url``favicon_url``page_explain` 等)、v2 展示与通知
站点信息(`site_name``logo_url``favicon_url``page_explain` 等)、26.9 展示与通知
`background_url``footer_text``footer_beian``notify_enabled``notify_title/content`)、
上传策略(`openUpload``enableChunk``uploadSize``allowed_file_types``expireStyle`
`code_generate_type``max_save_seconds``storageLimit`,及 v2 上限键 `max_save_count`
`code_generate_type``max_save_seconds``storageLimit`,及 26.9 上限键 `max_save_count`
`max_file_size`)、限流(`uploadCount/uploadMinute``errorCount/errorMinute`
`loginCount/loginMinute`)、安全(`adminSessionExpire``admin_token`/`jwt_secret` 由系统管理)。
26.9 带宽(`upload_rate` / `download_rate`,字节/秒,0=不限速)。
完整键表与默认值见《[环境变量与配置项](docs/api/10-config.md)》;
26.9 带宽限速专题《[带宽限速](docs/api/13-bandwidth.md)》;
修改接口见《[管理后台 API](docs/api/07-admin.md)》(`PATCH /admin/config/update`,改密自动轮换 jwt_secret)。
## 审计日志
+6 -6
View File
@@ -5,17 +5,17 @@
WEB_PORT=8466
# ---- 数据库(需求 ⑧)----
# 默认 SQLite:零依赖,无需任何下方 Postgres 变量(数据落 serverdata 卷 /app/data/filecodebox.db
# 默认 SQLite:零依赖,无需任何下方 Postgres 变量(数据落 serverdata 卷 /app/data/fileshare.db
FCB_DB_DRIVER=sqlite
FCB_DB_DSN=
# Postgres 模式(可选):先 `docker compose --profile postgres up -d --build`,再改为:
# FCB_DB_DRIVER=postgres
# FCB_DB_DSN=postgres://filecodebox:filecodebox@postgres:5432/filecodebox?sslmode=disable
# FCB_DB_DSN=postgres://fileshare:fileshare@postgres:5432/fileshare?sslmode=disable
# ---- Postgres(仅 --profile postgres 时使用;生产务必修改默认口令并启用 TLS)----
POSTGRES_USER=filecodebox
POSTGRES_PASSWORD=filecodebox
POSTGRES_DB=filecodebox
POSTGRES_USER=fileshare
POSTGRES_PASSWORD=fileshare
POSTGRES_DB=fileshare
# ---- 存储引擎切换:local | s3 | webdav ----
FCB_STORAGE_ENGINE=local
@@ -47,7 +47,7 @@ FCB_TRUSTED_PROXIES=
# 注意:minioadmin/minioadmin 仅为本机冒烟默认值,对外部署必须修改
# (并在 compose 中删除 minio 的 ports 发布或仅绑定 127.0.0.1
FCB_S3_ENDPOINT_URL=http://minio:9000
FCB_S3_BUCKET_NAME=filecodebox
FCB_S3_BUCKET_NAME=fileshare
FCB_S3_ACCESS_KEY_ID=minioadmin
FCB_S3_SECRET_ACCESS_KEY=minioadmin
FCB_S3_REGION_NAME=us-east-1
+16 -5
View File
@@ -2,14 +2,23 @@
# 1) node:20-alpine 构建 Vue 前端产物(web/dist
# 2) golang:1.27.1-alpine 编译 servergo:embed 前端产物,缓存指向 /tmp 避免写权限问题)
# 3) 最小运行时镜像:server 二进制 + web/dist 静态产物
FROM node:20-alpine AS frontend
# 基础镜像可通过 build-arg 覆盖:CI/受限网络下指向 ACR 命名空间镜像
# --build-arg NODE_IMAGE=… GO_IMAGE=… RUNTIME_IMAGE=… APK_MIRROR=mirrors.aliyun.com
ARG NODE_IMAGE=node:20-alpine
ARG GO_IMAGE=golang:1.27.1-alpine
ARG RUNTIME_IMAGE=alpine:3.20
# NPM_REGISTRYCI/受限网络下注入 npm 镜像源(默认官方源)
ARG NPM_REGISTRY=https://registry.npmjs.org
FROM ${NODE_IMAGE} AS frontend
ARG NPM_REGISTRY
WORKDIR /build
RUN npm config set registry "$NPM_REGISTRY"
COPY web/package.json web/package-lock.json* ./
RUN npm ci || npm install
COPY web/ ./
RUN npm run build
FROM golang:1.27.1-alpine AS backend
FROM ${GO_IMAGE} AS backend
WORKDIR /build
# GOPROXY:默认走 goproxy.cnproxy.golang.org 的 GCS 后端在本环境网络下 EOF/超时),direct 兜底
ENV CGO_ENABLED=0 GOSUMDB=off GOPROXY=https://goproxy.cn,direct GOCACHE=/tmp/.gocache GOMODCACHE=/tmp/.gomodcache
@@ -30,9 +39,11 @@ RUN if [ -f web-embed/index.html ]; then \
&& rm -rf web-embed frontend-dist
RUN go build -trimpath -ldflags "-s -w" -o /out/server ./cmd/server
FROM alpine:3.20
# curl 供容器健康检查使用
RUN apk add --no-cache ca-certificates tzdata curl && adduser -D -u 10001 fcb
FROM ${RUNTIME_IMAGE}
# APK_MIRROR:受限网络下注入 alpine 镜像源(空=默认源)
ARG APK_MIRROR=""
RUN if [ -n "$APK_MIRROR" ]; then sed -i "s|https://dl-cdn.alpinelinux.org|$APK_MIRROR|g" /etc/apk/repositories; fi \
&& apk add --no-cache ca-certificates tzdata curl && adduser -D -u 10001 fcb
WORKDIR /app
COPY --from=backend /out/server /app/server
# dist 随镜像分发(服务端已 go:embed,磁盘副本供运维检查/替换)
+9 -7
View File
@@ -1,11 +1,13 @@
# 文件快传 部署编排(deploy/)
Go 1.27.1Gin + GORM+ Vue 3 重写版(v3.1)的容器化部署。数据库**默认 SQLite,零外部依赖**
Go 1.27.1Gin + GORM+ Vue 3 重写版(26.9)的容器化部署。数据库**默认 SQLite,零外部依赖**
modernc.org/sqlite 纯 Go 驱动,无需 Postgres),可选切换 Postgres`--profile postgres`);
Redis 为可选增强(`--profile redis`),未配置 `FCB_REDIS_ADDR` 时服务端自动降级为进程内存缓存。
> v3.1 功能提示:管理后台可设「站点对外域名」(内网部署生成公网分享链接)、
> 26.9 功能提示:管理后台可设「站点对外域名」(内网部署生成公网分享链接)、
> 分享时支持自定义提取码(4-8 位字母数字);这些均为运行时配置,无需改部署。
> 26.9 新增:管理后台可设「上行/下行带宽」限速(字节/秒,0=不限速),立即生效。
> S3 预签名直传(客户端→S3)服务端无法介入限速,其余下载/上传路径均覆盖。详见《[带宽限速](../docs/api/13-bandwidth.md)》。
## compose 服务与 profile 一览
@@ -30,7 +32,7 @@ docker compose up -d --build
# 打开 http://localhost:8466.env 可用 WEB_PORT 改端口)→ 自动跳转 /setup 完成初始化
```
- 数据落 **`serverdata` 卷**:容器内 `/app/data/filecodebox.db`WAL 模式,父目录自动创建)。
- 数据落 **`serverdata` 卷**:容器内 `/app/data/fileshare.db`WAL 模式,父目录自动创建)。
- `.env` 中 Postgres 段变量(`POSTGRES_USER/PASSWORD/DB`)在此模式下不生效,无需修改。
### 路径 ②:Postgres 模式(可选)
@@ -39,7 +41,7 @@ docker compose up -d --build
```dotenv
FCB_DB_DRIVER=postgres
FCB_DB_DSN=postgres://filecodebox:filecodebox@postgres:5432/filecodebox?sslmode=disable
FCB_DB_DSN=postgres://fileshare:fileshare@postgres:5432/fileshare?sslmode=disable
```
2. 带 postgres profile 启动(server 会等 postgres 健康检查通过后再启动):
@@ -65,13 +67,13 @@ docker compose up -d --build
```bash
# S3 引擎(MinIO 冒烟)
docker compose --profile minio up -d --build # .env: FCB_STORAGE_ENGINE=s3
# minio-init 一次性任务自动建桶(mc mb filecodebox
# minio-init 一次性任务自动建桶(mc mb fileshare
# WebDAV 引擎(dufs 冒烟,Basic 认证 admin/admin123@/:rw
docker compose --profile webdav up -d --build # .env: FCB_STORAGE_ENGINE=webdav
```
> **v3 起支持运行时热切换**:也可不改 `.env`,直接在管理后台「系统设置 → 存储引擎」
> **26.9 起支持运行时热切换**:也可不改 `.env`,直接在管理后台「系统设置 → 存储引擎」
> 三选并保存引擎参数后点切换(健康检查通过才生效,失败保持原引擎),或调用
> `POST /admin/storage/switch`——均无需重启容器。`.env` 的 `FCB_STORAGE_ENGINE`
> 仅作为首次启动(KV 为空时)的默认引擎。
@@ -125,7 +127,7 @@ curl -I http://localhost:8466/docs # 200SPA 回退)
或在 `.env` 设 `FCB_ADMIN_PASSWORD`(≥8 位)让服务启动即自动初始化,
消除「公网上被抢先访问 /setup 接管」的窗口。初始化完成后建议从 `.env` 移除该变量。
3. **修改组件默认凭据**minio`minioadmin/minioadmin`)、webdav`admin/admin123`)、
Postgres`filecodebox/filecodebox`)默认凭据仅限本机冒烟;对外部署必须修改,
Postgres`fileshare/fileshare`)默认凭据仅限本机冒烟;对外部署必须修改,
且不建议把 9000/9001/5005 端口发布到公网(compose 中删除对应 `ports` 或仅绑 127.0.0.1)。
4. **Postgres 建议启用 TLS**:默认示例 DSN 为 `sslmode=disable`,生产请改为
`sslmode=require` 及以上。
+9 -9
View File
@@ -5,10 +5,10 @@
# docker compose --profile minio up -d # 可选:S3 引擎冒烟(FCB_STORAGE_ENGINE=s3
# docker compose --profile webdav up -d # 可选:WebDAV 引擎冒烟(FCB_STORAGE_ENGINE=webdav
#
# 数据库默认 SQLitemodernc.org/sqlite 纯 Go 驱动,数据落 serverdata 卷 /app/data/filecodebox.db);
# 数据库默认 SQLitemodernc.org/sqlite 纯 Go 驱动,数据落 serverdata 卷 /app/data/fileshare.db);
# postgres 为可选 profile 服务,启用时 server 经 FCB_DB_DRIVER/FCB_DB_DSN 自动接入;
# Redis 同为可选,未配置时服务端内存降级。
name: filecodebox
name: fileshare
services:
postgres:
@@ -18,13 +18,13 @@ services:
# 需要时 docker compose --profile postgres up -d --build
profiles: ["postgres"]
environment:
POSTGRES_USER: ${POSTGRES_USER:-filecodebox}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-filecodebox}
POSTGRES_DB: ${POSTGRES_DB:-filecodebox}
POSTGRES_USER: ${POSTGRES_USER:-fileshare}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fileshare}
POSTGRES_DB: ${POSTGRES_DB:-fileshare}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-filecodebox}"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fileshare}"]
interval: 5s
timeout: 3s
retries: 20
@@ -52,7 +52,7 @@ services:
start_period: 5s
environment:
# 需求 ⑧:数据库驱动 sqlite(默认,零依赖)| postgres(需 --profile postgres
# 默认 SQLiteDSN 留空 → 数据库文件落 /app/data/filecodebox.dbserverdata 卷)
# 默认 SQLiteDSN 留空 → 数据库文件落 /app/data/fileshare.dbserverdata 卷)
# postgres 模式:在 .env 设 FCB_DB_DRIVER=postgres 与 FCB_DB_DSN(模板见 deploy/.env.example
FCB_DB_DRIVER: ${FCB_DB_DRIVER:-sqlite}
FCB_DB_DSN: ${FCB_DB_DSN:-}
@@ -67,7 +67,7 @@ services:
FCB_LOCAL_STORAGE_PATH: /app/data
# S3 引擎(MinIO profile
FCB_S3_ENDPOINT_URL: ${FCB_S3_ENDPOINT_URL:-}
FCB_S3_BUCKET_NAME: ${FCB_S3_BUCKET_NAME:-filecodebox}
FCB_S3_BUCKET_NAME: ${FCB_S3_BUCKET_NAME:-fileshare}
FCB_S3_ACCESS_KEY_ID: ${FCB_S3_ACCESS_KEY_ID:-minioadmin}
FCB_S3_SECRET_ACCESS_KEY: ${FCB_S3_SECRET_ACCESS_KEY:-minioadmin}
FCB_S3_REGION_NAME: ${FCB_S3_REGION_NAME:-us-east-1}
@@ -122,7 +122,7 @@ services:
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 ${FCB_S3_ACCESS_KEY_ID:-minioadmin} ${FCB_S3_SECRET_ACCESS_KEY:-minioadmin} &&
mc mb --ignore-existing local/${FCB_S3_BUCKET_NAME:-filecodebox} &&
mc mb --ignore-existing local/${FCB_S3_BUCKET_NAME:-fileshare} &&
echo 'MinIO 桶已就绪'"
# WebDAV 冒烟服务器(dufs,简单读写 + Basic 认证)
+18 -2
View File
@@ -1,7 +1,14 @@
# API 概述
文件快传 Go 版(v2.5.6)对外提供一套 REST API,覆盖文本/文件分享、分片上传、
预签名直传、管理后台与审计日志查询。本文档与 `server/internal/api/` 实际实现逐一对齐,
文件快传 Go 版(26.9)对外提供一套 REST API,覆盖文本/文件分享、分片上传、
预签名直传、管理后台与审计日志查询。
## 更新日志
- **26.9**:上下行带宽限速(`upload_rate` / `download_rate`,详见《[带宽限速](13-bandwidth.md)》)、
站点对外域名(`site_domain`)、自定义提取码(5~8 位)
本文档与 `server/internal/api/` 实际实现逐一对齐,
交互式规范见站内 `/openapi`(源文件 `docs/openapi.yaml`)。
## Base URL
@@ -55,6 +62,7 @@
| `error` | 取件失败(404/过期)时计数 | 10 次 / 1 分钟 | `errorCount` / `errorMinute` |
| `login` | 登录失败时计数 | 5 次 / 15 分钟 | `loginCount` / `loginMinute` |
| `metadata` | 每次访问即计数 | 同 `error` | `errorCount` / `errorMinute` |
| `bandwidth` | 每 Read 块按 `rate` 字节/秒对齐 sleep,0=不限速 | 0 / 0(不限) | `upload_rate` / `download_rate` |
## 初始化守卫
@@ -86,6 +94,14 @@
| 管理后台 | `POST /admin/login` · `GET /admin/verify` · `POST /admin/logout` · `GET /admin/dashboard` · 文件管理 `/admin/file/*` · 配置 `/admin/config/*` · 密码 `/admin/settings/password` |
| 审计日志 | `GET /admin/audit/list`(别名 `/admin/audit/logs` |
## 专题文档
| 主题 | 文档 |
|---|---|
| 站点 Logo 自定义 | [12-logo.md](12-logo.md) |
| 错误码 | [11-errors.md](11-errors.md) |
| 带宽限速(26.9 | [13-bandwidth.md](13-bandwidth.md) |
## 时间与编码
- 时间字段一律 RFC 3339(如 `2025-06-01T12:00:00+08:00`);管理员会话过期时间为 Unix 秒。
+1 -1
View File
@@ -9,7 +9,7 @@
## 管理员令牌
-`POST /admin/login` 用管理员密码换取,HS256 JWT,默认有效期 **7 天**`adminSessionExpire`1~365 整天,v2.5.6 起由 30 天缩短)。
-`POST /admin/login` 用管理员密码换取,HS256 JWT,默认有效期 **7 天**`adminSessionExpire`1~365 整天,26.9 起由 30 天缩短)。
- 请求头格式:`Authorization: Bearer <token>`
- **改密/重置管理员密码会轮换 `jwt_secret`,所有已签发令牌立即失效**(401)。
- 密码存储为 bcryptcost 12);历史 `sha256$`/明文格式在登录成功后自动升级重哈希,无需手动迁移。
+2 -2
View File
@@ -18,7 +18,7 @@
- `day`/`hour`/`minute`:按时间过期,`expired_count = -1`
- `count`:按次数过期,取件 `expire_value` 次后失效(`expired_count = expire_value`);
**v2 需求 ④**`max_save_count>0``expire_value` 不得超出该上限,超限 403。
**26.9 需求 ④**`max_save_count>0``expire_value` 不得超出该上限,超限 403。
- `forever`:永久(需站点允许;`max_save_seconds>0` 时其他方式受最长保存上限约束,超限 403)。
> 可选值与上限来自公开配置 `GET /api/v1/config``expireStyle`、`max_save_seconds`、
@@ -88,4 +88,4 @@ curl -s "http://localhost:8466/share/select?code=8XQ2M"
你好,文件快传
```
**v3.1 变更**:① 支持 JSON 提交(`Content-Type: application/json`,字段同名);② 空文本 400「分享内容不能为空」;③ 可选 `code` 自定义提取码(5-8 位字母数字,占用 400)。
**26.9 变更**:① 支持 JSON 提交(`Content-Type: application/json`,字段同名);② 空文本 400「分享内容不能为空」;③ 可选 `code` 自定义提取码(5-8 位字母数字,占用 400)。
+29 -1
View File
@@ -39,7 +39,7 @@ curl -s -X POST http://localhost:8466/share/file \
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
```
> 大小上限为动态策略(v2 需求 ④⑩):管理端改 `max_file_size`0=回落 `uploadSize`)后
> 大小上限为动态策略(26.9 需求 ④⑩):管理端改 `max_file_size`0=回落 `uploadSize`)后
> **下一次上传立即按新上限执行**,无需重启;上限值可经 `GET /api/v1/config` 的
> `max_file_size`/`maxFileSize` 字段读取。
@@ -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` 会剥离文件名中的路径分隔符,多级路径无法成体保存。
+1 -1
View File
@@ -130,7 +130,7 @@ curl -s -X POST http://localhost:8466/chunk/upload/3f6b8c2a4d5e6f708192a3b4c5d6e
> 约束:单分片 ≤ `chunk_size`init 声明值)且 ≤ **32MiB 硬上限**init 时 `chunk_size>33554432` 直接 400「chunk_size 过大」);
> 总大小(init 按分片数上限、上传/合并按累计)受**动态策略上限**约束——`max_file_size>0` 时为其,
> 否则回落 `uploadSize`v2 需求 ④⑩,管理端改后立即生效,超限清理会话);首个分片做 magic bytes
> 否则回落 `uploadSize`26.9 需求 ④⑩,管理端改后立即生效,超限清理会话);首个分片做 magic bytes
> 防伪校验(403「文件内容校验失败:…」)。分片哈希由服务端计算;合并时与分片记录交叉校验,不一致报 400。
>
> **enableChunk 开关**:管理端关闭分片上传后,`/chunk/upload/*` 全部端点返回 403「分片上传未启用」(后端强制,前端仅隐藏入口)。
+2 -2
View File
@@ -52,7 +52,7 @@ curl -s -X POST http://localhost:8466/presign/upload/init \
"code": 200, "msg": "ok",
"data": {
"upload_id": "6a1e…",
"upload_url": "https://minio:9000/filecodebox/share/data/2025/06/01/6a1e…/backup.zip?X-Amz-…",
"upload_url": "https://minio:9000/fileshare/share/data/2025/06/01/6a1e…/backup.zip?X-Amz-…",
"mode": "direct",
"expires_in": 900,
"file_path": "share/data/2025/06/01/6a1e…"
@@ -82,7 +82,7 @@ curl -s -X POST http://localhost:8466/presign/upload/init \
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
```
> 大小上限为动态策略(v2 需求 ④⑩):管理端改 `max_file_size`0=回落 `uploadSize`)后
> 大小上限为动态策略(26.9 需求 ④⑩):管理端改 `max_file_size`0=回落 `uploadSize`)后
> 立即按新上限校验 init 声明的 `file_size`。
```json
+23 -6
View File
@@ -318,7 +318,7 @@ curl -s "http://localhost:8466/admin/file/preview?id=41&maxChars=100" -H "Author
返回运行时配置 KV(含默认值与管理端修改)。`admin_token` 恒返回空串(屏蔽);
`jwt_secret` 不下发;存储引擎为进程级单例,`_engine_hint` 提示引擎配置修改需重启。
v2 新增键(需求 ①②③④⑩)一并返回:`background_url``footer_text``footer_beian`
26.9 新增键(需求 ①②③④⑩)一并返回:`background_url``footer_text``footer_beian`
`notify_enabled``max_save_count``max_file_size` 等。
```json
@@ -351,7 +351,7 @@ v2 新增键(需求 ①②③④⑩)一并返回:`background_url`、`foote
}
```
## 存储引擎热切换:POST /admin/storage/switchv3
## 存储引擎热切换:POST /admin/storage/switch26.9
运行时切换存储引擎,**无需重启**:
@@ -374,14 +374,14 @@ curl -s -X POST http://localhost:8466/admin/storage/switch \
部分更新(JSON 对象,未提供的键不变;表单亦可)。**PATCH 为主名,POST 为兼容别名**。
- 仅接受管理端可见键(见上响应键集合),未知键忽略。
- 数值型键自动转型:`openUpload``enableChunk``uploadSize``storageLimit`、限流四组、`max_save_seconds``adminSessionExpire``showAdminAddr`v2 新增 `max_save_count``max_file_size``notify_enabled``opacity` 为浮点。
- **v3.1**`site_domain`(站点对外域名)可经本端点设置,非法格式 400(仅 http/https、主机+端口、不带路径)。
- **v3 引擎键**`storage_engine` 不经本端点修改(走 `POST /admin/storage/switch`);引擎参数键
- 数值型键自动转型:`openUpload``enableChunk``uploadSize``storageLimit`、限流四组、`max_save_seconds``adminSessionExpire``showAdminAddr`26.9 新增 `max_save_count``max_file_size``notify_enabled``opacity` 为浮点。
- **26.9**`site_domain`(站点对外域名)可经本端点设置,非法格式 400(仅 http/https、主机+端口、不带路径)。
- **26.9 引擎键**`storage_engine` 不经本端点修改(走 `POST /admin/storage/switch`);引擎参数键
`local_storage_path``webdav_url``webdav_root_path``webdav_username``webdav_password`
`s3_endpoint_url``s3_region_name``s3_bucket_name``s3_access_key_id``s3_secret_access_key`
`aws_session_token``s3_addressing_style`)可经本端点保存——保存后对应引擎实例缓存失效,
下次切换/构建生效;敏感键空串或 `******` 表示不修改。
- **v2 schema 校验**`settings.KVSchema`,越界一律 400,中文错误信息):
- **26.9 schema 校验**`settings.KVSchema`,越界一律 400,中文错误信息):
- 整型边界:`max_file_size` ≤ 10GiB10737418240)、`max_save_count` ≤ 100000、`max_save_seconds` ≤ 31536000365 天)、`notify_enabled` ∈ {0,1}、`uploadCount` 1~10000、`uploadMinute` 1~1440 等;
- 字符串长度:`background_url` ≤ 2048、`footer_text` ≤ 2000、`footer_beian` ≤ 128、`notify_title` ≤ 128、`notify_content` ≤ 2000 字符;
- 列表键 `expireStyle` / `allowed_file_types`:须为字符串数组(或逗号分隔串)且至少保留一项;
@@ -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`(引用计数开关)。
+1 -1
View File
@@ -13,7 +13,7 @@
| 客户端 | `ip` | 可信代理场景解析 XFF 后的真实 IP |
| 终端信息 | `user_agent` | 原始 UA |
| 设备解析 | `device_os` / `device_browser` / `device_type` | 由 UA 解析(如 Windows/Chrome/desktop |
| 动作 | `action` | `upload`(上传类) / `download`(取件/下载类) / `admin`(管理端敏感操作,v2.5.6 新增) |
| 动作 | `action` | `upload`(上传类) / `download`(取件/下载类) / `admin`(管理端敏感操作,26.9 新增) |
| 结果 | `result` | `success` / `denied`(拒绝:401/403/423/428/ `failed`(失败:其余 4xx/5xx 或业务报错) |
| 字节数 | `size_bytes` | 文件总大小;`transferred_bytes` 实际传输(**Range 下载只计实际区间字节**;下载由中间件自动统计,上传由各 handler 填充) |
| 耗时 | `duration_ms` | 毫秒 |
+2 -2
View File
@@ -14,11 +14,11 @@ FCB_STORAGE_ENGINE=webdav
非法值直接启动失败:`FCB_STORAGE_ENGINE 无效值 "xxx",仅支持 local|s3|webdav`
**v3 运行时热切换**:管理端 `POST /admin/storage/switch`(或后台设置页「存储引擎」卡)可在不重启的情况下切换引擎——
**26.9 运行时热切换**:管理端 `POST /admin/storage/switch`(或后台设置页「存储引擎」卡)可在不重启的情况下切换引擎——
先构建新引擎并健康检查,通过才生效;失败 503 保持原引擎。当前引擎持久化在 settings KV `storage_engine`(空=回落启动值)。
各引擎参数(存储目录/服务地址/存储桶/密钥)同样在后台设置页运行时可改;保存后对应引擎实例缓存失效,下次切换/构建生效。
## 文件归属引擎(v3
## 文件归属引擎(26.9
每条分享记录(`file_codes.engine`)与上传会话(`upload_chunks.engine` / `presign_upload_sessions.engine`
在创建时戳记当时的引擎名。下载、分片合并、删除按**归属引擎**操作——切换引擎后,旧引擎里的文件仍可正常下载与删除
+29 -20
View File
@@ -1,15 +1,15 @@
# 环境变量与配置项
配置分三层:**默认值 → `FCB_*` 环境变量 → 数据库 settings KV(管理端运行时修改)**。
v2 起进程必需的环境变量为空集:数据库默认 **SQLite**modernc.org/sqlite 纯 Go 驱动,零外部依赖,
DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres``FCB_DB_DSN` 必需(需求 ⑧)。
26.9 起进程必需的环境变量为空集:数据库默认 **SQLite**modernc.org/sqlite 纯 Go 驱动,零外部依赖,
DSN 缺省落 `./data/fileshare.db`);`FCB_DB_DRIVER=postgres``FCB_DB_DSN` 必需(需求 ⑧)。
## 环境变量(进程级)
| 变量 | 必需 | 默认 | 说明 |
|---|---|---|---|
| `FCB_DB_DRIVER` | ❌ | `sqlite` | 数据库驱动:`sqlite` / `postgres`(需求 ⑧) |
| `FCB_DB_DSN` | 视驱动 | `./data/filecodebox.db` | postgres:连接串(**必需**,如 `postgres://user:pass@host:5432/filecodebox?sslmode=disable`);sqlite:数据库文件路径(可空,父目录自动创建) |
| `FCB_DB_DSN` | 视驱动 | `./data/fileshare.db` | postgres:连接串(**必需**,如 `postgres://user:pass@host:5432/filecodebox?sslmode=disable`);sqlite:数据库文件路径(可空,父目录自动创建) |
| `FCB_REDIS_ADDR` | ❌ | 空 | Redis 地址(如 `redis:6379`),也支持 `redis://[:password@]host:port[/db]` / `rediss://`(TLS)URL 形式;**空则缓存降级为进程内存实现**(缓存故障时自动降级为进程内限流计数) |
| `FCB_REDIS_DB` | ❌ | `0` | Redis 逻辑库号 0-15;URL 形式地址显式携带 `/N` 时以 URL 为准 |
| `FCB_ADMIN_PASSWORD` | ❌ | 空 | 设置后服务首次启动即自动初始化管理员(≥8 位,不足告警跳过),消除 `/setup` 被抢占窗口;初始化完成后建议移除 |
@@ -26,19 +26,19 @@ DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_
`FCB_WEBDAV_URL``FCB_WEBDAV_USERNAME``FCB_WEBDAV_PASSWORD``FCB_WEBDAV_ROOT_PATH`
部署用编排变量(`deploy/.env.example`):`WEB_PORT`(默认 8466)、
`POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB`(默认 filecodebox,仅 `--profile postgres` 时使用)。
`POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB`(默认 fileshare,仅 `--profile postgres` 时使用)。
## 配置项(settings KV,默认值对齐参考实现)
> 键名/类型/默认值/边界以 `server/internal/config/schema.go` 的 `KVSchema()` 为单一事实来源
> schema 同步测试保证与 defaults() 逐键一致);v2 新增键统一 snake_case。
> schema 同步测试保证与 defaults() 逐键一致);26.9 新增键统一 snake_case。
### 站点信息与展示(需求 ①②③)
| 键 | 类型/边界 | 默认 | 说明 |
|---|---|---|---|
| `site_name` / `name` | string | 文件快传 | 站点名称(`site_name` 优先) |
| `site_domain` | string,≤256 | 空 | **v3.1**:站点对外域名(`http(s)://host[:port]`,不带路径;裸主机自动补 `http://`)。配置后分享链接(结果卡/管理端复制)用该域名生成——内网部署也能把公网链接发出去;留空=用当前访问地址 |
| `site_domain` | string,≤256 | 空 | **26.9**:站点对外域名(`http(s)://host[:port]`,不带路径;裸主机自动补 `http://`)。配置后分享链接(结果卡/管理端复制)用该域名生成——内网部署也能把公网链接发出去;留空=用当前访问地址 |
| `description` | string | 开箱即用的文件快传系统 | 站点描述 |
| `page_explain` | string | (合规声明) | 页面说明文案 |
| `keywords` | string | 文件快传, 文件分享… | SEO 关键词 |
@@ -46,10 +46,10 @@ DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_
| `favicon_url` | string | 空(前端回落本地打包 `/assets/favicon-*.png`,需求 ⑤) | favicon / 备用 Logo |
| `opacity` | float | 0.9 | 界面不透明度 |
| `background` | string | 空 | 背景图 URL(参考实现既有键,v1 兼容保留) |
| `background_url` | string,≤2048 字符 | 空 | **v2 需求 ①**:背景图 URL 或上传后地址(空=主题默认;取值时 legacy `background` 键兜底)。管理端保存时校验协议白名单:仅 `http(s)``data:image/*` 与站内相对路径(防 `javascript:` 注入,非法 400 |
| `footer_text` | string,≤2000 字符 | 空 | **v2 需求 ②**:页脚自定义内容(纯文本或受控 HTML 片段) |
| `footer_beian` | string,≤128 字符 | 空 | **v2 需求 ②**:备案号(如 `京ICP备2024xxxxxx号-1`),展示于页脚 |
| `notify_enabled` | int0/1 | 1 | **v2 需求 ③**:通知开关(1=前台右上角悬浮窗展示 / 0=关闭) |
| `background_url` | string,≤2048 字符 | 空 | **26.9 需求 ①**:背景图 URL 或上传后地址(空=主题默认;取值时 legacy `background` 键兜底)。管理端保存时校验协议白名单:仅 `http(s)``data:image/*` 与站内相对路径(防 `javascript:` 注入,非法 400 |
| `footer_text` | string,≤2000 字符 | 空 | **26.9 需求 ②**:页脚自定义内容(纯文本或受控 HTML 片段) |
| `footer_beian` | string,≤128 字符 | 空 | **26.9 需求 ②**:备案号(如 `京ICP备2024xxxxxx号-1`),展示于页脚 |
| `notify_enabled` | int0/1 | 1 | **26.9 需求 ③**:通知开关(1=前台右上角悬浮窗展示 / 0=关闭) |
| `notify_title` | string,≤128 字符 | 系统通知 | 通知标题 |
| `notify_content` | string,≤2000 字符 | 欢迎使用… | 通知正文(**服务端白名单净化**:仅保留纯文本与 `<a href>` 为 http(s)/站内相对/`#` 锚点的链接,其余标签与事件属性剥离,保存与读取双侧生效) |
| `showAdminAddr` | int(0/1 | 0 | 是否展示后台入口 |
@@ -59,8 +59,8 @@ DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_
| 键 | 类型/边界 | 默认 | 说明 |
|---|---|---|---|
| `max_save_seconds` | int640~31536000 | 0 | 最长保存秒数上限(0=仅默认 7 天兜底;>0 时按时间过期超限 403「限制最长时间为 X,可换用其他方式」)。**v3**:管理界面以「小时/天」下拉单位编辑(≥1 天自动显示天),提交时前端换算为秒——canonical 单位保持秒,接口语义不变 |
| `max_save_count` | int0~100000 | 0 | **v2 新增**:单次分享最大可取(保存)次数上限(0=不限制;`expire_style=count``expire_value` 超上限时 403「限制次数最多为 N 次」) |
| `max_save_seconds` | int640~31536000 | 0 | 最长保存秒数上限(0=仅默认 7 天兜底;>0 时按时间过期超限 403「限制最长时间为 X,可换用其他方式」)。**26.9**:管理界面以「小时/天」下拉单位编辑(≥1 天自动显示天),提交时前端换算为秒——canonical 单位保持秒,接口语义不变 |
| `max_save_count` | int0~100000 | 0 | **26.9 新增**:单次分享最大可取(保存)次数上限(0=不限制;`expire_style=count``expire_value` 超上限时 403「限制次数最多为 N 次」) |
| `expireStyle` | []string | `["day","hour","minute","forever","count"]` | 允许的过期方式白名单(上传时不在白名单 400「过期时间类型错误」) |
### 存储策略(需求 ④⑩)
@@ -68,7 +68,7 @@ DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_
| 键 | 类型/边界 | 默认 | 说明 |
|---|---|---|---|
| `uploadSize` | int641024~10GiB | 1048576010MB) | 单文件大小上限(字节),参考实现语义;`max_file_size=0` 时作为生效上限 |
| `max_file_size` | int640~10GiB | 0 | **v2 新增**:存储策略-单文件上限(字节),0=回落 `uploadSize`;超出 403(文案 humanSize 自适应 B/KB/MB/GB)。**v3**:管理界面以「MB/GB」下拉单位编辑(≥1 GiB 自动显示 GB),提交时前端换算为字节 |
| `max_file_size` | int640~10GiB | 0 | **26.9 新增**:存储策略-单文件上限(字节),0=回落 `uploadSize`;超出 403(文案 humanSize 自适应 B/KB/MB/GB)。**26.9**:管理界面以「MB/GB」下拉单位编辑(≥1 GiB 自动显示 GB),提交时前端换算为字节 |
| `allowed_file_types` | []string | `["*"]` | 允许类型白名单(扩展名/MIME 通配,`*` 不限制;非白名单 403「不允许上传该类型文件」) |
| `storageLimit` | int64,≥0 | 0 | 站点总容量(字节),0=不限制(超限 507) |
| `openUpload` | int0/1) | 1 | 游客上传开关(0 时上传接口要求管理员令牌 403) |
@@ -79,6 +79,15 @@ DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_
| 键 | 类型/边界 | 默认 | 说明 |
|---|---|---|---|
| `uploadCount` / `uploadMinute` | int1~10000 / 1~1440 | 10 / 1 | 窗口内允许上传次数 / 窗口分钟(上传成功才计数,超限 423;管理端修改后运行时同步限流规则,立即生效) |
| `upload_rate` / `download_rate` | int640~1 GiB/s | 0 / 0 | **26.9**:上下行带宽字节/秒,0=不限速;管理端改后立即生效(每请求动态读 KV)。详见《[带宽限速](13-bandwidth.md)》 |
| `recycle_enabled` | 0/1 | 1 | **26.9**:过期分享自动回收开关(定时扫描 + 取件惰性回收) |
| `recycle_interval` | int6460~86400 秒) | 1800 | **26.9**:回收扫描间隔(秒;管理端以分钟展示) |
| `retention_days` | int640~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` | int6460~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 | 登录失败计数 |
@@ -90,9 +99,9 @@ DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_
| `jwt_secret` | 空 | JWT 签名密钥(初始化/改密时自动生成轮换;不下发;`settings.SensitiveKeys` 双模式下一致屏蔽) |
| `adminSessionExpire` | 604800(7 天) | 管理员会话秒数(须 1~365 整天) |
### 存储引擎(v3 运行时可配 + 热切换)
### 存储引擎(26.9 运行时可配 + 热切换)
**`storage_engine`**v3 新增键):string`local|s3|webdav`,默认空=回落启动值 `FCB_STORAGE_ENGINE`
**`storage_engine`**26.9 新增键):string`local|s3|webdav`,默认空=回落启动值 `FCB_STORAGE_ENGINE`
运行时切换走 **`POST /admin/storage/switch`**(JWT 保护):构建新引擎 → 健康检查通过才生效;
失败返回 503「存储引擎切换失败,已保持原引擎: …」且不改 KV。成功后持久化 `storage_engine`,重启沿用。
`GET /api/v1/config` 公开下发 `storage_engine` 当前名(仅名称,任何引擎参数/凭据不下发)。
@@ -114,10 +123,10 @@ DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_
| `webdav_root_path` | `filebox_storage` |
| `webdav_proxy` | 0 |
> 敏感键 `webdav_password` / `s3_secret_access_key` / `aws_session_token`v3 加入 `settings.SensitiveKeys`):
> 敏感键 `webdav_password` / `s3_secret_access_key` / `aws_session_token`26.9 加入 `settings.SensitiveKeys`):
> 管理端 GET 返回掩码 `******`PATCH 时空串或 `******` 表示不修改。直接写库 settings KV 后重启同样生效。
## 策略动态生效机制(v2 需求 ④⑩)
## 策略动态生效机制(26.9 需求 ④⑩)
上传页通过 `GET /api/v1/config``config` 字段读取**当前策略快照**并在范围内渲染选项;
上传链路(`/share/file``/chunk/*``/presign/*`**每次请求实时读取** settings KV 同一组值校验:
@@ -128,7 +137,7 @@ DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_
## 公共配置接口
前端启动时经 `GET /api/v1/config` 获取站点公开配置(无需认证;v2 扩展需求 ①②③④⑩):
前端启动时经 `GET /api/v1/config` 获取站点公开配置(无需认证;26.9 扩展需求 ①②③④⑩):
```json
{
@@ -161,7 +170,7 @@ DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_
"openUpload": true
},
"meta": {
"version": "2.5.6",
"version": "26.9",
"features": { "chunkUpload": false, "guestUpload": true }
}
}
@@ -182,7 +191,7 @@ curl -s http://localhost:8466/api/v1/health
"code": 200, "msg": "ok",
"data": {
"status": "ok",
"version": "2.5.6",
"version": "26.9",
"storage": "local",
"time": "2025-06-01T12:00:00+08:00"
}
+2 -2
View File
@@ -1,13 +1,13 @@
# Logo 自定义
## 默认 Logo(内置,v2 需求 ⑤)
## 默认 Logo(内置,26.9 需求 ⑤)
| 项 | 默认值 | 用途 |
|---|---|---|
| 页面导航 Logo | 前端打包本地资源 `/assets/logo-*.svg`(源:`web/src/assets/brand/logo.svg` | 导航栏 `<img>``config.logo_url` 为空时回落使用 |
| favicon / 备用 Logo | 前端打包本地资源 `/assets/favicon-*.png`(源:`web/src/assets/brand/favicon.png` | `index.html` `<link rel="icon">` + 动态 favicon 回落 |
v2 起默认不再引用远程 URL`GET /api/v1/config``logo_url`/`favicon_url` 默认下发空串,
26.9 起默认不再引用远程 URL`GET /api/v1/config``logo_url`/`favicon_url` 默认下发空串,
前端 `displayLogoUrl`/`displayFaviconUrl` 判空后回落到打包的本地资源。
管理端仍可设置任意 URL 全站替换(三步如下)。
+112
View File
@@ -0,0 +1,112 @@
# 带宽限速(26.9
> 26.9 新增。管理端可独立设置**上行(上传)/ 下行(下载)带宽**,单位字节/秒(前端 UI 友好单位为 MB/s),
> 0=不限速。修改后**立即生效**(每请求动态读取最新 KV,不需重启容器/进程)。
## 适用对象
| 方向 | 范围 | 说明 |
|---|---|---|
| 上行 | `POST /share/file``POST /chunk/upload/{id}/{idx}``POST /chunk/upload`(扁平兼容)、`PUT /presign/upload/proxy/{id}` | 包裹 `c.Request.Body`,对 multipart 解析与表单字段读取天然节流 |
| 下行 | `GET /share/download?key=&code=``GET /share/select?code=`(文件流)、`GET /admin/file/download?id=` | 包裹 `storage.ReadCloser`local/webdav/s3 代理下载),Range 分段也按节流后的字节流推进 |
> **不在限速范围**:S3 预签名**直传**(客户端 → S3 桶直连,**服务端无法介入**);
> 文本分享下载(GET `/share/select` 命中 `text` 字段时直接返回 JSON 字符串,体积通常远小于 1MB)。
## 配置项
| 键 | 类型/边界 | 默认 | 说明 |
|---|---|---|---|
| `upload_rate` | int640 ~ 1 073 741 824,即 1 GiB/s | 0 | 上行字节/秒;0=不限速 |
| `download_rate` | int640 ~ 1 073 741 824 | 0 | 下行字节/秒;0=不限速 |
UI 输入:管理端「系统设置 → 上传频率限制」卡底部两个字段,**MB/s** 整数,0=不限速。
保存时前端把 `MB/s × 1024 × 1024` 换算为字节/秒写入 KV。
## 算法(实现原理)
基于**时间窗的精确调度**,每个 `Read` 调用:
```
start ← 首次 Read 的时刻
bytes ← 累计已读字节(每次 Read 累加返回值 n)
expected = start + bytes / rate // 按限速值推算"应到达时间"
if now < expected:
sleep(expected - now) // 超前则阻塞补齐
return n, err
```
保证长期速率严格 ≤ `rate`,瞬时由调用方 Read 块大小自然突发。**不按字节微睡眠**——避免单次 Read 在低速场景下被调度抖动放大。
> 早期版本按每字节 sleep 1/rate 秒,被 Mac 调度粒度(约 10µs)放大后实际速率只有目标值的 1/3;26.9 修正为整块对齐 sleep。
## 公开接口
`GET /api/v1/config``GET /admin/config/get` 均下发当前生效值(数值型,字节/秒):
```json
{
"data": {
"config": {
"upload_rate": 0,
"download_rate": 0,
...
}
}
}
```
## 修改(管理端)
```bash
# PATCH /admin/config/update
curl -X PATCH http://localhost:8466/admin/config/update \
-H "Authorization: Bearer <admin-jwt>" \
-H "Content-Type: application/json" \
-d '{"upload_rate": 1048576, "download_rate": 524288}'
# ↑ 上行 1 MB/s、下行 512 KB/s
```
边界校验(来自 `settings.KVSchema`,越界 400 中文错误):
- `upload_rate` / `download_rate` 必须是整数,0 ≤ 值 ≤ 1073741824
## 验证(端到端)
```bash
# 1) 设下载限速 200 KB/s
curl -X PATCH http://localhost:8466/admin/config/update \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"download_rate": 204800}' -w '\nHTTP %{http_code}\n'
# 2) 上传 2 MB 测试文件(自动随机取件码或自定义 5~8 位)
curl -X POST http://localhost:8466/share/file -F file=@/tmp/2mb.bin \
-F "expire_value=1" -F "expire_style=day" -F "code="
# 3) 管理员接口下载(不消耗取件次数,便于压测)
ID=$(curl -s "http://localhost:8466/admin/file/list?page=1&size=20" \
-H "Authorization: Bearer <token>" | jq '.data.data[0].id')
time curl -s -o /tmp/dl.bin "http://localhost:8466/admin/file/download?id=$ID" \
-H "Authorization: Bearer <token>"
# 期望 ~10s2 097 152 字节 / 200 000 B/s = 10.486 s
# 实测 10.65s(误差 1.5%,含 HTTP 头/响应开销)
```
> 测完记得恢复:`PATCH /admin/config/update` 设回 0。
## 性能开销
- 0 速率路径:完全透传,无任何额外分配/锁/计时。
- 限速路径:每次 Read 多两次 `time.Now()` 与一次整数除法;`mutex.Lock` 仅在并发复用同一 reader 时阻塞(每请求独立实例,正常无争用)。
## 边界与注意
1. **S3 预签名直传不可限速**:客户端拿到预签名 URL 后直连 S3 桶,不经本服务。`PUT /presign/upload/proxy/{id}`(**代理模式**)仍受上行限速;前端实现可在管理端提示用户这一点。
2. **共享存储后端的"突发"**:令牌桶改为时间窗对齐后,单次 Read 仍可能瞬时突发到调用方请求的块大小(典型 32~64 KB)。需更严格平滑的可在调用方调小读取块大小(如 4 KB)。
3. **代理/反代层限速**:如部署在 Nginx / Cloudflare 后,公网入站还会经过代理的限速/带宽上限;本服务限速只对服务进程侧可见。
4. **Range 多区间**:HTTP Range 单区间请求的字节流仍按限速推进;多区间未实现,回退全量。
5. **重置**:UI 「恢复默认」按钮把两个速率归 0(不限速)。
## 相关
- 配置 KV 概览:见《环境变量与配置》`upload_rate` / `download_rate` 条目
- 管理端修改示例:见《管理后台 API》`PATCH /admin/config/update`
- OpenAPI schema`docs/openapi.yaml``/admin/config/update` requestBody.properties
+49 -2
View File
@@ -93,7 +93,7 @@ paths:
footer_text: { type: string, description: '需求 ② 页脚自定义内容' }
footer_beian: { type: string, description: '需求 ② 备案号' }
storage_engine: { type: string, description: 当前存储引擎名(仅名称,凭据不下发) }
site_domain: { type: string, description: 'v3.1 站点对外域名(空=用当前访问地址)' }
site_domain: { type: string, description: '26.9 站点对外域名(空=用当前访问地址)' }
notify_enabled: { type: integer, enum: [0, 1], description: '需求 ③ 通知开关(1=右上角悬浮窗)' }
notify_title: { type: string }
notify_content: { type: string }
@@ -166,6 +166,16 @@ paths:
loginMinute: { type: integer, default: 15 }
uploadCount: { type: integer, default: 10 }
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~36500=不限制)' }
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 }
@@ -205,7 +215,7 @@ paths:
文本 ≤222KB(请求体全局上限 1MiBContent-Length>441KB 直接 403);上传类接口(成功计数 upload 限流,423 超限),经审计中间件落库。
保存策略(需求 ④):expire_style 须在 expireStyle 白名单(400);count 型受
max_save_count 约束(403「限制次数最多为 N 次」);时间型受 max_save_seconds 约束(403)。
v3.1:支持 JSON 提交(字段同名);空文本 400「分享内容不能为空」;
26.9:支持 JSON 提交(字段同名);空文本 400「分享内容不能为空」;
可选 code 自定义提取码(5-8 位字母或数字,占用 400「该提取码已被占用」)。
requestBody:
content:
@@ -1375,6 +1385,16 @@ paths:
storageLimit: { type: integer, format: int64 }
uploadCount: { type: integer }
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
@@ -1525,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: [管理后台]
+1 -1
View File
@@ -83,7 +83,7 @@
- 位置:`server/internal/api/helpers.go:114-125``validatePickupCode`4 位下限)
- 原状:`code_generate_type=number` 时仅 9 万空间(5 位数字),默认 `errorCount=10/分/IP` 下单 IP 需约 6 天扫完,分布式多 IP 可显著缩短;自定义码允许 4 位(36⁴≈168 万)。
- ✅ 修复:自定义提码最小长度 4 → **5 位**`pickupCodeMinLen=5`36⁵≈6000 万空间);测试 `v31_test.go``TestPickupCodeMinLen` 同步更新。
- ✅ 修复:自定义提码最小长度 4 → **5 位**`pickupCodeMinLen=5`36⁵≈6000 万空间);测试 `custom_code_test.go``TestPickupCodeMinLen` 同步更新。
### L4 `enableChunk` 开关后端不强制 ✅ 已修复
+7 -6
View File
@@ -11,7 +11,7 @@ server/
├── cmd/server/main.go # 入口:装配 配置→DB→缓存→设置→审计→中间件→路由
└── internal/
├── config/ # 配置:FCB_* 环境变量基线 + DB settings KV 运行时覆盖
│ └── schema.go # v2 新配置键 schema(键名/类型/默认值/边界)
│ └── schema.go # 26.9 新配置键 schema(键名/类型/默认值/边界)
├── model/ # GORM 模型 + 双方言 AutoMigrate
├── cache/ # 缓存统一接口:redis.go / memory.go 双实现
├── database/ # 双方言连接与迁移(sqlite 默认 / postgres 可选)
@@ -27,7 +27,7 @@ server/
| 变量 | 必需 | 默认 | 说明 |
|---|---|---|---|
| `FCB_DB_DRIVER` | ❌ | `sqlite` | 数据库驱动:`sqlite` \| `postgres`(需求 ⑧) |
| `FCB_DB_DSN` | 视驱动 | `./data/filecodebox.db` | postgres:连接串(**必需**),如 `postgres://user:pass@host:5432/filecodebox?sslmode=disable`;sqlite:数据库文件路径(可空,自动创建 `data/` 目录) |
| `FCB_DB_DSN` | 视驱动 | `./data/fileshare.db` | postgres:连接串(**必需**),如 `postgres://user:pass@host:5432/fileshare?sslmode=disable`;sqlite:数据库文件路径(可空,自动创建 `data/` 目录) |
| `FCB_REDIS_ADDR` | ❌ | 空 | 为空时缓存降级为内存实现 |
| `FCB_LISTEN` | ❌ | `:8466` | 监听地址 |
| `FCB_STORAGE_ENGINE` | ❌ | `local` | `local` \| `s3` \| `webdav` |
@@ -36,14 +36,14 @@ server/
### 数据库模式(需求 ⑧)
- **SQLite(默认)**`FCB_DB_DRIVER=sqlite`(或缺省)。零 DSN 零依赖启动,数据库文件
默认 `./data/filecodebox.db``FCB_DB_DSN` 可覆盖路径;父目录自动创建)。
默认 `./data/fileshare.db``FCB_DB_DSN` 可覆盖路径;父目录自动创建)。
连接参数:`busy_timeout=10s` + `WAL` 日志模式 + `foreign_keys=1`(通过 DSN pragma 注入)。
- **Postgres(可选)**`FCB_DB_DRIVER=postgres` 且必须提供 `FCB_DB_DSN`,否则启动报错。
连接池沿用 v1 参数(32/8、1h 轮换)。
- 双方言共用 GORM 抽象:AutoMigrate、settings KV、全部业务查询方言无关;
唯一原生 DDLmigrates 台账表)在 `database.createMigratesTable` 内部分支处理。
## v2 新增配置键(internal/config/schema.go 为单一事实来源)
## 26.9 新增配置键(internal/config/schema.go 为单一事实来源)
| 键 | 类型 | 默认 | 说明 |
|---|---|---|---|
@@ -57,6 +57,7 @@ server/
| `max_file_size` | int64 | `0` | 需求 ⑩:存储策略-单文件上限字节(0=回落 `uploadSize`,≤10GiB |
| `uploadSize` / `allowed_file_types` / `storageLimit` / `openUpload` | 既有 | - | 存储策略既有键(语义不变) |
| `uploadCount` / `uploadMinute` | 既有 | `10` / `1` | 上传频率限制(对齐参考 `ip_limit["upload"]` |
| `upload_rate` / `download_rate` | int64 | `0` / `0` | **26.9 带宽限速**:上下行字节/秒(0=不限速,≤1 GiB/s)。管理端改后立即生效(每请求动态读 KV);上传侧 middleware 包裹 `Request.Body`,下载侧在 `serveFile` 包裹 `storage.ReadCloser`;S3 预签名直传(客户端→S3)服务端无法限速。算法为时间窗对齐 sleep(`middleware/bandwidth.go` |
管理端与文档(t2/t4)以 `config.KVSchema()``settings.KVSchema()` re-export)为元数据源;
schema 同步测试保证 `KVSchema()``defaults()` 逐键一致。
@@ -144,13 +145,13 @@ middleware.AuditRecordRequest(c, auditSvc, model.AuditResultSuccess, "")
## 本地开发
```bash
# 默认 SQLite 模式:零依赖,数据库落 ./data/filecodebox.db
# 默认 SQLite 模式:零依赖,数据库落 ./data/fileshare.db
go run ./cmd/server # 启动于 :8466
curl localhost:8466/api/v1/health
# Postgres 模式(可选)
export FCB_DB_DRIVER=postgres
export FCB_DB_DSN='postgres://postgres:postgres@localhost:5432/filecodebox?sslmode=disable'
export FCB_DB_DSN='postgres://postgres:postgres@localhost:5432/fileshare?sslmode=disable'
go run ./cmd/server
# 双方言单测:sqlite 始终执行;postgres 需真实实例(FCB_TEST_PG_DSN 指向测试库)
+26 -15
View File
@@ -15,19 +15,26 @@ import (
"github.com/gin-gonic/gin"
"filecodebox/internal/api"
"filecodebox/internal/audit"
"filecodebox/internal/cache"
"filecodebox/internal/config"
"filecodebox/internal/database"
"filecodebox/internal/janitor"
"filecodebox/internal/middleware"
"filecodebox/internal/settings"
"filecodebox/internal/storage"
"fileshare/internal/api"
"fileshare/internal/audit"
"fileshare/internal/cache"
"fileshare/internal/config"
"fileshare/internal/database"
"fileshare/internal/janitor"
"fileshare/internal/middleware"
"fileshare/internal/settings"
"fileshare/internal/storage"
)
// APP_VERSION 版本号,对齐参考仓库 VERSION。
const APP_VERSION = "2.5.6"
// APP_VERSION 版本号,对齐参考仓库 VERSION。26.9 起改为 var 以便
// goreleaser 通过 -ldflags -X main.APP_VERSION=… 注入发布版本。
//
// 编译时可选注入(goreleaser 触发),不注入则保持默认 26.9。
var (
APP_VERSION = "26.9"
BuildCommit = "dev"
BuildDate = "unknown"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
@@ -98,7 +105,7 @@ func main() {
middleware.LimitMeta: {Count: cfg.GetInt("errorCount"), Window: minutes(cfg.GetInt("errorMinute"))},
})
// 7. 存储引擎:注入配置 → 构造 → 健康预检(需求 ④;v3 包装为可热切换 Manager
// 7. 存储引擎:注入配置 → 构造 → 健康预检(需求 ④;26.9 包装为可热切换 Manager
storage.SetEngineOptions(buildEngineOptions(cfg))
bootStore, err := storage.NewEngine(ctx, cfg.Engine())
if err != nil {
@@ -110,7 +117,7 @@ func main() {
} else {
log.Printf("[boot] 存储引擎 %s 健康检查通过", cfg.Engine())
}
// v3:Manager 包装——保存/读取委托当前引擎;管理端可热切换
// 26.9:Manager 包装——保存/读取委托当前引擎;管理端可热切换
//(构建闭包在每次切换前用最新 KV 刷新 EngineOptions,参数改动即时生效)
store := storage.NewManager(cfg.Engine(), bootStore, func(name string) (storage.Storage, error) {
storage.SetEngineOptions(buildEngineOptions(cfg))
@@ -142,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,
+2 -2
View File
@@ -1,4 +1,4 @@
module filecodebox
module fileshare
go 1.27.1
@@ -13,6 +13,7 @@ require (
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/redis/go-redis/v9 v9.17.0
golang.org/x/crypto v0.56.0
gorm.io/driver/postgres v1.6.2
gorm.io/gorm v1.31.2
)
@@ -66,7 +67,6 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.56.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
+61 -20
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"errors"
"fmt"
"log"
@@ -12,12 +13,13 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"filecodebox/internal/config"
"filecodebox/internal/middleware"
"filecodebox/internal/model"
"filecodebox/internal/response"
"filecodebox/internal/settings"
"filecodebox/internal/storage"
"fileshare/internal/config"
"fileshare/internal/janitor"
"fileshare/internal/middleware"
"fileshare/internal/model"
"fileshare/internal/response"
"fileshare/internal/settings"
"fileshare/internal/storage"
)
// minutesDuration 分钟数转 Duration0 回退 1 分钟,对齐 main.go 语义)。
@@ -78,9 +80,12 @@ func registerAdmin(r *gin.Engine, d *Deps) {
authed.PATCH("/settings/password", d.adminChangePassword)
authed.POST("/settings/password", d.adminChangePassword)
// v3 存储引擎:运行时热切换(健康检查通过才生效,失败保持原引擎)
// 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)
@@ -404,7 +409,7 @@ func buildAdminFileItem(fc *model.FileCodes, now time.Time) gin.H {
"hasDownloadLimit": fc.ExpiredCount >= 0, "has_download_limit": fc.ExpiredCount >= 0,
"isPermanent": fc.ExpiredAt == nil && fc.ExpiredCount < 0, "is_permanent": fc.ExpiredAt == nil && fc.ExpiredCount < 0,
"remainingDownloads": remaining, "remaining_downloads": remaining,
"engine": fc.Engine, // v3:归属引擎(管理端展示/排查用)
"engine": fc.Engine, // 26.9:归属引擎(管理端展示/排查用)
}
if fc.FileHash != nil {
item["fileHash"] = *fc.FileHash
@@ -784,7 +789,7 @@ func (d *Deps) adminFilePreview(c *gin.Context) {
// ============ 配置 ============
// configKeys 管理端可见/可改的配置键(不含 jwt_secretadmin_token 屏蔽展示)。
// v2 新增键(需求 ①②③④⑩):背景图、页脚、通知开关、保存/存储策略、频率限制。
// 26.9 新增键(需求 ①②③④⑩):背景图、页脚、通知开关、保存/存储策略、频率限制。
var configKeys = []string{
"site_name", "name", "description", "page_explain", "keywords",
"notify_title", "notify_content", "notify_enabled", "logo_url", "favicon_url",
@@ -794,10 +799,14 @@ var configKeys = []string{
"code_generate_type", "enableChunk",
"uploadMinute", "uploadCount", "errorMinute", "errorCount",
"loginCount", "loginMinute",
"opacity", "background", "showAdminAddr", "robotsText", "site_domain", // v3.1:站点对外域名
"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",
// v3 存储引擎与引擎参数(热切换;凭据为敏感键,get 掩码/update 空跳过)
// 26.9 存储引擎与引擎参数(热切换;凭据为敏感键,get 掩码/update 空跳过)
"storage_engine",
"local_storage_path",
"webdav_url", "webdav_root_path", "webdav_username", "webdav_password",
@@ -806,7 +815,7 @@ var configKeys = []string{
}
// intConfigKeys 需按 schema 边界校验的整型键(adminConfigUpdate 归一化用)。
// v1 既有键保留原语义;v2 新增键(max_file_size/max_save_count/notify_enabled
// v1 既有键保留原语义;26.9 新增键(max_file_size/max_save_count/notify_enabled
// 的边界来自 settings.KVSchema(单一事实来源在 config/schema.go)。
var intConfigKeys = []string{
"openUpload", "enableChunk", "showAdminAddr", "storageLimit",
@@ -814,6 +823,9 @@ var intConfigKeys = []string{
"loginCount", "loginMinute", "max_save_seconds", "uploadSize",
"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.9hotlink_whitelist 为字符串键)
}
// validateConfigValue 按 settings.KVSchema 校验单个配置值:
@@ -903,7 +915,7 @@ func toStrSlice(v any) []string {
}
// adminConfigGet 读取配置(对齐参考 get_configadmin_token 屏蔽、jwt_secret 不下发)。
// v3:引擎凭据类敏感键返回掩码占位(前端表单"留空=不修改");storage_engine 为当前热切换后的引擎。
// 26.9:引擎凭据类敏感键返回掩码占位(前端表单"留空=不修改");storage_engine 为当前热切换后的引擎。
func (d *Deps) adminConfigGet(c *gin.Context) {
cfg := d.Cfg
out := gin.H{}
@@ -923,7 +935,7 @@ func (d *Deps) adminConfigGet(c *gin.Context) {
}
// jwt_secret 永不下发
delete(out, "jwt_secret")
// 引擎运行时状态(v3:热切换即时生效,无需重启)
// 引擎运行时状态(26.9:热切换即时生效,无需重启)
out["_engine_hint"] = gin.H{
"storage_backend": d.Store.CurrentName(),
"engines": gin.H{"local": true, "s3": true, "webdav": true},
@@ -971,7 +983,7 @@ func (d *Deps) adminConfigUpdate(c *gin.Context) {
if !known {
continue
}
// 类型归一 + schema 边界校验(v2:数值/字符串长度/列表键统一走 KVSchema)
// 类型归一 + schema 边界校验(26.9:数值/字符串长度/列表键统一走 KVSchema)
isInt := false
for _, key := range intConfigKeys {
if key == k {
@@ -1065,7 +1077,7 @@ func (d *Deps) adminConfigUpdate(c *gin.Context) {
dbPatch["jwt_secret"] = settings.GenerateJWTSecret()
}
// v3.1site_domain 规范化(http(s)://host[:port];空=用当前地址)
// 26.9site_domain 规范化(http(s)://host[:port];空=用当前地址)
if raw, ok := dbPatch["site_domain"]; ok {
sv, _ := raw.(string)
normalized, err := normalizeSiteDomain(sv)
@@ -1076,7 +1088,7 @@ func (d *Deps) adminConfigUpdate(c *gin.Context) {
dbPatch["site_domain"] = normalized
}
// v3 引擎键处理:参数键与 storage_engine 分离。
// 26.9 引擎键处理:参数键与 storage_engine 分离。
// 1) storage_engine 只接受合法枚举;
// 2) 敏感凭据键空串/掩码=不修改(避免管理端表单回显把密钥抹掉);
// 3) 先持久化普通键+参数键 → Invalidate 对应引擎缓存 → 再尝试 Switch 新引擎;
@@ -1150,7 +1162,7 @@ func engineOfParamKeys(patch map[string]any) []string {
return out
}
// adminStorageSwitch v3 存储引擎热切换:{engine:"local"|"s3"|"webdav"}。
// adminStorageSwitch 26.9 存储引擎热切换:{engine:"local"|"s3"|"webdav"}。
// 成功:持久化 storage_engine KV 并返回当前引擎;失败:503 且原引擎不变。
func (d *Deps) adminStorageSwitch(c *gin.Context) {
var body struct {
@@ -1298,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 {
// v3:删除走文件归属引擎(旧引擎里的文件也要能删掉)
// 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())
}
}
@@ -1314,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{}
+17 -13
View File
@@ -16,10 +16,10 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"filecodebox/internal/middleware"
"filecodebox/internal/model"
"filecodebox/internal/response"
"filecodebox/internal/storage"
"fileshare/internal/middleware"
"fileshare/internal/model"
"fileshare/internal/response"
"fileshare/internal/storage"
)
// chunkExpireTTL 分片会话保留时长(M5:预留窗口由 24h 缩短为 2h;
@@ -99,7 +99,7 @@ func (d *Deps) chunkInit(c *gin.Context) {
// 服务端按分片数上限校验总大小(防分片声明绕过)
totalChunks := (req.FileSize + chunkSize - 1) / chunkSize
maxPossible := totalChunks * chunkSize
// v2 需求 ④⑩:动态策略校验(max_file_size0=回落 uploadSize
// 26.9 需求 ④⑩:动态策略校验(max_file_size0=回落 uploadSize
if err := d.CurrentUploadPolicy().CheckSize(maxPossible); err != nil {
auditUploadEntry(c, "", safeName, req.FileSize, 0)
auditRecordFailed(c, d.AuditSvc, "文件大小超过限制")
@@ -165,7 +165,7 @@ func (d *Deps) chunkInit(c *gin.Context) {
ChunkHash: req.FileHash,
FileName: safeName,
SavePath: savePath,
Engine: d.Store.CurrentName(), // v3:会话归属引擎(分片/合并全程走同一引擎)
Engine: d.Store.CurrentName(), // 26.9:会话归属引擎(分片/合并全程走同一引擎)
}
if err := d.DB.WithContext(ctx).Create(&session).Error; err != nil {
releaseStorage(ctx, d.DB, resToken)
@@ -389,7 +389,7 @@ func (d *Deps) saveOneChunk(c *gin.Context, ctx context.Context, session *model.
ChunkSize: session.ChunkSize,
FileName: session.FileName,
SavePath: session.SavePath,
Engine: session.Engine, // v3:继承会话引擎
Engine: session.Engine, // 26.9:继承会话引擎
}
if err := d.DB.WithContext(ctx).
Where("upload_id = ? AND chunk_index = ?", session.UploadID, idx).
@@ -444,7 +444,7 @@ func (d *Deps) chunkStatus(c *gin.Context) {
type chunkCompleteRequest struct {
ExpireValue int `json:"expire_value" form:"expire_value"`
ExpireStyle string `json:"expire_style" form:"expire_style"`
Code string `json:"code" form:"code"` // v3.1:自定义提取码(4-8 位字母数字,空=随机)
Code string `json:"code" form:"code"` // 26.9:自定义提取码(4-8 位字母数字,空=随机)
}
// chunkComplete 合并分片并创建分享(对齐参考 complete_upload)。
@@ -489,7 +489,7 @@ func (d *Deps) chunkComplete(c *gin.Context) {
respondError(c, err)
return
}
// v3.1:自定义提取码(合并前校验,失败快速返回)
// 26.9:自定义提取码(合并前校验,失败快速返回)
if err := validatePickupCode(req.Code); err != nil {
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
auditRecordFailed(c, d.AuditSvc, "提取码非法")
@@ -539,7 +539,7 @@ func (d *Deps) chunkComplete(c *gin.Context) {
}
return rec.ChunkHash, nil
}
// v3:合并走会话归属引擎(会话创建时的引擎,即使中途热切换也不受影响)
// 26.9:合并走会话归属引擎(会话创建时的引擎,即使中途热切换也不受影响)
mergeStore, sErr := d.storeFor(session.Engine)
if sErr != nil {
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, session.FileSize)
@@ -556,7 +556,7 @@ func (d *Deps) chunkComplete(c *gin.Context) {
return
}
// 创建分享记录(v3.1:支持自定义提取码)
// 创建分享记录(26.9:支持自定义提取码)
code, err := pickCustomCode(ctx, d.DB, d.Cfg, req.Code)
if err == nil {
fc := model.FileCodes{
@@ -568,7 +568,7 @@ func (d *Deps) chunkComplete(c *gin.Context) {
ExpiredAt: exp.ExpiredAt,
ExpiredCount: exp.ExpiredCount,
UsedCount: exp.UsedCount,
Engine: session.Engine, // v3:归属引擎戳
Engine: session.Engine, // 26.9:归属引擎戳
}
// 拆分路径与文件名(对齐参考:path=dirname(save_path), uuid=basename
dir, name := splitDirBase(session.SavePath)
@@ -578,7 +578,11 @@ func (d *Deps) chunkComplete(c *gin.Context) {
fc.Prefix = trimExt(name)
fc.Suffix = ext
err = d.DB.WithContext(ctx).Create(&fc).Error
err = mapCodeConflict(err) // v3.1
err = mapCodeConflict(err) // 26.9
if err == nil {
// 26.9:SHA512 内容去重(命中则复用旧对象并删除本次副本)
d.applyDedup(ctx, mergeStore, session.SavePath, &fc)
}
}
if err == nil {
// 成功:清理分片与记录(走归属引擎)
@@ -1,6 +1,6 @@
package api
// v3.1:自定义提取码与站点域名单元测试。
// 26.9:自定义提取码与站点域名单元测试。
import (
"encoding/json"
@@ -13,7 +13,7 @@ import (
"github.com/gin-gonic/gin"
)
// postForm 以 urlencoded 表单调用 handlerv3.1 测试辅助)。
// postForm 以 urlencoded 表单调用 handler26.9 测试辅助)。
func postForm(d *Deps, path string, fields map[string]string) *httptest.ResponseRecorder {
form := url.Values{}
for k, v := range fields {
+80
View File
@@ -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
}
}
+14 -14
View File
@@ -30,12 +30,12 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"filecodebox/internal/audit"
"filecodebox/internal/config"
"filecodebox/internal/middleware"
"filecodebox/internal/model"
"filecodebox/internal/response"
"filecodebox/internal/storage"
"fileshare/internal/audit"
"fileshare/internal/config"
"fileshare/internal/middleware"
"fileshare/internal/model"
"fileshare/internal/response"
"fileshare/internal/storage"
)
// apiError 统一的业务错误:handler 返回该错误并由 respondError 映射 HTTP 状态。
@@ -125,7 +125,7 @@ func generateCode(style string) string {
return string(b)
}
// ============ 自定义提取码(v3.1,防撞库)============
// ============ 自定义提取码(26.9,防撞库)============
const (
// pickupCodeMinLen 最小长度:L3 由 4 提升至 5(4 位码空间仅 168 万,
@@ -172,7 +172,7 @@ func pickCustomCode(ctx context.Context, db *gorm.DB, cfg *config.Config, custom
return custom, nil
}
// mapCodeConflict v3.1:分享记录创建失败时,若是自定义码唯一索引冲突(并发兜底,
// mapCodeConflict 26.9:分享记录创建失败时,若是自定义码唯一索引冲突(并发兜底,
// pickCustomCode 的预查重未覆盖竞态),转为友好 400;其余错误原样返回。
func mapCodeConflict(err error) error {
if err == nil {
@@ -188,9 +188,9 @@ func mapCodeConflict(err error) error {
return err
}
// ============ 站点对外域名(v3.1============
// ============ 站点对外域名(26.9============
// SiteDomain 站点对外域名规范化(v3.1):空串合法(分享链接用当前访问地址)。
// SiteDomain 站点对外域名规范化(26.9):空串合法(分享链接用当前访问地址)。
// 接受 http(s)://host[:port] 或裸 host[:port](自动补 http://,内网场景)。
// 拒绝路径/查询/片段/用户信息/非 http(s) 协议/非法主机字符(防 javascript: 注入分享链接)。
var siteDomainHostRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$`)
@@ -275,7 +275,7 @@ type expireResult struct {
// resolveExpire 校验 expire_style 白名单并计算过期信息。
// 对齐参考:max_save_seconds>0 时为最长保存上限(超限 403),否则默认 7 天上限;
// v2 需求 ④:style=count 时 expire_value 不得超出 max_save_count0=不限制,超限 403)。
// 26.9 需求 ④:style=count 时 expire_value 不得超出 max_save_count0=不限制,超限 403)。
func resolveExpire(cfg *config.Config, expireValue int, expireStyle string) (*expireResult, error) {
allowed := cfg.ExpireStyle()
okStyle := false
@@ -358,7 +358,7 @@ func formatDurationCN(d time.Duration) string {
// ============ 存储路径 / 容量预留 ============
// storeFor v3:按归属引擎取存储实例(空戳/未知名回落当前引擎,兼容历史数据)。
// storeFor 26.9:按归属引擎取存储实例(空戳/未知名回落当前引擎,兼容历史数据)。
func (d *Deps) storeFor(engine string) (storage.Storage, error) {
if engine == "" || !storage.ValidEngine(engine) {
return d.Store, nil
@@ -656,7 +656,7 @@ func readMultipartHeader(f multipart.File, n int64) []byte {
// ============ 杂项 ============
// humanSize 把字节数转成人类可读描述(B/KB/MB/GB 自适应;
// v2 需求④:max_file_size 支持子 MB 上限,固定 MB 格式会显示 0.00 MB)。
// 26.9 需求④:max_file_size 支持子 MB 上限,固定 MB 格式会显示 0.00 MB)。
func humanSize(n int64) string {
const kb, mb, gb = int64(1024), int64(1024 * 1024), int64(1024 * 1024 * 1024)
switch {
@@ -728,7 +728,7 @@ func bindJSONOrForm(c *gin.Context, obj any) error {
}
return nil
}
// v3.1.1 兼容归一化:旧前端 bundlefetch 字符串 body 默认 text/plain)发的
// 26.9 兼容归一化:旧前端 bundlefetch 字符串 body 默认 text/plain)发的
// 是 text/plain + urlencoded 格式。此类请求改写 Content-Type 后走表单绑定,
// 否则 ShouldBind 对 text/plain 不解析,非空字段全部丢失。
base := ct
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"testing"
"time"
"filecodebox/internal/config"
"filecodebox/internal/storage"
"fileshare/internal/config"
"fileshare/internal/storage"
)
// newTestConfig 构造测试配置(defaults 基线,无 KV 覆盖;需求 ⑧ 默认 sqlite,无需真实数据库)。
+1 -1
View File
@@ -1,4 +1,4 @@
// policy.go — v2 上传策略统一读取与校验(需求 ④⑩)。
// policy.go — 26.9 上传策略统一读取与校验(需求 ④⑩)。
//
// 管理端在后台设置页修改策略(settings KVt1 schema)后,上传链路
// share/file、chunk、presign)每次请求实时读取当前策略并动态校验:
+13 -13
View File
@@ -15,13 +15,13 @@ import (
"github.com/gin-gonic/gin"
"filecodebox/internal/audit"
"filecodebox/internal/cache"
"filecodebox/internal/config"
"filecodebox/internal/database"
"filecodebox/internal/middleware"
"filecodebox/internal/settings"
"filecodebox/internal/storage"
"fileshare/internal/audit"
"fileshare/internal/cache"
"fileshare/internal/config"
"fileshare/internal/database"
"fileshare/internal/middleware"
"fileshare/internal/settings"
"fileshare/internal/storage"
)
// ============ 测试环境装配(真实 sqlite + 内存缓存 + 本地存储)============
@@ -55,7 +55,7 @@ func newPolicyTestDeps(t *testing.T) *Deps {
if err != nil {
t.Fatalf("storage.NewLocalStorage: %v", err)
}
// v3:包装为 Managerbuild 直接返回 local 实例,测试无需真实多引擎)
// 26.9:包装为 Managerbuild 直接返回 local 实例,测试无需真实多引擎)
storeMgr := storage.NewManager("local", store, func(string) (storage.Storage, error) {
return storage.NewLocalStorage(filepath.Join(dir, "storage"))
})
@@ -141,13 +141,13 @@ func respBody(t *testing.T, w *httptest.ResponseRecorder) (code int, data map[st
// pngMagic 最小合法 PNG 头(magic 校验可识别)。
var pngMagic = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
// ============ ① 公开 configv2 展示与策略字段下发 ============
// ============ ① 公开 config26.9 展示与策略字段下发 ============
// TestPublicConfigV2Fields 验证 /api/v1/config 下发背景/页脚/备案/通知与策略范围,
// 且响应不包含任何敏感键(admin_token/jwt_secret)。
func TestPublicConfigV2Fields(t *testing.T) {
d := newPolicyTestDeps(t)
// 管理端先设置 v2 展示字段
// 管理端先设置 26.9 展示字段
if w := patchConfig(d, map[string]any{
"background_url": "https://cdn.example.com/bg.png",
"footer_text": "自定义页脚内容",
@@ -204,9 +204,9 @@ func TestPublicConfigV2Fields(t *testing.T) {
}
}
// ============ ② 管理端 get/updatev2 键全链路 + 类型范围校验 ============
// ============ ② 管理端 get/update26.9 键全链路 + 类型范围校验 ============
// TestAdminConfigV2RoundTrip 验证 v2 新键 update → get → public 的往返,
// TestAdminConfigV2RoundTrip 验证 26.9 新键 update → get → public 的往返,
// 且 admin_token 屏蔽、jwt_secret 不下发。
func TestAdminConfigV2RoundTrip(t *testing.T) {
d := newPolicyTestDeps(t)
@@ -480,7 +480,7 @@ func TestPolicySnapshotMatchesConfig(t *testing.T) {
// 编译期保证 fmt 被使用(测试辅助函数中错误路径占位)。
var _ = fmt.Sprintf
// ============ v3 存储引擎热切换 ============
// ============ 26.9 存储引擎热切换 ============
// switchEngine 调用 POST /admin/storage/switch。
func switchEngine(d *Deps, engine string) *httptest.ResponseRecorder {
+22 -18
View File
@@ -9,10 +9,10 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"filecodebox/internal/middleware"
"filecodebox/internal/model"
"filecodebox/internal/response"
"filecodebox/internal/storage"
"fileshare/internal/middleware"
"fileshare/internal/model"
"fileshare/internal/response"
"fileshare/internal/storage"
)
// presignSessionExpires 预签名会话有效期(对齐参考 PRESIGN_SESSION_EXPIRES=900 秒)。
@@ -51,7 +51,7 @@ type presignInitRequest struct {
FileSize int64 `json:"file_size" form:"file_size"`
ExpireValue int `json:"expire_value" form:"expire_value"`
ExpireStyle string `json:"expire_style" form:"expire_style"`
Code string `json:"code" form:"code"` // v3.1:自定义提取码(init 时校验,完成时落库)
Code string `json:"code" form:"code"` // 26.9:自定义提取码(init 时校验,完成时落库)
}
// presignInit 初始化预签名上传(对齐参考 presign_upload_init):
@@ -74,7 +74,7 @@ func (d *Deps) presignInit(c *gin.Context) {
response.Fail(c, http.StatusBadRequest, "文件名非法")
return
}
// v3.1:自定义提取码提前校验(init 时快速失败;完成请求须再次携带)
// 26.9:自定义提取码提前校验(init 时快速失败;完成请求须再次携带)
if err := validatePickupCode(req.Code); err != nil {
auditRecordFailed(c, d.AuditSvc, "提取码非法")
respondError(c, err)
@@ -86,7 +86,7 @@ func (d *Deps) presignInit(c *gin.Context) {
respondError(c, err)
return
}
// v2 需求 ④⑩:动态策略校验(max_file_size0=回落 uploadSize
// 26.9 需求 ④⑩:动态策略校验(max_file_size0=回落 uploadSize
if err := d.CurrentUploadPolicy().CheckSize(req.FileSize); err != nil {
auditUploadEntry(c, "", safeName, req.FileSize, 0)
auditRecordFailed(c, d.AuditSvc, "文件大小超过限制")
@@ -146,7 +146,7 @@ func (d *Deps) presignInit(c *gin.Context) {
FileSize: req.FileSize,
SavePath: savePath,
Mode: mode,
Engine: d.Store.CurrentName(), // v3:会话归属引擎
Engine: d.Store.CurrentName(), // 26.9:会话归属引擎
ExpireValue: req.ExpireValue,
ExpireStyle: req.ExpireStyle,
ExpiresAt: time.Now().Add(presignSessionExpires * time.Second),
@@ -195,7 +195,7 @@ func (d *Deps) presignProxy(c *gin.Context) {
respondError(c, err)
return
}
// v3.1:自定义提取码随代理上传表单携带(init 时已预校验)
// 26.9:自定义提取码随代理上传表单携带(init 时已预校验)
if err := validatePickupCode(c.PostForm("code")); err != nil {
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
auditRecordFailed(c, d.AuditSvc, "提取码非法")
@@ -234,7 +234,7 @@ func (d *Deps) presignProxy(c *gin.Context) {
if err == nil {
defer func() { _ = f.Close() }()
if err = validateFileMagic(d.Cfg, session.FileName, fh.Header.Get("Content-Type"), readMultipartHeader(f, 64)); err == nil {
// v3:落盘走会话归属引擎
// 26.9:落盘走会话归属引擎
var ps storage.Storage
ps, sErr := d.storeFor(session.Engine)
if sErr != nil {
@@ -259,7 +259,7 @@ func (d *Deps) presignProxy(c *gin.Context) {
releaseStorage(ctx, d.DB, "presign:"+uploadID)
if err != nil {
if ps, sErr := d.storeFor(session.Engine); sErr == nil {
_ = ps.DeleteFile(ctx, session.SavePath) // v3:清理走归属引擎
_ = ps.DeleteFile(ctx, session.SavePath) // 26.9:清理走归属引擎
}
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
auditRecordFailed(c, d.AuditSvc, "创建分享失败")
@@ -299,7 +299,7 @@ func (d *Deps) presignConfirm(c *gin.Context) {
return
}
// v3:直传文件存在性按会话归属引擎检查(直传可能落在旧引擎)
// 26.9:直传文件存在性按会话归属引擎检查(直传可能落在旧引擎)
psCheck, sErr := d.storeFor(session.Engine)
if sErr != nil {
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
@@ -307,7 +307,7 @@ func (d *Deps) presignConfirm(c *gin.Context) {
respondError(c, mapStorageError(sErr))
return
}
// v3.1:自定义提取码随确认请求携带(query 或 JSON/form body,均可选)
// 26.9:自定义提取码随确认请求携带(query 或 JSON/form body,均可选)
customCode := c.Query("code")
if customCode == "" && c.Request.Body != nil && c.Request.ContentLength != 0 {
var fin struct {
@@ -378,7 +378,7 @@ func (d *Deps) presignConfirm(c *gin.Context) {
releaseStorage(ctx, d.DB, "presign:"+uploadID)
if err != nil {
if ps, sErr := d.storeFor(session.Engine); sErr == nil {
_ = ps.DeleteFile(ctx, session.SavePath) // v3:清理走归属引擎
_ = ps.DeleteFile(ctx, session.SavePath) // 26.9:清理走归属引擎
}
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
auditRecordFailed(c, d.AuditSvc, "创建分享失败")
@@ -399,7 +399,7 @@ func (d *Deps) createRecordFromSession(c *gin.Context, session *model.PresignUpl
if err != nil {
return "", err
}
// v3.1:完成请求的自定义提取码兜底校验(init 已验,防只发完成请求绕过)
// 26.9:完成请求的自定义提取码兜底校验(init 已验,防只发完成请求绕过)
if err := validatePickupCode(customCode); err != nil {
return "", err
}
@@ -420,10 +420,14 @@ func (d *Deps) createRecordFromSession(c *gin.Context, session *model.PresignUpl
ExpiredAt: exp.ExpiredAt,
ExpiredCount: exp.ExpiredCount,
UsedCount: exp.UsedCount,
Engine: session.Engine, // v3:归属引擎戳
Engine: session.Engine, // 26.9:归属引擎戳
}
if err := d.DB.WithContext(ctx).Create(&fc).Error; err != nil {
return "", mapCodeConflict(err) // v3.1:并发占用自定义码 → 友好 400
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
}
@@ -474,7 +478,7 @@ func (d *Deps) presignCancel(c *gin.Context) {
return
}
if session.Mode == "direct" {
// v3:清理走会话归属引擎
// 26.9:清理走会话归属引擎
if ps, sErr := d.storeFor(session.Engine); sErr == nil {
if exists, eErr := ps.FileExists(ctx, session.SavePath); eErr == nil && exists {
_ = ps.DeleteFile(ctx, session.SavePath)
+406
View File
@@ -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_atGORM 自动填 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 应 403got %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("非白名单应 403got %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)
}
+18 -14
View File
@@ -6,12 +6,12 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"filecodebox/internal/audit"
"filecodebox/internal/config"
"filecodebox/internal/middleware"
"filecodebox/internal/response"
"filecodebox/internal/settings"
"filecodebox/internal/storage"
"fileshare/internal/audit"
"fileshare/internal/config"
"fileshare/internal/middleware"
"fileshare/internal/response"
"fileshare/internal/settings"
"fileshare/internal/storage"
)
// Deps API 层共享依赖(main.go 装配后注入)。
@@ -21,7 +21,7 @@ type Deps struct {
Mgr *settings.Manager
AuditSvc *audit.Service
Limiter *middleware.RateLimiter
Store *storage.Manager // v3:可热切换引擎管理器(实现 Storage 接口)
Store *storage.Manager // 26.9:可热切换引擎管理器(实现 Storage 接口)
Version string
}
@@ -46,13 +46,14 @@ func Register(r *gin.Engine, d *Deps) {
share := r.Group("/share")
{
share.POST("/text", d.shareText)
share.POST("/file", d.shareFile)
share.POST("/file", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.shareFile)
// metadata:每次访问即计数(RequireRateLimit=进入检查+完成计数)
share.GET("/metadata", d.Limiter.RequireRateLimit(middleware.LimitMeta), d.shareMetadata)
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)
}
// —— 分片上传 ——
@@ -61,8 +62,8 @@ func Register(r *gin.Engine, d *Deps) {
chunk.POST("/upload/init", d.chunkInit)
// 主路径(参考语义):/chunk/upload/{uploadID}/{index}
// 扁平兼容:/chunk/upload + 表单/query 传 upload_id/chunk_index
chunk.POST("/upload/:uploadID/:chunkIndex", d.chunkUpload)
chunk.POST("/upload", d.chunkUploadFlat)
chunk.POST("/upload/:uploadID/:chunkIndex", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.chunkUpload)
chunk.POST("/upload", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.chunkUploadFlat)
chunk.GET("/upload/status/:uploadID", d.chunkStatus)
chunk.POST("/upload/complete/:uploadID", d.chunkComplete)
chunk.DELETE("/upload/:uploadID", d.chunkCancel)
@@ -72,7 +73,7 @@ func Register(r *gin.Engine, d *Deps) {
presign := r.Group("/presign")
{
presign.POST("/upload/init", d.presignInit)
presign.PUT("/upload/proxy/:uploadID", d.presignProxy)
presign.PUT("/upload/proxy/:uploadID", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.presignProxy)
presign.POST("/upload/confirm/:uploadID", d.presignConfirm)
presign.GET("/upload/status/:uploadID", d.presignStatus)
presign.DELETE("/upload/:uploadID", d.presignCancel)
@@ -104,7 +105,7 @@ func (d *Deps) robotsText(c *gin.Context) {
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(d.Cfg.GetString("robotsText")))
}
// publicConfig 公共配置(前端首页/上传页所需;v2 需求 ①②③④⑩ 扩展):
// publicConfig 公共配置(前端首页/上传页所需;26.9 需求 ①②③④⑩ 扩展):
// - 展示字段:站点信息、Logo/favicon、背景图、页脚文案/备案号、通知;
// - 策略范围(上传页动态渲染):大小上限、类型白名单、过期方式、保存
// 时间/次数上限、上传频率(仅范围,不含内部实现键)。
@@ -132,9 +133,12 @@ func (d *Deps) publicConfig(c *gin.Context) {
// 需求 ②:页脚自定义内容与备案号
"footer_text": cfg.FooterText(),
"footer_beian": cfg.FooterBeian(),
// v3:当前存储引擎名(仅名称,任何引擎参数/凭据不下发)
// 26.9:当前存储引擎名(仅名称,任何引擎参数/凭据不下发)
"storage_engine": d.Store.CurrentName(),
"site_domain": d.Cfg.SiteDomain(),
// 26.9:上下行带宽字节/秒(公开下发,0=不限速,方便管理端展示当前值)
"upload_rate": d.Cfg.UploadRate(),
"download_rate": d.Cfg.DownloadRate(),
// 需求 ③:系统通知(开关 + 内容,前台右上角悬浮窗)
// L7:读取侧再做一次白名单净化,覆盖历史存量与直改库的数据
"notify_enabled": boolToInt(cfg.NotifyEnabled()),
+2 -2
View File
@@ -15,8 +15,8 @@ import (
"github.com/gin-gonic/gin"
"filecodebox/internal/model"
"filecodebox/internal/settings"
"fileshare/internal/model"
"fileshare/internal/settings"
)
// postJSON 以 JSON body 调用 POST 端点。
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"github.com/gin-gonic/gin"
"filecodebox/internal/response"
"filecodebox/internal/settings"
"fileshare/internal/response"
"fileshare/internal/settings"
)
// fileSizeUnits 文件大小单位(对齐参考 FILE_SIZE_UNITS)。
+59 -16
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"errors"
"fmt"
"io"
@@ -12,11 +13,12 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"filecodebox/internal/audit"
"filecodebox/internal/middleware"
"filecodebox/internal/model"
"filecodebox/internal/response"
"filecodebox/internal/storage"
"fileshare/internal/audit"
"fileshare/internal/janitor"
"fileshare/internal/middleware"
"fileshare/internal/model"
"fileshare/internal/response"
"fileshare/internal/storage"
)
// nowRFC3339 当前时间的 RFC3339 表示。
@@ -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 := ""
@@ -90,7 +105,7 @@ func (d *Deps) shareText(c *gin.Context) {
if !requireUploadLimit(c, d.Limiter) {
return
}
// v3.1 修复:JSON/表单/ultipart 统一绑定(form+json 双标签——此前仅 PostForm 时,
// 26.9 修复:JSON/表单/ultipart 统一绑定(form+json 双标签——此前仅 PostForm 时,
// JSON 提交会静默存成空文本并 200,取件页空白)。
var body struct {
Text string `json:"text" form:"text"`
@@ -120,7 +135,7 @@ func (d *Deps) shareText(c *gin.Context) {
if expireStyle == "" {
expireStyle = "day"
}
// v3.1:自定义提取码格式校验(4-8 位字母数字,空=随机)
// 26.9:自定义提取码格式校验(4-8 位字母数字,空=随机)
if err := validatePickupCode(body.Code); err != nil {
respondError(c, err)
return
@@ -153,11 +168,11 @@ func (d *Deps) shareText(c *gin.Context) {
ExpiredAt: exp.ExpiredAt,
ExpiredCount: exp.ExpiredCount,
UsedCount: exp.UsedCount,
Engine: d.Store.CurrentName(), // v3:归属引擎戳(文本也记录,保持一致性)
Engine: d.Store.CurrentName(), // 26.9:归属引擎戳(文本也记录,保持一致性)
}
err = d.DB.WithContext(ctx).Create(&fc).Error
}
err = mapCodeConflict(err) // v3.1:自定义码唯一索引冲突 → 友好 400
err = mapCodeConflict(err) // 26.9:自定义码唯一索引冲突 → 友好 400
releaseStorage(ctx, d.DB, token)
if err != nil {
auditRecordFailed(c, d.AuditSvc, "文本分享创建失败")
@@ -188,7 +203,7 @@ func (d *Deps) shareFile(c *gin.Context) {
return
}
origName := fh.Filename
// v2 需求 ④⑩:动态策略校验(max_file_size0=回落 uploadSize
// 26.9 需求 ④⑩:动态策略校验(max_file_size0=回落 uploadSize
if err := d.CurrentUploadPolicy().CheckSize(fh.Size); err != nil {
auditUploadEntry(c, "", origName, fh.Size, 0)
auditRecordFailed(c, d.AuditSvc, "大小超过限制")
@@ -205,7 +220,7 @@ func (d *Deps) shareFile(c *gin.Context) {
return
}
// v3.1:自定义提取码(落盘前校验,失败快速返回不占容量预留)
// 26.9:自定义提取码(落盘前校验,失败快速返回不占容量预留)
if err := validatePickupCode(c.PostForm("code")); err != nil {
auditUploadEntry(c, "", origName, fh.Size, 0)
auditRecordFailed(c, d.AuditSvc, "提取码非法")
@@ -257,12 +272,15 @@ func (d *Deps) shareFile(c *gin.Context) {
ExpiredAt: exp.ExpiredAt,
ExpiredCount: exp.ExpiredCount,
UsedCount: exp.UsedCount,
Engine: d.Store.CurrentName(), // v3:归属引擎戳(下载按此取回)
Engine: d.Store.CurrentName(), // 26.9:归属引擎戳(下载按此取回)
}
if err = d.DB.WithContext(ctx).Create(&fc).Error; err != nil {
err = mapCodeConflict(err) // v3.1
err = mapCodeConflict(err) // 26.9
// 记录创建失败:清理已落盘文件
_ = d.Store.DeleteFile(ctx, savePath)
} else {
// 26.9:SHA512 内容去重(命中则复用旧对象并删除本次副本)
d.applyDedup(ctx, d.Store, savePath, &fc)
}
} else {
// 保存失败:尽力清理半写文件
@@ -273,7 +291,7 @@ func (d *Deps) shareFile(c *gin.Context) {
auditUploadEntry(c, "", origName, fh.Size, 0)
auditRecordFailed(c, d.AuditSvc, "文件保存失败")
if be, ok := err.(*apiError); ok && be.Status == http.StatusBadRequest {
respondError(c, err) // v3.1:提取码冲突等业务 400 原样透出
respondError(c, err) // 26.9:提取码冲突等业务 400 原样透出
} else {
respondError(c, mapStorageError(err))
}
@@ -544,7 +562,7 @@ func (d *Deps) serveFile(c *gin.Context, fc *model.FileCodes) {
savePath := fileSavePath(fc)
name := fc.Prefix + fc.Suffix
// v3:按文件归属引擎取回(切换引擎后旧文件仍可下载);空戳=历史数据回落当前引擎
// 26.9:按文件归属引擎取回(切换引擎后旧文件仍可下载);空戳=历史数据回落当前引擎
store, err := d.storeFor(fc.Engine)
if err != nil {
auditUploadEntry(c, fc.Code, name, fc.Size, 0)
@@ -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 {
@@ -597,7 +638,9 @@ func (d *Deps) serveFile(c *gin.Context, fc *model.FileCodes) {
}
auditUploadEntry(c, fc.Code, name, total, 0)
c.Status(status)
n, _ := io.Copy(c.Writer, dl)
// 26.9:下载带宽限速(storage.ReadCloser → 限速 reader → c.Writer
dlReader := middleware.WrapReadCloser(dl, d.Cfg.DownloadRate())
n, _ := io.Copy(c.Writer, dlReader)
middleware.AuditSet(c, func(e *audit.Entry) {
if e.TransferredBytes == 0 {
e.TransferredBytes = n
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"github.com/gin-gonic/gin"
"filecodebox/internal/response"
web "filecodebox/web"
"fileshare/internal/response"
web "fileshare/web"
)
// registerWeb 注册前端静态资源与 SPA 回退(必须最后注册):
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"gorm.io/gorm"
"filecodebox/internal/model"
"fileshare/internal/model"
)
// Service 审计日志服务。
+99 -6
View File
@@ -18,7 +18,7 @@ const (
AdminSessionExpireMax = 365 * 24 * 60 * 60 // 最大 365 天
// DefaultSQLitePath SQLite 模式默认数据库文件路径(相对运行目录,自动创建 data/)。
DefaultSQLitePath = "./data/filecodebox.db"
DefaultSQLitePath = "./data/fileshare.db"
)
// 数据库驱动常量(需求 ⑧:SQLite 默认、Postgres 可选)。
@@ -28,7 +28,7 @@ const (
)
// DefaultLogoURL / DefaultFaviconURL 默认 Logo 与 favicon(需求 ⑤):
// v2 起默认改用前端打包的本地资源(web/src/assets/brand/logo.svg + favicon.png
// 26.9 起默认改用前端打包的本地资源(web/src/assets/brand/logo.svg + favicon.png
// 经 Vite 产出 /assets/logo-*.svg 与 /assets/favicon-*.png)。此处留空,
// GET /api/v1/config 下发空值时前端 displayLogoUrl/displayFaviconUrl 回落到本地打包资源;
// 管理端仍可设置任意 URL 全站替换。
@@ -62,9 +62,20 @@ func defaults() map[string]any {
"file_storage": "local",
"storage_path": "",
"storageLimit": 0,
// v3:存储引擎运行时可配(热切换);空=沿用 Env.StorageEngine 启动值
// 26.9:存储引擎运行时可配(热切换);空=沿用 Env.StorageEngine 启动值
"storage_engine": "",
"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": "文件快传", // 新增:管理端可自定义
@@ -76,7 +87,7 @@ func defaults() map[string]any {
// 需求 ⑤:默认 Logo 与 favicon(空 = 前端使用打包的本地资源)
"logo_url": DefaultLogoURL,
"favicon_url": DefaultFaviconURL,
// 需求 ①:背景图(v2 新增 background_urlbackground 为参考实现既有键,保留兼容)
// 需求 ①:背景图(26.9 新增 background_urlbackground 为参考实现既有键,保留兼容)
"background": "",
"background_url": "",
// 需求 ②:页脚自定义内容与备案号
@@ -278,7 +289,89 @@ func (c *Config) GetBool(key string) bool {
}
// GetStringSlice 取字符串切片配置。
// SiteDomain 站点对外域名(v3.1):空=分享链接用当前访问地址
// UploadRate 上传带宽字节/秒(26.9,0=不限速)
func (c *Config) UploadRate() int {
v := c.GetInt(KeyUploadRate)
if v < 0 {
return 0
}
return v
}
// DownloadRate 下载带宽字节/秒(26.9,0=不限速)。
func (c *Config) DownloadRate() int {
v := c.GetInt(KeyDownloadRate)
if v < 0 {
return 0
}
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")), "/")
}
@@ -401,7 +494,7 @@ func (c *Config) AdminSessionExpireSeconds() int {
}
// Engine 当前存储引擎。
// Engine 返回当前存储引擎名:KV storage_engine 优先(v3 运行时可改),
// Engine 返回当前存储引擎名:KV storage_engine 优先(26.9 运行时可改),
// 空(未设置/历史数据)回落启动值 Env.StorageEngineenv 校验过的 local|s3|webdav)。
// 枚举校验内联(避免 config→storage 反向依赖)。
func (c *Config) Engine() string {
+45 -13
View File
@@ -1,38 +1,49 @@
// Package config — schema.go 定义 v2 新增配置键(KVschema
// Package config — schema.go 定义 26.9 新增配置键(KVschema
// 键名常量、类型、默认值与取值边界。管理与 API 层(t2)按下表读写与校验,
// 文档(t4)按本表生成说明。键名除参考实现既有 camelCase 键外,
// v2 新增键统一 snake_case。
// 26.9 新增键统一 snake_case。
package config
// —— v2 新增/沿用键名常量(单一事实来源;settings 包会 re-export)——
// 命名规则:v2 新增键 snake_case;与参考实现对齐的既有键保持原拼写。
// —— 26.9 新增/沿用键名常量(单一事实来源;settings 包会 re-export)——
// 命名规则:26.9 新增键 snake_case;与参考实现对齐的既有键保持原拼写。
const (
// 需求 ①:背景图
KeyBackground = "background" // 参考实现既有键(v1 兼容保留)
KeyBackgroundURL = "background_url" // v2 新增:背景图 URL 或上传后的访问地址(空=默认主题)
KeyBackgroundURL = "background_url" // 26.9 新增:背景图 URL 或上传后的访问地址(空=默认主题)
// 需求 ②:页脚
KeyFooterText = "footer_text" // v2 新增:页脚自定义内容(纯文本或受控 HTML 片段)
KeyFooterBeian = "footer_beian" // v2 新增:备案号(如 京ICP备2024xxxxxx号-1
KeyFooterText = "footer_text" // 26.9 新增:页脚自定义内容(纯文本或受控 HTML 片段)
KeyFooterBeian = "footer_beian" // 26.9 新增:备案号(如 京ICP备2024xxxxxx号-1
// 需求 ③:系统通知
KeyNotifyEnabled = "notify_enabled" // v2 新增:通知开关,1 开启 / 0 关闭
KeyNotifyEnabled = "notify_enabled" // 26.9 新增:通知开关,1 开启 / 0 关闭
KeyNotifyTitle = "notify_title" // 既有键:通知标题
KeyNotifyContent = "notify_content" // 既有键:通知内容(允许 <a> 等受控 HTML
// 需求 ④:保存策略(上传页动态读取并在范围内选择)
KeyMaxSaveSeconds = "max_save_seconds" // 既有键:最长保存秒数,0=不限制(仅受默认 7 天兜底)
KeyMaxSaveCount = "max_save_count" // v2 新增:单次分享最大可取(保存)次数上限,0=不限制
KeyMaxSaveCount = "max_save_count" // 26.9 新增:单次分享最大可取(保存)次数上限,0=不限制
KeyExpireStyle = "expireStyle" // 既有键:允许的过期方式白名单
// 需求 ④:上传频率限制(既有键,对齐参考 ip_limit["upload"]
KeyUploadCount = "uploadCount" // 窗口内允许上传次数
KeyUploadMinute = "uploadMinute" // 频率窗口(分钟)
// 需求 ④⑩:存储策略(最大文件大小/允许类型/总容量)
KeyUploadSize = "uploadSize" // 既有键:单文件上限(字节),参考实现语义
KeyMaxFileSize = "max_file_size" // v2 新增:存储策略-单文件上限(字节),0=回落 uploadSize
KeyMaxFileSize = "max_file_size" // 26.9 新增:存储策略-单文件上限(字节),0=回落 uploadSize
KeyAllowedTypes = "allowed_file_types" // 既有键:允许类型白名单("*" 不限制)
KeyStorageLimit = "storageLimit" // 既有键:站点总容量(字节),0=不限制
KeyOpenUpload = "openUpload" // 既有键:游客上传开关
// v3:存储引擎运行时可配(热切换;file_storage 为参考既有键保留兼容)
// 26.9:存储引擎运行时可配(热切换;file_storage 为参考既有键保留兼容)
KeyStorageEngine = "storage_engine" // 当前存储引擎:local|s3|webdav
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 校验用)——
@@ -52,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 与文档生成。
@@ -64,7 +85,7 @@ type KVSchemaEntry struct {
Description string // 中文说明
}
// KVSchema v2 全量配置键 schema 表(含既有策略键,供管理端/文档/AI 校验)。
// KVSchema 26.9 全量配置键 schema 表(含既有策略键,供管理端/文档/AI 校验)。
// 注意:Default 与 config defaults() 逐一对应(schema_test 保证)。
func KVSchema() []KVSchemaEntry {
return []KVSchemaEntry{
@@ -90,8 +111,19 @@ func KVSchema() []KVSchemaEntry {
{KeyAllowedTypes, "[]string", []string{"*"}, -1, -1, "允许上传类型白名单(\"*\" 不限制)"},
{KeyStorageLimit, "int64", int64(0), 0, -1, "站点总容量(字节),0=不限制"},
{KeyOpenUpload, "int", 1, 0, 1, "游客上传开关:1 开 / 0 需管理员登录"},
// —— v3 存储引擎(热切换;引擎参数键沿用 defaults() 既有键,管理端经 config get/update 读写)——
// —— 26.9 存储引擎(热切换;引擎参数键沿用 defaults() 既有键,管理端经 config get/update 读写)——
{KeyStorageEngine, "string", "", 0, 16, "当前存储引擎:local|s3|webdav(热切换,健康检查通过才生效;空=回落启动值 FCB_STORAGE_ENGINE"},
{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 分钟;不超过分享剩余时效)"},
}
}
+4 -4
View File
@@ -35,7 +35,7 @@ func TestKVSchemaNoDuplicates(t *testing.T) {
}
}
// TestV2NewKeysPresent v2 新增键必须在 schema 与 defaults 中同时存在。
// TestV2NewKeysPresent 26.9 新增键必须在 schema 与 defaults 中同时存在。
func TestV2NewKeysPresent(t *testing.T) {
def := defaults()
newKeys := []string{
@@ -44,13 +44,13 @@ func TestV2NewKeysPresent(t *testing.T) {
}
for _, k := range newKeys {
if _, ok := def[k]; !ok {
t.Fatalf("v2 新键 %q 缺少默认值", k)
t.Fatalf("26.9 新键 %q 缺少默认值", k)
}
}
}
// TestV2AccessorDefaults v2 便捷访问器默认语义。
func TestV2AccessorDefaults(t *testing.T) {
// TestKVAccessorDefaults 26.9 便捷访问器默认语义。
func TestKVAccessorDefaults(t *testing.T) {
t.Setenv("FCB_DB_DRIVER", "sqlite")
t.Setenv("FCB_DB_DSN", "")
c, err := New()
+2 -2
View File
@@ -19,8 +19,8 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/logger"
"filecodebox/internal/config"
"filecodebox/internal/model"
"fileshare/internal/config"
"fileshare/internal/model"
)
// Options 连接选项(main.go 从 config.Env 装配)。
+2 -2
View File
@@ -18,8 +18,8 @@ import (
"gorm.io/gorm"
"filecodebox/internal/database"
"filecodebox/internal/model"
"fileshare/internal/database"
"fileshare/internal/model"
)
// pgTestDSN 返回 Postgres 测试连接串;未设置 FCB_TEST_PG_DSN 时返回空。
+123 -8
View File
@@ -1,7 +1,6 @@
// Package janitor 后台清理循环(安全审计 M5):
// 回收过期容量预留、超时未完成的上传会话(含其分片对象)过期预签名会话
// (direct 模式残留对象一并删除)。此前这些资源仅在同 token 复用/显式取消时
// 释放,恶意 init 可长期占用容量预留或累积垃圾数据。
// Package janitor 后台清理循环(安全审计 M5 / 26.9 过期回收):
// 回收过期容量预留、超时未完成的上传会话(含其分片对象)过期预签名会话
// (direct 模式残留对象一并删除),以及过期/超留存期的分享记录与存储对象。
package janitor
import (
@@ -12,8 +11,8 @@ import (
"gorm.io/gorm"
"filecodebox/internal/model"
"filecodebox/internal/storage"
"fileshare/internal/model"
"fileshare/internal/storage"
)
// chunkSessionMaxAge 未完成分片会话的最大保留时长(预留 TTL 为 2h,
@@ -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)
}
}
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"github.com/gin-gonic/gin"
"filecodebox/internal/audit"
"filecodebox/internal/model"
"filecodebox/internal/response"
"fileshare/internal/audit"
"fileshare/internal/model"
"fileshare/internal/response"
)
// auditHooks 审计钩子:由 API 层在响应前后填充与落库。
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"github.com/gin-gonic/gin"
"filecodebox/internal/audit"
"filecodebox/internal/model"
"fileshare/internal/audit"
"fileshare/internal/model"
)
type captureSink struct {
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"github.com/gin-gonic/gin"
"filecodebox/internal/audit"
"filecodebox/internal/model"
"fileshare/internal/audit"
"fileshare/internal/model"
)
// memSink 测试用内存落库实现。
+103
View File
@@ -0,0 +1,103 @@
package middleware
// 26.9:上传/下载带宽限速(字节/秒,0=不限速)。
//
// 设计要点:
// - 令牌桶(token bucket):每 Read 计算自上次起累计可消费字节,
// 未达上限则阻塞至令牌补足;保证长期平均速率,瞬时按桶容量突发。
// - 桶容量 = rate(1 秒突发量):简单且足够友好;后续若要更细粒度可参数化。
// - 0 速率直接透传,不引入任何开销(性能基线保持)。
// - 对 gin 中间件"上传限速":包裹 c.Request.Body,使 multipart 解析/单分片读取
// 都被天然节流;下载侧由 api 层在 storage.Open 之后包裹 ReadCloser。
import (
"io"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"fileshare/internal/response"
)
// rateLimitedReader 按 bytes/sec 限速的 io.Reader(基于时间窗的精确调度)。
// 实现:累计已读字节,按 rate 推算"应到达时间";超前则 sleep 补齐。
// - rate <= 0 时退化为直通;
// - 长期速率严格 ≤ rate,瞬时按调用方 Read 块大小自然突发;
// - 并发安全(每次请求持独立实例即可,但保留锁便于复用)。
type rateLimitedReader struct {
r io.Reader
rate int // 字节/秒
start time.Time // 起始时间(首 Read 时记)
bytes int64 // 累计已读字节
mu sync.Mutex
}
// NewRateLimitedReader 构造限速 readerrate<=0 返回原始 reader。
func NewRateLimitedReader(r io.Reader, rate int) io.Reader {
if r == nil || rate <= 0 {
return r
}
return &rateLimitedReader{r: r, rate: rate}
}
// Read 限速:底层 Read 后若已超时应到达时间,sleep 补齐;错误/EOF 透传。
func (rl *rateLimitedReader) Read(p []byte) (int, error) {
if rl.rate <= 0 {
return rl.r.Read(p)
}
n, err := rl.r.Read(p)
if n <= 0 {
return n, err
}
rl.mu.Lock()
if rl.start.IsZero() {
rl.start = time.Now()
}
rl.bytes += int64(n)
// 已读 n 字节的"应到达时间" = start + bytes/rate
expected := rl.start.Add(time.Duration(float64(time.Second) * float64(rl.bytes) / float64(rl.rate)))
now := time.Now()
rl.mu.Unlock()
if now.Before(expected) {
time.Sleep(expected.Sub(now))
}
return n, err
}
// UploadBandwidthMiddleware 限速上传请求体(包裹 c.Request.Body)。
// rateProvider 每次请求动态读取最新配置(管理端改 KV 后立即生效)。
func UploadBandwidthMiddleware(rateProvider func() int) gin.HandlerFunc {
return func(c *gin.Context) {
rate := 0
if rateProvider != nil {
rate = rateProvider()
}
if rate > 0 && c.Request != nil && c.Request.Body != nil {
c.Request.Body = WrapReadCloser(c.Request.Body, rate)
}
c.Next()
}
}
// WrapReadCloser 包裹任意 io.ReadCloser 为限速版本(io.Copy 等下游使用)。
func WrapReadCloser(rc io.ReadCloser, rate int) io.ReadCloser {
if rc == nil || rate <= 0 {
return rc
}
return &rateLimitedReadCloser{rc: rc, rl: &rateLimitedReader{r: rc, rate: rate}}
}
type rateLimitedReadCloser struct {
rc io.ReadCloser
rl *rateLimitedReader
}
func (r *rateLimitedReadCloser) Read(p []byte) (int, error) { return r.rl.Read(p) }
func (r *rateLimitedReadCloser) Close() error { return r.rc.Close() }
// DownloadRateReporter 限速命中提示:超长下载时可选上报。
// 当前仅占位——真正接入可在 audit 中按"下载耗时/字节比"标记慢速,但用户暂不需要。
var _ = response.Fail // 保留 response 包以备后续扩展
var _ = http.StatusOK
@@ -0,0 +1,81 @@
package middleware
import (
"bytes"
"io"
"testing"
"time"
)
// TestRateLimitedReaderAtRate 验证长读大致符合设定速率。
// 设 100 KB/s,读取 400 KB 总字节,期望耗时约 4s(±1s 容忍)。
func TestRateLimitedReaderAtRate(t *testing.T) {
const rate = 100 * 1024
const total = 400 * 1024
src := io.LimitReader(bytes.NewReader(make([]byte, total+1024)), total)
rl := NewRateLimitedReader(src, rate).(*rateLimitedReader)
buf := make([]byte, 32*1024) // 32KB 块
start := time.Now()
read := 0
for {
n, err := rl.Read(buf)
read += n
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
}
elapsed := time.Since(start)
want := time.Duration(float64(time.Second) * float64(total) / float64(rate))
if elapsed < want-time.Second {
t.Fatalf("读取过快 elapsed=%v want>=%v", elapsed, want)
}
if elapsed > want+1500*time.Millisecond {
t.Fatalf("读取过慢 elapsed=%v want<=%v", elapsed, want+1500*time.Millisecond)
}
if read != total {
t.Fatalf("读到 %d 字节,期望 %d", read, total)
}
}
// TestRateLimitedReaderZeroPassthrough 速率 0 时不应引入任何延迟/包封。
func TestRateLimitedReaderZeroPassthrough(t *testing.T) {
src := bytes.NewReader([]byte("hello"))
rl := NewRateLimitedReader(src, 0)
if rl == src {
// 透传:返回原 reader
} else if _, ok := rl.(*rateLimitedReader); ok {
// 0 速率时按实现可走 enabled=false(不退化亦可)
}
// 关键:必须能读完
b, err := io.ReadAll(rl)
if err != nil || string(b) != "hello" {
t.Fatalf("0 速率透传失败: %q %v", b, err)
}
}
// TestWrapReadCloserClose 验证包裹后 Close 透传到底层。
func TestWrapReadCloserClose(t *testing.T) {
src := &closeCount{Reader: bytes.NewReader([]byte("xyz")), closed: 0}
rc := WrapReadCloser(src, 50*1024)
if rc == nil {
t.Fatal("WrapReadCloser nil")
}
_, _ = io.ReadAll(rc)
if err := rc.Close(); err != nil {
t.Fatalf("close err: %v", err)
}
if src.closed != 1 {
t.Fatalf("底层 Close 未被调用: %d", src.closed)
}
}
type closeCount struct {
io.Reader
closed int
}
func (c *closeCount) Close() error { c.closed++; return nil }
+68
View File
@@ -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
}
+2 -2
View File
@@ -8,7 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"filecodebox/internal/response"
"fileshare/internal/response"
)
// jwtClaims 自定义声明:对齐参考实现(payload 含 is_admin 与 exp)。
@@ -36,7 +36,7 @@ func SignAdminToken(secret string, expires time.Duration) (string, time.Time, er
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "filecodebox",
Issuer: "fileshare",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/gin-gonic/gin"
"filecodebox/internal/response"
"fileshare/internal/response"
)
const testSecret = "unit-test-secret-0123456789abcdef"
+2 -2
View File
@@ -11,8 +11,8 @@ import (
"github.com/gin-gonic/gin"
"filecodebox/internal/cache"
"filecodebox/internal/response"
"fileshare/internal/cache"
"fileshare/internal/response"
)
// 限流类别(对齐参考 apps/base/utils.py 的 ip_limit)。
@@ -8,7 +8,7 @@ import (
"github.com/gin-gonic/gin"
"filecodebox/internal/cache"
"fileshare/internal/cache"
)
// failingCache 模拟缓存故障(Get/Incr 均返回非 ErrNotFound 错误)。
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/gin-gonic/gin"
"filecodebox/internal/cache"
"fileshare/internal/cache"
)
func rateLimitRouter(rl *RateLimiter, kind string) *gin.Engine {
+22 -3
View File
@@ -3,6 +3,7 @@
package model
import (
"strings"
"time"
"gorm.io/gorm"
@@ -23,14 +24,32 @@ type FileCodes struct {
UsedCount int `gorm:"default:0" json:"used_count"` // 已取次数
CreatedAt time.Time `json:"created_at"`
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"` // 归属存储引擎(v3local|s3|webdav;空=历史数据按当前引擎取)
Engine string `gorm:"size:16;default:''" json:"engine"` // 归属存储引擎(26.9local|s3|webdav;空=历史数据按当前引擎取)
}
// 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 {
@@ -55,7 +74,7 @@ type UploadChunk struct {
SavePath string `gorm:"size:512" json:"save_path"`
CreatedAt time.Time `json:"created_at"`
Completed bool `gorm:"default:false" json:"completed"`
Engine string `gorm:"size:16;default:''" json:"engine"` // 归属存储引擎(v3:分片与会话记录当时引擎,合并走同一引擎)
Engine string `gorm:"size:16;default:''" json:"engine"` // 归属存储引擎(26.9:分片与会话记录当时引擎,合并走同一引擎)
}
// TableName 表名。
@@ -84,7 +103,7 @@ type PresignUploadSession struct {
ExpireStyle string `gorm:"size:20;default:day" json:"expire_style"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
Engine string `gorm:"size:16;default:''" json:"engine"` // 归属存储引擎(v3:直传/代理完成走同一引擎取回)
Engine string `gorm:"size:16;default:''" json:"engine"` // 归属存储引擎(26.9:直传/代理完成走同一引擎取回)
}
// TableName 表名。
+3 -3
View File
@@ -10,9 +10,9 @@ import (
"gorm.io/gorm"
"filecodebox/internal/config"
"filecodebox/internal/database"
"filecodebox/internal/settings"
"fileshare/internal/config"
"fileshare/internal/database"
"fileshare/internal/settings"
)
// pgTestDSN 返回 Postgres 测试连接串;未设置 FCB_TEST_PG_DSN 时跳过。
+5 -5
View File
@@ -1,4 +1,4 @@
// Package settings — schema.gov2 配置键 schema 常量与元数据表。
// Package settings — schema.go26.9 配置键 schema 常量与元数据表。
//
// 键名常量的单一事实来源在 internal/config/schema.godefaults() 需引用);
// 本文件 re-export 供 API/管理层使用,并提供「键名/类型/默认值」全量表,
@@ -9,7 +9,7 @@
// 4. schema 同步测试(config schema_test / settings schema_test
package settings
import "filecodebox/internal/config"
import "fileshare/internal/config"
// —— 键名 re-export(与 config 包保持同一字符串,避免魔法值散落)——
const (
@@ -35,7 +35,7 @@ const (
KeyAllowedTypes = config.KeyAllowedTypes
KeyStorageLimit = config.KeyStorageLimit
KeyOpenUpload = config.KeyOpenUpload
// v3 存储引擎
// 26.9 存储引擎
KeyStorageEngine = config.KeyStorageEngine
)
@@ -52,7 +52,7 @@ const (
)
// 敏感键:不允许出现在管理端 config get 下发/前端可见集合中(双模式下一致生效)。
// v3:引擎凭据(webdav_password/s3_secret_access_key/aws_session_token)加入敏感集——
// 26.9:引擎凭据(webdav_password/s3_secret_access_key/aws_session_token)加入敏感集——
// 管理端 get 返回掩码占位,update 时空串/掩码=不修改;公开 config 永不下发。
var SensitiveKeys = []string{
"admin_token", "jwt_secret",
@@ -72,7 +72,7 @@ func IsSensitiveKey(key string) bool {
return false
}
// KVSchema 返回 v2 全量配置键元数据(键名/类型/默认值/边界/说明)。
// KVSchema 返回 26.9 全量配置键元数据(键名/类型/默认值/边界/说明)。
// 默认值必须与 config defaults() 一致(schema 同步测试保证)。
func KVSchema() []config.KVSchemaEntry { return config.KVSchema() }
+2 -2
View File
@@ -12,8 +12,8 @@ import (
"gorm.io/gorm"
"filecodebox/internal/config"
"filecodebox/internal/model"
"fileshare/internal/config"
"fileshare/internal/model"
)
// settingsKey 数据库中的配置键(对齐参考实现)。
+2 -2
View File
@@ -30,10 +30,10 @@ type LocalStorage struct {
rootReal string
}
// NewLocalStorage 构造本地引擎。root 为空时使用系统临时目录下的 filecodebox_storage。
// NewLocalStorage 构造本地引擎。root 为空时使用系统临时目录下的 fileshare_storage。
func NewLocalStorage(root string) (*LocalStorage, error) {
if strings.TrimSpace(root) == "" {
root = filepath.Join(os.TempDir(), "filecodebox_storage")
root = filepath.Join(os.TempDir(), "fileshare_storage")
}
abs, err := filepath.Abs(root)
if err != nil {
+1 -1
View File
@@ -32,7 +32,7 @@ func sha256Hex(b []byte) string {
func TestLocalSaveOpenRange(t *testing.T) {
st := newTestLocal(t)
ctx := context.Background()
data := []byte("hello filecodebox 本地引擎 0123456789")
data := []byte("hello fileshare 本地引擎 0123456789")
n, err := st.SaveFile(ctx, bytes.NewReader(data), "2025/08/测试文件.bin")
if err != nil {
+1 -1
View File
@@ -9,7 +9,7 @@ import (
// Manager 存储引擎管理器:实现 Storage 全接口并支持运行时热切换。
//
// v3 需求:管理后台可设置存储类型(local|s3|webdav)与各引擎参数,
// 26.9 需求:管理后台可设置存储类型(local|s3|webdav)与各引擎参数,
// 保存后无需重启即生效。设计要点:
// - 读写/保存类操作全部委托到"当前引擎"(原子指针,无锁热路径);
// - Switch 先构建并健康检查新引擎,成功才替换指针,失败保持原引擎;
+1 -1
View File
@@ -87,7 +87,7 @@ func TestSwitchSuccessAndCurrentName(t *testing.T) {
}
}
// TestSwitchFailureKeepsCurrent 健康检查失败时保持原引擎(v3 核心语义)。
// TestSwitchFailureKeepsCurrent 健康检查失败时保持原引擎(26.9 核心语义)。
func TestSwitchFailureKeepsCurrent(t *testing.T) {
var s3Fail atomic.Bool
s3Fail.Store(true) // s3 不健康
-1
View File
@@ -1 +0,0 @@
import{d as p,u as v,i as h,I as g,c as w,G as s,b as e,t as n,f as t,A as d,w as k,F as y,O as r,q as m,B as R,o as V}from"./index-DYsKpclu.js";import{_ as x}from"./SiteNav.vue_vue_type_script_setup_true_lang-CwaEJKIZ.js";import{u as A}from"./auth-B7MDTgxJ.js";import"./admin-DEOkRyTC.js";const B={class:"admin-shell"},C={class:"admin-aside"},b={class:"aside-title"},L=["aria-label"],N={class:"admin-main"},q=p({__name:"AdminLayout",setup(S){const{t:a}=v(),u=R(),o=A(),l=h();g(async()=>{o.isAuthed&&!o.checked&&await o.verify()});async function _(){await o.logout(),l.success(a("admin.nav.loggedOut")),u.replace({name:"admin-login"})}return(F,c)=>{const i=r("RouterLink"),f=r("RouterView");return V(),w(y,null,[s(x),e("div",B,[e("aside",C,[e("div",b,n(t(a)("admin.nav.title")),1),e("nav",{class:"aside-menu","aria-label":t(a)("admin.nav.menu")},[s(i,{to:{name:"admin-files"}},{default:d(()=>[m("📁 "+n(t(a)("admin.nav.files")),1)]),_:1}),s(i,{to:{name:"admin-audit"}},{default:d(()=>[m("🛡 "+n(t(a)("admin.nav.audit")),1)]),_:1}),s(i,{to:{name:"admin-settings"}},{default:d(()=>[m("⚙️ "+n(t(a)("admin.nav.settings")),1)]),_:1}),c[0]||(c[0]=e("div",{class:"aside-sep"},null,-1)),e("a",{href:"#",onClick:k(_,["prevent"])},"🚪 "+n(t(a)("admin.nav.logout")),1)],8,L)]),e("div",N,[s(f)])])],64)}}});export{q as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
.field-sub[data-v-d049fcf9]{display:block;font-size:13px;font-weight:600;color:var(--c-text-2);margin-bottom:6px}.result-head[data-v-8bebf3d9]{display:flex;align-items:center;gap:10px;margin-bottom:14px}.result-name[data-v-8bebf3d9]{font-weight:600;word-break:break-all}.link-row[data-v-8bebf3d9]{display:flex;gap:8px}.link-row .input[data-v-8bebf3d9]{flex:1}.result-meta[data-v-8bebf3d9]{display:flex;align-items:center;gap:6px;color:var(--c-text-2);font-size:13px;flex-wrap:wrap}.code-copy[data-v-8bebf3d9]{display:block;width:100%;cursor:pointer;appearance:none;-webkit-appearance:none;transition:filter .15s}.code-copy[data-v-8bebf3d9]:hover{filter:brightness(.96)}.code-copy[data-v-8bebf3d9]:active{filter:brightness(.92)}.dropzone.disabled[data-v-1b57fe5c]{opacity:.55;cursor:not-allowed}
-1
View File
@@ -1 +0,0 @@
import{d as w,u as b,a as y,z as x,A as k,b as e,f as t,t as a,w as V,C as B,D as C,c as m,g as _,q as N,j as r,N as S,B as q,o as d,H as L,_ as P}from"./index-DYsKpclu.js";import{P as A}from"./PageShell-D87JkPkG.js";import{u as D}from"./auth-B7MDTgxJ.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-CwaEJKIZ.js";import"./admin-DEOkRyTC.js";const E={class:"card",style:{"max-width":"380px",margin:"8vh auto 0"}},I={class:"login-head"},M=["src"],R={class:"card-title"},T={class:"card-sub"},U={class:"field"},j={for:"admin-password"},z=["placeholder"],H={key:0,class:"hint",style:{color:"var(--c-danger)","margin-bottom":"12px"}},F=["disabled"],G={key:0,class:"spin","aria-hidden":"true"},J={class:"hint",style:{"margin-top":"16px"}},K=w({__name:"LoginView",setup(O){const{t:s}=b(),c=S(),g=q(),h=D(),u=y(),i=r(""),l=r(!1),n=r("");async function f(){if(!i.value){n.value=s("admin.login.required");return}l.value=!0,n.value="";try{await h.login(i.value);const o=typeof c.query.redirect=="string"?c.query.redirect:"/admin/files";g.replace(o)}catch(o){n.value=o instanceof L?o.code===401?s("admin.login.wrongPassword"):o.msg:s("admin.login.failed"),i.value=""}finally{l.value=!1}}return(o,p)=>(d(),x(A,null,{default:k(()=>[e("section",E,[e("div",I,[e("img",{src:t(u).displayLogoUrl,alt:"Logo",class:"login-logo"},null,8,M),e("h1",R,a(t(s)("admin.login.title")),1),e("p",T,a(t(s)("admin.login.subtitle",{name:t(u).displayName})),1)]),e("form",{onSubmit:V(f,["prevent"])},[e("div",U,[e("label",j,a(t(s)("admin.login.password")),1),B(e("input",{id:"admin-password","onUpdate:modelValue":p[0]||(p[0]=v=>i.value=v),class:"input",type:"password",placeholder:t(s)("admin.login.passwordPlaceholder"),autocomplete:"current-password",autofocus:""},null,8,z),[[C,i.value]])]),n.value?(d(),m("p",H,a(n.value),1)):_("",!0),e("button",{class:"btn btn-block",type:"submit",disabled:l.value},[l.value?(d(),m("span",G)):_("",!0),N(" "+a(t(s)("admin.login.submit")),1)],8,F)],32),e("p",J,a(t(s)("admin.login.hint")),1)])]),_:1}))}}),$=P(K,[["__scopeId","data-v-26e7f2a4"]]);export{$ as default};
-1
View File
@@ -1 +0,0 @@
import{d as c,u as i,z as d,A as a,b as t,t as e,f as s,G as l,q as p,O as u,o as _}from"./index-DYsKpclu.js";import{P as m}from"./PageShell-D87JkPkG.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-CwaEJKIZ.js";const f={class:"card empty",style:{"max-width":"480px",margin:"10vh auto 0"}},h={style:{"font-weight":"600",color:"var(--c-text)"}},x={class:"hint"},F=c({__name:"NotFoundView",setup(y){const{t:o}=i();return(g,n)=>{const r=u("RouterLink");return _(),d(m,null,{default:a(()=>[t("div",f,[n[0]||(n[0]=t("div",{class:"empty-icon"},"🧭",-1)),t("p",h,e(s(o)("notFound.title")),1),t("p",x,e(s(o)("notFound.desc")),1),l(r,{class:"btn",to:"/",style:{"margin-top":"10px"}},{default:a(()=>[p(e(s(o)("notFound.back")),1)]),_:1})])]),_:1})}}});export{F as default};
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{d as h,u as g,a as k,c as n,G as i,b as t,Q as y,e as v,f as e,t as a,g as c,q as r,A as d,F as x,h as B,O as C,o as l,_ as b}from"./index-DYsKpclu.js";import{_ as w}from"./SiteNav.vue_vue_type_script_setup_true_lang-CwaEJKIZ.js";const N={class:"site-footer"},S={class:"footer-left"},F={key:0,class:"footer-text"},P={key:1,class:"footer-beian"},T={key:2},V=["aria-label"],$=h({__name:"PageShell",setup(D){const{t:s}=g(),o=k(),m=new Date().getFullYear(),u=B(()=>!!(o.footerText.trim()||o.footerBeian.trim()));return(p,_)=>{const f=C("RouterLink");return l(),n(x,null,[i(w),t("main",{class:v(["page",{"page-wide":p.$route.meta.wide}])},[y(p.$slots,"default",{},void 0,!0)],2),t("footer",N,[t("div",S,[e(o).footerText.trim()?(l(),n("span",F,a(e(o).footerText),1)):c("",!0),e(o).footerBeian.trim()?(l(),n("span",P,a(e(o).footerBeian),1)):c("",!0),u.value?c("",!0):(l(),n("span",T,a(e(s)("footer.copyright",{year:e(m),name:e(o).displayName})),1))]),_[0]||(_[0]=t("span",{class:"footer-powered"},[r(" Powered by "),t("a",{href:"https://skymirror.top",target:"_blank",rel:"noopener noreferrer"},"SKYMirror"),r(" 26.9 ")],-1)),t("nav",{"aria-label":e(s)("footer.linkNav")},[i(f,{to:"/docs"},{default:d(()=>[r(a(e(s)("footer.docs")),1)]),_:1}),i(f,{to:"/openapi"},{default:d(()=>[r(a(e(s)("footer.openapi")),1)]),_:1}),i(f,{to:"/admin/files"},{default:d(()=>[r(a(e(s)("footer.admin")),1)]),_:1})],8,V)])],64)}}}),R=b($,[["__scopeId","data-v-80685d6f"]]);export{R as P};
@@ -1 +0,0 @@
import{d as b,u as p,c as r,b as o,t as i,f as c,h,o as f}from"./index-DYsKpclu.js";const v={class:"pager"},x={class:"pager-info"},k=["disabled"],M=["disabled"],y=b({__name:"Pager",props:{page:{},size:{},total:{}},emits:["change"],setup(t,{emit:m}){const a=t,d=m,{t:n}=p(),s=h(()=>Math.max(1,Math.ceil(a.total/a.size)));function g(l){const e=Math.min(Math.max(1,l),s.value);e!==a.page&&d("change",e,a.size)}return(l,e)=>(f(),r("div",v,[o("span",x,i(c(n)("common.pagerInfo",{total:t.total,page:t.page,pages:s.value})),1),o("button",{class:"btn btn-ghost btn-sm",type:"button",disabled:t.page<=1,onClick:e[0]||(e[0]=u=>g(t.page-1))},i(c(n)("common.previousPage")),9,k),o("button",{class:"btn btn-ghost btn-sm",type:"button",disabled:t.page>=s.value,onClick:e[1]||(e[1]=u=>g(t.page+1))},i(c(n)("common.nextPage")),9,M)]))}});export{y as _};
-1
View File
@@ -1 +0,0 @@
import{d as N,u as U,i as q,I,J as b,z as L,A as R,H as C,h as T,j as p,b as t,c as u,q as z,t as s,f as n,w as E,C as H,D as $,F as y,s as _,g as w,K as j,L as J,n as K,M as B,k as W,N as G,B as O,o as l}from"./index-DYsKpclu.js";import{P as Q}from"./PageShell-D87JkPkG.js";import{g as X,p as Y,b as Z}from"./share-CqDDLJWI.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-CwaEJKIZ.js";const ee={class:"card",style:{"max-width":"640px",margin:"12px auto 0"}},te={key:0,class:"loading-block"},ae={class:"empty"},oe={style:{"font-weight":"600",color:"var(--c-text)"}},ne={class:"hint"},se=["placeholder"],ie={class:"btn",type:"submit"},le={style:{display:"flex","align-items":"center",gap:"10px","flex-wrap":"wrap","margin-bottom":"4px"}},ue={class:"badge"},re={style:{"font-size":"16px","word-break":"break-all"}},ce={key:0,class:"hint",style:{margin:"0"}},pe={class:"hint",style:{"margin-bottom":"18px"}},de={key:0,class:"loading-block"},me={class:"text-view"},ve={style:{display:"flex",gap:"10px","margin-top":"14px","flex-wrap":"wrap"}},ye={class:"file-summary"},ke={style:{"font-weight":"600","word-break":"break-all"}},fe={class:"hint",style:{margin:"0"}},ge={key:0,style:{margin:"16px 0 6px"}},he={class:"progress"},xe={class:"hint"},_e=["disabled"],Be=N({__name:"PickupView",setup(we){const{t:e}=U(),D=G(),F=O(),k=q(),d=T(()=>String(D.params.code??"").trim().split(/\s+/)[0]),r=p("loading"),m=p(""),o=p(null),v=p(""),f=p(!1),c=p(null);async function g(){if(!d.value){r.value="error",m.value=e("pickup.emptyCode");return}r.value="loading",m.value="",o.value=null,v.value="";try{const a=await X(d.value);if(o.value=a,r.value="ready",a.isText){f.value=!0;try{v.value=await Y(d.value)}finally{f.value=!1}}}catch(a){r.value="error",m.value=a instanceof C?a.code===404?e("pickup.notFound"):a.code===423?e("home.rateLimited"):a.code===428?e("home.notInitialized"):a.msg:e("pickup.failedDefault")}}I(g),b(d,()=>{g()}),b(()=>e("pickup.emptyCode"),()=>{r.value==="error"&&m.value&&g()});async function A(){if(o.value){c.value=0;try{const{blob:a,filename:i}=await Z(d.value,x=>c.value=x);B(a,i||o.value.name||"download"),k.success(e("pickup.downloaded"))}catch(a){k.error(a instanceof C?a.msg:e("pickup.downloadFailed"))}finally{c.value=null}}}async function M(){await W(v.value)?k.success(e("pickup.copied")):k.error(e("common.copyFailed"))}function P(){if(!o.value)return;const a=new Blob([v.value],{type:"text/plain;charset=utf-8"}),i=o.value.name?.includes(".")?o.value.name:`${o.value.name||"text"}.txt`;B(a,i)}const h=p("");function S(){const a=h.value.trim();a&&F.push({name:"pickup",params:{code:a}})}const V=T(()=>{const a=o.value;return a?a.remainingDownloads===null||a.remainingDownloads===void 0||a.remainingDownloads<0?e("pickup.remainingUnlimited"):e("pickup.remainingCount",{n:a.remainingDownloads}):""});return(a,i)=>(l(),L(Q,null,{default:R(()=>[t("section",ee,[r.value==="loading"?(l(),u("div",te,[i[1]||(i[1]=t("span",{class:"spin","aria-hidden":"true"},null,-1)),z(" "+s(n(e)("pickup.querying",{code:d.value})),1)])):r.value==="error"?(l(),u(y,{key:1},[t("div",ae,[i[2]||(i[2]=t("div",{class:"empty-icon"},"📮",-1)),t("p",oe,s(m.value||n(e)("pickup.failed")),1),t("p",ne,s(n(e)("pickup.confirmHint")),1)]),t("form",{class:"quick-pickup",style:{"margin-top":"6px","max-width":"none"},onSubmit:E(S,["prevent"])},[H(t("input",{"onUpdate:modelValue":i[0]||(i[0]=x=>h.value=x),class:"input input-mono",placeholder:n(e)("pickup.retryPlaceholder"),maxlength:"32"},null,8,se),[[$,h.value]]),t("button",ie,s(n(e)("pickup.retryButton")),1)],32)],64)):o.value?(l(),u(y,{key:2},[t("div",le,[t("span",ue,s(o.value.isText?n(e)("common.text"):n(e)("common.file")),1),t("strong",re,s(o.value.name),1),o.value.isText?w("",!0):(l(),u("span",ce,s(n(_)(o.value.size)),1))]),t("p",pe,s(V.value)+" · "+s(n(e)("pickup.expireAt",{time:o.value.expiredAt?n(j)(o.value.expiredAt):n(e)("time.permanent")}))+" · "+s(n(J)(o.value.expiredAt)),1),o.value.isText?(l(),u(y,{key:0},[f.value?(l(),u("div",de,[i[3]||(i[3]=t("span",{class:"spin","aria-hidden":"true"},null,-1)),z(" "+s(n(e)("pickup.loadingText")),1)])):(l(),u(y,{key:1},[t("pre",me,s(v.value),1),t("div",ve,[t("button",{class:"btn",type:"button",onClick:M},s(n(e)("pickup.copyContent")),1),t("button",{class:"btn btn-ghost",type:"button",onClick:P},s(n(e)("pickup.downloadTxt")),1)])],64))],64)):(l(),u(y,{key:1},[t("div",ye,[i[4]||(i[4]=t("span",{style:{"font-size":"30px"},"aria-hidden":"true"},"📄",-1)),t("div",null,[t("div",ke,s(o.value.name),1),t("div",fe,s(n(e)("pickup.sizeUsed",{size:n(_)(o.value.size),n:o.value.usedCount})),1)])]),c.value!==null?(l(),u("div",ge,[t("div",he,[t("i",{style:K({width:`${c.value}%`})},null,4)]),t("p",xe,s(n(e)("pickup.downloading",{percent:c.value})),1)])):w("",!0),t("button",{class:"btn btn-block",type:"button",disabled:c.value!==null,onClick:A}," ⬇ "+s(n(e)("pickup.downloadFile",{size:n(_)(o.value.size)})),9,_e)],64))],64)):w("",!0)])]),_:1}))}});export{Be as default};
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
.settings-grid[data-v-03aa64c9]{display:grid;gap:16px}.save-row[data-v-03aa64c9]{display:flex;justify-content:flex-end}.notify-switch-row[data-v-03aa64c9]{display:flex;align-items:center;justify-content:space-between;gap:12px}.notify-switch-row label[data-v-03aa64c9]{margin-bottom:0}.unit-row[data-v-03aa64c9]{display:flex;gap:8px;align-items:center}.unit-row .input[data-v-03aa64c9]{flex:1}.unit-select[data-v-03aa64c9]{flex:0 0 110px!important}
@@ -1 +0,0 @@
import{d as z,u as E,a as x,c as i,G as B,f as t,A as m,b as a,F as _,r as v,az as S,t as r,h as d,aA as p,aB as T,O as A,o,z as M,q as V,e as b,aC as w}from"./index-DYsKpclu.js";const D={class:"site-nav"},F=["src"],I={class:"brand-name"},O=["aria-label"],R={class:"nav-controls"},U=["aria-label"],q=["aria-checked","title","onClick"],G={class:"nav-control-icon","aria-hidden":"true"},H=["title"],K=z({__name:"SiteNav",setup($){const{t:s}=E(),c=x(),{mode:u,setMode:k}=w(),g=d(()=>[{to:"/",label:s("nav.home"),match:n=>n==="/"}]);function y(n){return n.match(location.pathname)}const C={light:"☀️",dark:"🌙",system:"💻"},f=d(()=>({light:s("theme.light"),dark:s("theme.dark"),system:s("theme.system")})),L=d(()=>p()==="zh-CN"?"中文":"English");function N(){const n=p()==="zh-CN"?"en-US":"zh-CN";T(n)}return(n,l)=>{const h=A("RouterLink");return o(),i("header",D,[B(h,{class:"brand",to:"/",title:t(s)("nav.homeTitle",{name:t(c).displayName})},{default:m(()=>[a("img",{src:t(c).displayLogoUrl,alt:"Logo",onError:l[0]||(l[0]=e=>e.target.style.visibility="hidden")},null,40,F),a("span",I,r(t(c).displayName),1)]),_:1},8,["title"]),a("nav",{class:"nav-links","aria-label":t(s)("nav.mainNav")},[(o(!0),i(_,null,v(g.value,e=>(o(),M(h,{key:e.to,to:e.to,class:b({"router-link-active":y(e)})},{default:m(()=>[V(r(e.label),1)]),_:2},1032,["to","class"]))),128))],8,O),a("div",R,[a("div",{class:"theme-seg",role:"radiogroup","aria-label":t(s)("theme.label")},[(o(!0),i(_,null,v(t(S),e=>(o(),i("button",{key:e,type:"button",role:"radio","aria-checked":t(u)===e,class:b({active:t(u)===e}),title:f.value[e],onClick:j=>t(k)(e)},[a("span",G,r(C[e]),1)],10,q))),128))],8,U),a("button",{class:"nav-control",type:"button",title:t(s)("lang.label"),onClick:N},[l[1]||(l[1]=a("span",{class:"nav-control-icon","aria-hidden":"true"},"🌐",-1)),a("span",null,r(L.value),1)],8,H)])])}}});export{K as _};
-1
View File
@@ -1 +0,0 @@
import{v as n,S as e,x as s}from"./index-DYsKpclu.js";function u(t){return n(s.adminLogin,{method:"POST",json:{password:t}})}function m(){return n(s.adminVerify)}async function l(){await n(s.adminLogout,{method:"POST"})}async function g(t){const a=await n(s.adminFileList,{query:{page:t.page,size:t.size,keyword:t.keyword||void 0}}),o=e(a,["data","list","items","files"])??[],d=Number(e(a,["total","count"])??o.length);return{page:Number(e(a,["page"])??t.page),size:Number(e(a,["size"])??t.size),total:d,data:o.map(i=>({id:Number(e(i,["id"])??0),code:String(e(i,["code"])??""),name:String(e(i,["name"])??`${e(i,["prefix"])??""}${e(i,["suffix"])??""}`),suffix:String(e(i,["suffix"])??""),size:Number(e(i,["size"])??0),isText:!!(e(i,["isText","is_text"])??!1),expiredAt:e(i,["expiredAt","expired_at","expires_at"])??null,expiredCount:e(i,["expiredCount","expired_count"])??null,usedCount:Number(e(i,["usedCount","used_count"])??0),createdAt:e(i,["createdAt","created_at"])??null,isExpired:!!(e(i,["isExpired","is_expired"])??!1)}))}}async function p(t){await n(s.adminFileDelete,{method:"DELETE",json:{id:t}})}async function f(t){await n(s.adminFileBatchDelete,{method:"POST",json:{ids:t}})}async function y(t){await n(s.adminFileUpdate,{method:"PATCH",json:t})}async function w(){const t=await n(s.adminConfigGet);if(t&&typeof t=="object"&&!Array.isArray(t)){const a=t;return e(a,["config","data","settings"])??a}return{}}async function S(t){await n(s.adminConfigUpdate,{method:"PATCH",json:t})}async function _(t){const a=await n(s.adminStorageSwitch,{method:"POST",json:{engine:t}});return String(a?.engine??t)}async function x(t,a){await n(s.adminPasswordUpdate,{method:"PATCH",json:{old_password:t,new_password:a}})}async function b(t){const a=await n(s.adminAuditList,{query:{page:t.page,size:t.size,action:t.action||void 0,result:t.result||void 0,ip:t.ip||void 0,start_time:t.startTime||void 0,end_time:t.endTime||void 0}}),o=e(a,["data","list","items","logs"])??[],d=Number(e(a,["total","count"])??o.length);return{page:Number(e(a,["page"])??t.page),size:Number(e(a,["size"])??t.size),total:d,data:o.map((i,r)=>({id:Number(e(i,["id"])??r+1),action:String(e(i,["action"])??""),result:String(e(i,["result"])??""),fileCode:String(e(i,["file_code","fileCode","code"])??""),fileName:String(e(i,["file_name","fileName","name"])??""),sizeBytes:e(i,["size_bytes","sizeBytes","size"])??null,transferredBytes:e(i,["transferred_bytes","transferredBytes","bytes"])??null,ip:String(e(i,["ip","client_ip","clientIp"])??""),userAgent:String(e(i,["user_agent","userAgent"])??""),deviceOs:String(e(i,["device_os","deviceOs","os"])??""),deviceBrowser:String(e(i,["device_browser","deviceBrowser","browser"])??""),deviceType:String(e(i,["device_type","deviceType"])??""),actor:String(e(i,["actor"])??""),errorMsg:String(e(i,["error_msg","errorMsg","error"])??""),durationMs:e(i,["duration_ms","durationMs","duration"])??null,createdAt:e(i,["created_at","createdAt","time"])??null}))}}export{g as a,f as b,p as c,y as d,b as e,w as f,_ as g,S as h,x as i,l as j,m as k,u as l};
-1
View File
@@ -1 +0,0 @@
import{av as o,aw as a,W as r}from"./index-DYsKpclu.js";import{j as n,k as i,l as c}from"./admin-DEOkRyTC.js";const s="fcb_admin_flag",l=o("auth",{state:()=>({token:r(),checked:!1}),getters:{isAuthed:t=>!!t.token},actions:{async login(t){const e=(await c(t))?.token;if(!e)throw new Error("登录响应缺少 token");this.token=e,a(e);try{localStorage.setItem(s,"1")}catch{}this.checked=!0},async verify(){if(!this.token)return!1;try{return await i(),this.checked=!0,!0}catch{return this.reset(),!1}},async logout(){try{this.token&&await n()}catch{}this.reset()},reset(){this.token="",this.checked=!1,a("");try{localStorage.removeItem(s)}catch{}}}});export{l as u};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{v as x,S as s,U as m,V as g,x as u,W as w,H as i,X as f,Y as h}from"./index-DYsKpclu.js";function T(n,t,r,a=""){return x(u.shareText,{method:"POST",form:{text:n,expire_value:String(t),expire_style:r,code:a}})}function y(n,t,r,a,o=""){const e=new FormData;return e.append("file",n),e.append("expire_value",String(t)),e.append("expire_style",r),o&&e.append("code",o),h(u.shareFile,e,a)}async function S(n){const t=await x(u.shareMetadata,{query:{code:n}}),r=!!(s(t,["is_text","isText"])??s(t,["type"])==="text");return{code:String(s(t,["code"])??n),name:String(s(t,["name"])??"未命名"),size:Number(s(t,["size"])??0),type:r?"text":"file",isText:r,createdAt:s(t,["created_at","createdAt"])??null,expiredAt:s(t,["expired_at","expires_at","expiredAt","expiresAt"])??null,expiredCount:s(t,["expired_count","expiredCount","remaining_downloads","remainingDownloads"])??null,usedCount:Number(s(t,["used_count","usedCount"])??0),remainingDownloads:s(t,["remaining_downloads","remainingDownloads","expired_count","expiredCount"])??null}}async function b(n){const{blob:t}=await m(u.shareSelect,{query:{code:n}});return t.text()}function A(n,t){return new Promise((r,a)=>{const o=new URL(g(u.shareSelect),location.origin);o.searchParams.set("code",n);const e=new XMLHttpRequest;e.open("GET",o.toString()),e.timeout=6e5,e.responseType="blob";const l=w();l&&e.setRequestHeader("Authorization",`Bearer ${l}`),e.onprogress=p=>{p.lengthComputable&&t&&t(Math.round(p.loaded/p.total*100))},e.onload=()=>{if((e.getResponseHeader("content-type")??"").includes("application/json")){const d=new FileReader;d.onload=()=>{try{const c=JSON.parse(String(d.result));a(new i(c.code??e.status,c.msg||"取件失败",e.status))}catch{a(new i(e.status,"取件失败",e.status))}},d.readAsText(e.response);return}e.status>=200&&e.status<300?r({blob:e.response,filename:f(e.getResponseHeader("content-disposition"))}):a(new i(e.status,`取件失败(HTTP ${e.status}`,e.status))},e.onerror=()=>a(new i(0,"网络异常,取件失败")),e.ontimeout=()=>a(new i(0,"下载超时,请重试")),e.send()})}export{y as a,A as b,S as g,b as p,T as s};
+1 -1
View File
@@ -27,7 +27,7 @@
background: #0b0f1a;
}
</style>
<script type="module" crossorigin src="/assets/index-DYsKpclu.js"></script>
<script type="module" crossorigin src="/assets/index-BumeFNdu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CtyCxWf5.css">
</head>
<body>
View File
+7 -4
View File
@@ -2,10 +2,13 @@
Vue 3 + Vite + TypeScript 单页应用:分享 / 取件 / 管理后台(文件管理、审计日志、系统设置)/ 站内文档。
v2(任务 t3):液态玻璃主题(亮/暗/跟随系统)、中英双语(vue-i18n)、本地 Logo/favicon 打包资源、
背景图 / 页脚自定义 / 系统通知(读 `GET /api/v1/config` 下发的 v2 字段)、上传页策略动态渲染、
26.9(任务 t3):液态玻璃主题(亮/暗/跟随系统)、中英双语(vue-i18n)、本地 Logo/favicon 打包资源、
背景图 / 页脚自定义 / 系统通知(读 `GET /api/v1/config` 下发的 26.9 字段)、上传页策略动态渲染、
管理设置页全量策略表单(PATCH `/admin/config/update`)。
26.9 起在管理设置「上传频率限制」卡底部新增**上下行带宽限速**字段(MB/s,0=不限速,
提交后换算为字节/秒写入 `upload_rate`/`download_rate` KV)。
## 页面
| 路由 | 说明 |
@@ -26,7 +29,7 @@ v2(任务 t3):液态玻璃主题(亮/暗/跟随系统)、中英双语
- 语言(导航栏切换 + `localStorage``fcb_locale`):默认 `navigator.language`(zh 开头 → zh-CN,其余 → en-US)。
文案在 `src/locales/`,新增文案先加 zh-CN 再同步 en-US。
## v2 配置字段(消费 `GET /api/v1/config`,管理端经 `PATCH /admin/config/update` 写回)
## 26.9 配置字段(消费 `GET /api/v1/config`,管理端经 `PATCH /admin/config/update` 写回)
`background_url``footer_text``footer_beian``notify_enabled/notify_title/notify_content`
`max_file_size``max_save_seconds``max_save_count``allowedFileTypes``expireStyle`
@@ -63,7 +66,7 @@ npm run preview # 预览构建产物
- 构建产物输出到 `web/dist/`(入口 `web/dist/index.html`)。**注意**`web/` 在 Go moduleserver/)之外,go:embed 无法直接引用 —— go-api 需在构建脚本中拷贝(如 `cp -a web/dist server/web/dist`)或以 `os.DirFS` 方式挂载;`base: '/'` + history 路由配合其 NoRoute SPA 回退(静态资源优先、GET+text/html 回 index.html、/api/* JSON 404)。
- 如需改输出目录,调整 `vite.config.ts``build.outDir`
## Logo 自定义(v2:本地资源兜底)
## Logo 自定义(26.9:本地资源兜底)
- 默认导航 Logo`src/assets/brand/logo.svg`(构建内嵌;`DEFAULT_LOGO_URL`
- 默认 favicon`src/assets/brand/favicon.png``index.html` + `DEFAULT_FAVICON_URL`
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "filecodebox-web",
"version": "1.0.0",
"name": "fileshare-web",
"version": "26.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "filecodebox-web",
"version": "1.0.0",
"name": "fileshare-web",
"version": "26.9",
"dependencies": {
"marked": "^16.3.0",
"naive-ui": "^2.45.3",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "filecodebox-web",
"name": "fileshare-web",
"private": true,
"version": "1.0.0",
"version": "26.9",
"type": "module",
"description": "FileCodeBox Go 版前端:分享 / 取件 / 管理后台 / 审计日志 / 站内文档",
"scripts": {
+1 -1
View File
@@ -94,7 +94,7 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
if (json !== undefined) body = JSON.stringify(json)
else if (formData) body = formData
else if (form) {
// v3.1.1 根因修复:URLSearchParams.toString() 产出的是字符串 body
// 26.9 根因修复:URLSearchParams.toString() 产出的是字符串 body
// fetch 对字符串默认 Content-Type: text/plain —— 后端无法按表单解析,
// 文本分享被静默存成空(或被空文本校验拒绝)。必须显式声明 urlencoded。
body = new URLSearchParams(form).toString()
+1 -1
View File
@@ -24,7 +24,7 @@ function routerBase(): string {
}
export function pickupPageUrl(code: string, base?: string): string {
// v3.1:配置了站点对外域名时用之(内网访问也能生成公网链接),否则当前地址
// 26.9:配置了站点对外域名时用之(内网访问也能生成公网链接),否则当前地址
const origin = (base?.trim() || location.origin).replace(/\/$/, '')
return `${origin}${routerBase()}${encodeURIComponent(code)}`
}
+1
View File
@@ -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,
}
}
+2
View File
@@ -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 {
+22
View File
@@ -40,6 +40,12 @@ const acceptHint = computed(() => {
function accept(f: File | null | undefined): void {
error.value = ''
if (!f) return
// 26.9webkitRelativePath =
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])
}
+1 -1
View File
@@ -27,7 +27,7 @@ const link = computed(() => pickupPageUrl(props.code, config.shareLinkBase))
async function copy(value: 'code' | 'link'): Promise<void> {
// /s/<code>
// /s/XVR3H XVR3H 404v3.1.2
// /s/XVR3H XVR3H 40426.9
const text = value === 'link' ? link.value : props.code
const ok = await copyText(text)
if (ok) {
+28
View File
@@ -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…',
@@ -360,6 +366,28 @@ export default {
uploadCountHint: 'Min 1, max {max}',
uploadMinute: 'Window length (minutes) · uploadMinute',
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 ——
unitHour: 'Hour(s)',
unitDay: 'Day(s)',
+28
View File
@@ -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: '检索文档内容…',
@@ -354,6 +360,28 @@ export default {
uploadCountHint: '最小 1,最大 {max}',
uploadMinute: '频率窗口(分钟)uploadMinute',
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:单位友好化 + 存储引擎 ——
unitHour: '小时',
unitDay: '天',

Some files were not shown because too many files have changed in this diff Show More