Compare commits
17
Commits
e335df81d8
...
v26.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a9015aad0 | ||
|
|
84df9996cb | ||
|
|
27432218c4 | ||
|
|
00a8c16ab7 | ||
|
|
113c514531 | ||
|
|
2196ee20a2 | ||
|
|
f050944866 | ||
|
|
5bc7d5ee20 | ||
|
|
2b37d46e83 | ||
|
|
9931db2670 | ||
|
|
46319cc99e | ||
|
|
15b2bdf9da | ||
|
|
27b8b75136 | ||
|
|
6f1a925833 | ||
|
|
b5f06d09b3 | ||
|
|
7cfa330751 | ||
|
|
7f060dd0e4 |
@@ -0,0 +1,13 @@
|
||||
# Docker 构建上下文排除(context=仓库根,防止 800MB+ 缓存与无关目录进 context)
|
||||
.gocache/
|
||||
.gomodcache/
|
||||
.git/
|
||||
.agent-teams/
|
||||
reference/
|
||||
docs/
|
||||
web/node_modules/
|
||||
web/.npm-cache/
|
||||
node_modules/
|
||||
deploy/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,42 @@
|
||||
name: CI 测试
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
# 镜像发布不再走 CI:GoReleaser 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
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# ===== 构建产物与本地缓存 =====
|
||||
# Go 构缓存 / 模块缓存(体积大,且 .gomodcache 含第三方源码副本)
|
||||
.gocache/
|
||||
.gomodcache/
|
||||
.gotools/
|
||||
.npm-cache/
|
||||
|
||||
# 前端依赖与构建产物
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
# web-embed/ 为宿主预构建产物(cp -a web/dist web-embed/),仅保留占位;
|
||||
# Dockerfile 缺失 index.html 时自动回退为容器内构建前端
|
||||
web-embed/*
|
||||
!web-embed/.gitkeep
|
||||
|
||||
# go:embed 目标:直接提交构建产物,保证全新克隆 `go build`/`go run` 开箱可用
|
||||
# (前端源码变更后重新构建并同步:cd web && npm run build && rm -rf ../server/web/dist && cp -a dist ../server/web/dist)
|
||||
|
||||
# 参考实现(第三方上游代码,非本项目源码,不入库)
|
||||
reference/
|
||||
|
||||
# 本地运行数据(SQLite 数据库、本地存储文件、日志)
|
||||
server/data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.log
|
||||
server.pid
|
||||
|
||||
# 环境变量(保留 .env.example 模板;.env 属各部署环境自有配置)
|
||||
deploy/.env
|
||||
|
||||
# 系统杂物
|
||||
.DS_Store
|
||||
|
||||
# 会话/协作目录(工具私有,非项目源码)
|
||||
.agent-teams/
|
||||
dist/
|
||||
@@ -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 Token(release + 资产上传)
|
||||
# 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/arm64(tar.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_VERSION(const → 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/.gomodcache(goreleaser 默认仍会拷 . 下的隐藏目录;
|
||||
# 已在 .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
|
||||
|
||||
# 多架构 manifest(amd64 / 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
|
||||
@@ -1,3 +1,154 @@
|
||||
# FileShare
|
||||
|
||||
文件快传
|
||||
|
||||
数据库**默认 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 运行时热切换。
|
||||
|
||||
| 目录 | 说明 |
|
||||
|---|---|
|
||||
| `server/` | Go 后端(API、模型迁移、缓存降级、认证限流、审计、存储引擎) |
|
||||
| `web/` | Vue 3 + Vite + TS 前端(分享/取件/管理/审计/设置/`/docs` 文档页/`/openapi` Swagger) |
|
||||
| `docs/` | 中文 API 操作文档(`docs/api/*.md`)与 OpenAPI 规范(`docs/openapi.yaml`) |
|
||||
| `deploy/` | docker-compose 编排、三阶段 Dockerfile、`.env.example` |
|
||||
| `reference/` | 参考原版仓库(只读克隆,目录名 `upstream`) |
|
||||
|
||||
## 默认 Logo 与自定义
|
||||
|
||||
- 页面导航 Logo:本地资源 `web/src/assets/brand/logo.svg`
|
||||
- favicon / 备用 Logo:本地资源 `web/src/assets/brand/favicon.png`(26.9 起不再使用远程 URL 默认值)
|
||||
|
||||
管理端自定义 Logo 三步:
|
||||
|
||||
1. 登录后台 `POST /admin/login` 获取 Bearer 令牌;
|
||||
2. `PATCH /admin/config/update` 提交 `{"logo_url":"…","favicon_url":"…"}`(或在管理界面「系统设置」页上传/填写 URL);
|
||||
3. 保存即全站生效(前端读取 `GET /api/v1/config` 立即换新,无需重启)。
|
||||
|
||||
详见《[Logo 自定义](docs/api/12-logo.md)》。
|
||||
|
||||
## 快速开始(docker compose)
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
# 打开 http://localhost:8466 → 自动跳转 /setup 完成初始化
|
||||
```
|
||||
|
||||
- **数据库双路径**:默认 SQLite 零依赖——`docker compose up -d --build` 即可(无需 postgres profile,
|
||||
数据落 `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 启动:
|
||||
`docker compose --profile minio up -d --build`(含 mc 自动建桶)或 `docker compose --profile webdav up -d --build`。
|
||||
- 详见《[部署编排](deploy/README.md)》与《[存储引擎配置](docs/api/09-storage.md)》。
|
||||
|
||||
## 本地开发
|
||||
|
||||
前置:Go 1.27.1、Node 20(Postgres 16 仅 Postgres 模式需要)。
|
||||
|
||||
```bash
|
||||
# 1) 后端(:8466)——默认 SQLite 零依赖,无需任何数据库
|
||||
cd server
|
||||
go run ./cmd/server # 数据落 ./data/fileshare.db;go test ./... 运行单测
|
||||
|
||||
# 2) 后端 Postgres 模式(可选)
|
||||
docker run -d --name fcb-pg -p 5432:5432 \
|
||||
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=filecodebox postgres:16
|
||||
export FCB_DB_DRIVER=postgres
|
||||
export FCB_DB_DSN='postgres://postgres:postgres@localhost:5432/filecodebox?sslmode=disable'
|
||||
go run ./cmd/server
|
||||
|
||||
# 3) 前端 dev server(Vite 代理 /api → http://127.0.0.1:8466)
|
||||
cd ../web
|
||||
npm ci
|
||||
npm run dev
|
||||
|
||||
# 生产构建(go:embed 进二进制)
|
||||
npm run build # 产出 web/dist/,构建时按 deploy/Dockerfile 拷入 server/web/dist
|
||||
```
|
||||
|
||||
- 浏览器打开 `http://localhost:5173`(前端 dev)或 `http://localhost:8466`(后端 embed)。
|
||||
- 未初始化时除 `/setup` 与 `/api/v1/health` 外一律 428;首次访问按向导完成初始化。
|
||||
|
||||
## 文档
|
||||
|
||||
| 内容 | 入口 |
|
||||
|---|---|
|
||||
| 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) |
|
||||
| 部署编排 | [deploy/README.md](deploy/README.md) |
|
||||
|
||||
## 全量配置
|
||||
|
||||
### 进程环境变量(`FCB_*`)
|
||||
|
||||
| 变量 | 必需 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `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-15(URL 显式 `/N` 时以 URL 为准) |
|
||||
| `FCB_LISTEN` | ❌ | `:8466` | 监听地址 |
|
||||
| `FCB_STORAGE_ENGINE` | ❌ | `local` | `local` / `s3` / `webdav` |
|
||||
| `FCB_TRUSTED_PROXIES` | ❌ | 空 | 可信代理 CIDR(逗号分隔),生产必读(限流/审计 IP 依据) |
|
||||
| `FCB_ADMIN_PASSWORD` | ❌ | 空 | 设置后首次启动自动初始化管理员(≥8 位),消除 `/setup` 被抢占窗口;初始化后建议移除 |
|
||||
|
||||
### 引擎环境变量(种子注入,重启生效)
|
||||
|
||||
| 变量 | 引擎 |
|
||||
|---|---|
|
||||
| `FCB_LOCAL_STORAGE_PATH`(容器内默认 `/app/data`) | local |
|
||||
| `FCB_STORAGE_PATH` | 全部(存储相对路径前缀) |
|
||||
| `FCB_S3_ACCESS_KEY_ID` / `FCB_S3_SECRET_ACCESS_KEY` / `FCB_AWS_SESSION_TOKEN` | s3 |
|
||||
| `FCB_S3_BUCKET_NAME` / `FCB_S3_ENDPOINT_URL` / `FCB_S3_REGION_NAME` / `FCB_S3_ADDRESSING_STYLE` | s3 |
|
||||
| `FCB_WEBDAV_URL` / `FCB_WEBDAV_USERNAME` / `FCB_WEBDAV_PASSWORD` / `FCB_WEBDAV_ROOT_PATH` | webdav |
|
||||
|
||||
### 运行时配置(settings KV,管理端可改)
|
||||
|
||||
站点信息(`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`,及 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)。
|
||||
|
||||
## 审计日志
|
||||
|
||||
所有上传/下载端点自动落库:操作时间、IP、UA、设备解析(OS/浏览器/类型)、动作
|
||||
(`upload`/`download`)、结果(`success`/`denied`/`failed`)、字节数(文件总大小 + 实际传输,
|
||||
Range 只计实际区间)、耗时、角色(`admin`/`guest`)。管理端查询:
|
||||
`GET /admin/audit/list?page&size&action&result&ip&start_time&end_time`。
|
||||
详见《[审计日志查询](docs/api/08-audit.md)》。
|
||||
|
||||
## API 速览
|
||||
|
||||
| 模块 | 代表端点 |
|
||||
|---|---|
|
||||
| 公共 | `GET /api/v1/health` · `GET /api/v1/config` |
|
||||
| 初始化 | `GET /setup` · `POST /setup` |
|
||||
| 分享 | `POST /share/text` · `POST /share/file` · `GET /share/metadata?code=` · `GET/POST /share/select` · `GET /share/download` |
|
||||
| 分片上传 | `POST /chunk/upload/init` → `POST /chunk/upload/{id}/{index}` → `POST /chunk/upload/complete/{id}` |
|
||||
| 预签名直传 | `POST /presign/upload/init`(S3=direct,local/webdav=proxy) |
|
||||
| 管理后台 | `POST /admin/login` · `/admin/file/*` · `PATCH /admin/config/update` |
|
||||
| 审计 | `GET /admin/audit/list`(别名 `/admin/audit/logs`) |
|
||||
|
||||
统一响应 `{"code":200,"msg":"ok","data":…}`;错误码语义见《[错误码](docs/api/11-errors.md)》。
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# ===== FileCodeBox 部署环境变量示例 =====
|
||||
# 复制为 .env 后按需修改;所有变量均有默认值。
|
||||
|
||||
# ---- 站点 ----
|
||||
WEB_PORT=8466
|
||||
|
||||
# ---- 数据库(需求 ⑧)----
|
||||
# 默认 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://fileshare:fileshare@postgres:5432/fileshare?sslmode=disable
|
||||
|
||||
# ---- Postgres(仅 --profile postgres 时使用;生产务必修改默认口令并启用 TLS)----
|
||||
POSTGRES_USER=fileshare
|
||||
POSTGRES_PASSWORD=fileshare
|
||||
POSTGRES_DB=fileshare
|
||||
|
||||
# ---- 存储引擎切换:local | s3 | webdav ----
|
||||
FCB_STORAGE_ENGINE=local
|
||||
|
||||
# ---- 可信代理(生产必读,安全审计 L10)----
|
||||
# 反代(nginx/caddy 等)后必须设为代理 IP/CIDR(逗号分隔),
|
||||
# 否则限流按代理 IP 计数(全体用户共享一个限流桶)且审计 IP 失真。
|
||||
# 示例:FCB_TRUSTED_PROXIES=172.18.0.0/16,10.0.0.5
|
||||
FCB_TRUSTED_PROXIES=
|
||||
|
||||
# ---- 管理员自动初始化(可选,安全审计 L1)----
|
||||
# 设置后服务首次启动即完成管理员初始化(≥8 位),消除 /setup 被抢占窗口;
|
||||
# 初始化完成后建议移除该变量。留空则走 /setup 网页向导。
|
||||
# FCB_ADMIN_PASSWORD=
|
||||
|
||||
# ---- 管理员会话有效期(秒,默认 7 天,可调 1~365 整天)----
|
||||
# FCB_ADMIN_SESSION_EXPIRE=604800
|
||||
|
||||
# ---- 本地引擎 ----
|
||||
# 容器内固定 /app/data;compose 已挂载 serverdata 卷
|
||||
# FCB_LOCAL_STORAGE_PATH=/app/data
|
||||
|
||||
# ---- Redis(可选增强;留空=内存降级;启用服务用 --profile redis)----
|
||||
# FCB_REDIS_ADDR=redis:6379
|
||||
# Redis 逻辑库号 0-15(默认 0);地址为 redis://…/N URL 形式时以 URL 内库号优先
|
||||
# FCB_REDIS_DB=0
|
||||
|
||||
# ---- S3 引擎(FCB_STORAGE_ENGINE=s3 时必填 bucket/access/secret;服务用 --profile minio)----
|
||||
# 注意:minioadmin/minioadmin 仅为本机冒烟默认值,对外部署必须修改
|
||||
# (并在 compose 中删除 minio 的 ports 发布或仅绑定 127.0.0.1)
|
||||
FCB_S3_ENDPOINT_URL=http://minio:9000
|
||||
FCB_S3_BUCKET_NAME=fileshare
|
||||
FCB_S3_ACCESS_KEY_ID=minioadmin
|
||||
FCB_S3_SECRET_ACCESS_KEY=minioadmin
|
||||
FCB_S3_REGION_NAME=us-east-1
|
||||
# FCB_S3_ADDRESSING_STYLE=auto
|
||||
|
||||
# ---- WebDAV 引擎(FCB_STORAGE_ENGINE=webdav 时必填 url;服务用 --profile webdav)----
|
||||
FCB_WEBDAV_URL=http://webdav:5000
|
||||
FCB_WEBDAV_USERNAME=admin
|
||||
FCB_WEBDAV_PASSWORD=admin123
|
||||
FCB_WEBDAV_ROOT_PATH=filebox_storage
|
||||
@@ -0,0 +1,56 @@
|
||||
# 文件快传 Go 版多阶段构建:
|
||||
# 1) node:20-alpine 构建 Vue 前端产物(web/dist)
|
||||
# 2) golang:1.27.1-alpine 编译 server(go:embed 前端产物,缓存指向 /tmp 避免写权限问题)
|
||||
# 3) 最小运行时镜像:server 二进制 + web/dist 静态产物
|
||||
# 基础镜像可通过 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_REGISTRY:CI/受限网络下注入 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 ${GO_IMAGE} AS backend
|
||||
WORKDIR /build
|
||||
# GOPROXY:默认走 goproxy.cn(proxy.golang.org 的 GCS 后端在本环境网络下 EOF/超时),direct 兜底
|
||||
ENV CGO_ENABLED=0 GOSUMDB=off GOPROXY=https://goproxy.cn,direct GOCACHE=/tmp/.gocache GOMODCACHE=/tmp/.gomodcache
|
||||
# 先拷贝 go.mod/go.sum 利用层缓存
|
||||
COPY server/go.mod server/go.sum ./
|
||||
RUN go mod download
|
||||
COPY server/ ./
|
||||
# 前端产物嵌入(go:embed 目标目录 server/web/dist):
|
||||
# 优先用宿主预构建产物 web-embed/(构建前由 `cp -a web/dist web-embed/` 维护,
|
||||
# 保证 embed 与前端最新修正一致);为空/缺失时回退跨阶段 COPY(CI 兜底)
|
||||
COPY --from=frontend /build/dist ./frontend-dist
|
||||
COPY web-embed/ ./web-embed/
|
||||
RUN if [ -f web-embed/index.html ]; then \
|
||||
rm -rf web/dist && cp -a web-embed/. web/dist/; \
|
||||
else \
|
||||
rm -rf web/dist && cp -a frontend-dist/. web/dist/; \
|
||||
fi \
|
||||
&& rm -rf web-embed frontend-dist
|
||||
RUN go build -trimpath -ldflags "-s -w" -o /out/server ./cmd/server
|
||||
|
||||
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,磁盘副本供运维检查/替换)
|
||||
COPY --from=backend /build/web/dist /app/web/dist
|
||||
RUN mkdir -p /app/data && chown -R fcb:fcb /app
|
||||
USER fcb
|
||||
ENV FCB_LISTEN=:8466
|
||||
EXPOSE 8466
|
||||
VOLUME ["/app/data"]
|
||||
ENTRYPOINT ["/app/server"]
|
||||
@@ -0,0 +1,137 @@
|
||||
# 文件快传 部署编排(deploy/)
|
||||
|
||||
Go 1.27.1(Gin + GORM)+ Vue 3 重写版(26.9)的容器化部署。数据库**默认 SQLite,零外部依赖**
|
||||
(modernc.org/sqlite 纯 Go 驱动,无需 Postgres),可选切换 Postgres(`--profile postgres`);
|
||||
Redis 为可选增强(`--profile redis`),未配置 `FCB_REDIS_ADDR` 时服务端自动降级为进程内存缓存。
|
||||
|
||||
> 26.9 功能提示:管理后台可设「站点对外域名」(内网部署生成公网分享链接)、
|
||||
> 分享时支持自定义提取码(4-8 位字母数字);这些均为运行时配置,无需改部署。
|
||||
> 26.9 新增:管理后台可设「上行/下行带宽」限速(字节/秒,0=不限速),立即生效。
|
||||
> S3 预签名直传(客户端→S3)服务端无法介入限速,其余下载/上传路径均覆盖。详见《[带宽限速](../docs/api/13-bandwidth.md)》。
|
||||
|
||||
## compose 服务与 profile 一览
|
||||
|
||||
| 启动方式 | 服务 | 说明 |
|
||||
|---|---|---|
|
||||
| `docker compose up -d --build` | server | **默认:SQLite 零依赖**,仅启动一个容器 |
|
||||
| `--profile postgres` | postgres | 可选:Postgres 数据库(数据落 `pgdata` 卷) |
|
||||
| `--profile redis` | redis | 可选:缓存增强(未配置自动内存降级) |
|
||||
| `--profile minio` | minio + minio-init | 可选:S3 引擎冒烟(minio-init 用 mc 自动建桶) |
|
||||
| `--profile webdav` | webdav | 可选:WebDAV 引擎冒烟(dufs) |
|
||||
|
||||
## 数据库双路径(需求 ⑧)
|
||||
|
||||
### 路径 ①:默认 SQLite(零依赖,推荐)
|
||||
|
||||
无需 postgres profile,也无需任何数据库环境变量:
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
cp .env.example .env # 保持 FCB_DB_DRIVER=sqlite、FCB_DB_DSN 留空(默认值即可)
|
||||
docker compose up -d --build
|
||||
# 打开 http://localhost:8466(.env 可用 WEB_PORT 改端口)→ 自动跳转 /setup 完成初始化
|
||||
```
|
||||
|
||||
- 数据落 **`serverdata` 卷**:容器内 `/app/data/fileshare.db`(WAL 模式,父目录自动创建)。
|
||||
- `.env` 中 Postgres 段变量(`POSTGRES_USER/PASSWORD/DB`)在此模式下不生效,无需修改。
|
||||
|
||||
### 路径 ②:Postgres 模式(可选)
|
||||
|
||||
1. 编辑 `.env`:
|
||||
|
||||
```dotenv
|
||||
FCB_DB_DRIVER=postgres
|
||||
FCB_DB_DSN=postgres://fileshare:fileshare@postgres:5432/fileshare?sslmode=disable
|
||||
```
|
||||
|
||||
2. 带 postgres profile 启动(server 会等 postgres 健康检查通过后再启动):
|
||||
|
||||
```bash
|
||||
docker compose --profile postgres up -d --build
|
||||
```
|
||||
|
||||
- compose 将 `.env` 的 `FCB_DB_DRIVER/FCB_DB_DSN` 透传给 server 容器;DSN 主机必须为 compose
|
||||
服务名 `postgres`,凭据与 `POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB` 一致。
|
||||
- Postgres 数据落 `pgdata` 卷;首次启动自动执行迁移(AutoMigrate)。
|
||||
|
||||
> 注意:设置 `FCB_DB_DRIVER=postgres` 后务必带 `--profile postgres` 启动,否则 postgres
|
||||
> 服务不会创建,server 将因连不上数据库而反复重启。
|
||||
|
||||
服务未初始化时(两种数据库模式行为一致)除 `/setup` 与 `/api/v1/health` 外一律返回 428,
|
||||
浏览器打开站点按向导设置管理员密码。
|
||||
|
||||
## 存储引擎切换(本地 / S3 / WebDAV)
|
||||
|
||||
修改 `.env` 中 `FCB_STORAGE_ENGINE` 并带对应 profile 启动:
|
||||
|
||||
```bash
|
||||
# S3 引擎(MinIO 冒烟)
|
||||
docker compose --profile minio up -d --build # .env: FCB_STORAGE_ENGINE=s3
|
||||
# minio-init 一次性任务自动建桶(mc mb fileshare)
|
||||
|
||||
# WebDAV 引擎(dufs 冒烟,Basic 认证 admin/admin123@/:rw)
|
||||
docker compose --profile webdav up -d --build # .env: FCB_STORAGE_ENGINE=webdav
|
||||
```
|
||||
|
||||
> **26.9 起支持运行时热切换**:也可不改 `.env`,直接在管理后台「系统设置 → 存储引擎」
|
||||
> 三选并保存引擎参数后点切换(健康检查通过才生效,失败保持原引擎),或调用
|
||||
> `POST /admin/storage/switch`——均无需重启容器。`.env` 的 `FCB_STORAGE_ENGINE`
|
||||
> 仅作为首次启动(KV 为空时)的默认引擎。
|
||||
|
||||
## Redis 缓存增强(可选)
|
||||
|
||||
Redis 不是必需服务:`--profile redis` 启动**且** `.env` 设 `FCB_REDIS_ADDR=redis:6379` 才接入;
|
||||
未配置(或未启用该 profile)时服务端**自动降级为进程内存缓存**,单实例部署功能完全可用,
|
||||
多实例部署或需要重启保活计数/会话场景时建议启用。
|
||||
|
||||
```bash
|
||||
docker compose --profile redis up -d --build # .env: FCB_REDIS_ADDR=redis:6379
|
||||
```
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 说明 |
|
||||
|---|---|
|
||||
| `Dockerfile` | 三阶段构建:node 构建 web/dist → go 编译(go:embed 前端产物)→ alpine 运行镜像 |
|
||||
| `docker-compose.yml` | server + 四个可选 profile:postgres / redis / minio(含 mc 建桶)/ webdav |
|
||||
| `.env.example` | 全量环境变量:`FCB_DB_DRIVER` 双路径、三引擎切换、`WEB_PORT`、可信代理 |
|
||||
|
||||
## 健康检查与验证
|
||||
|
||||
端口由 `.env` 的 `WEB_PORT`(默认 `8466`)映射到容器内 `8466`:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8466/api/v1/health # {"code":200,"data":{"status":"ok",...}}
|
||||
curl -I http://localhost:8466/ # 200,前端 SPA
|
||||
curl -I http://localhost:8466/docs # 200(SPA 回退)
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **数据持久化**:SQLite 数据库与本地引擎文件都在 `serverdata` 卷(容器内 `/app/data`);
|
||||
Postgres 模式数据在 `pgdata` 卷;redis/minio/webdav 数据各在独立卷。
|
||||
切换数据库驱动不会自动迁移既有数据(SQLite 文件与 Postgres 库互相独立)。
|
||||
- **非 root 运行**:容器以 uid 10001 运行。
|
||||
- **日志**:`docker compose logs -f server`。
|
||||
- **CORS**:同源部署无需额外配置;`/admin/*` 已收紧——仅同源或 `site_domain` 白名单可跨域调用,
|
||||
其余跨域由浏览器拦截;公开接口维持 `*`(Bearer 认证,无 Cookie CSRF 面)。
|
||||
|
||||
## 安全清单(生产部署必读)
|
||||
|
||||
安全审计(`docs/security-audit-2026-09-05.md`)后的部署要求:
|
||||
|
||||
1. **反向代理必须配置可信代理**:`.env` 设 `FCB_TRUSTED_PROXIES=<代理IP/CIDR>`(逗号分隔)。
|
||||
否则限流按代理 IP 计数(全体用户共享一个限流桶、互相误伤),审计日志 IP 失真;
|
||||
也不可把不受信地址加进来——那会允许伪造 `X-Forwarded-For` 绕过限流。
|
||||
2. **部署后立即初始化管理员**:打开站点完成 `/setup`(设置 ≥8 位密码),
|
||||
或在 `.env` 设 `FCB_ADMIN_PASSWORD`(≥8 位)让服务启动即自动初始化,
|
||||
消除「公网上被抢先访问 /setup 接管」的窗口。初始化完成后建议从 `.env` 移除该变量。
|
||||
3. **修改组件默认凭据**:minio(`minioadmin/minioadmin`)、webdav(`admin/admin123`)、
|
||||
Postgres(`fileshare/fileshare`)默认凭据仅限本机冒烟;对外部署必须修改,
|
||||
且不建议把 9000/9001/5005 端口发布到公网(compose 中删除对应 `ports` 或仅绑 127.0.0.1)。
|
||||
4. **Postgres 建议启用 TLS**:默认示例 DSN 为 `sslmode=disable`,生产请改为
|
||||
`sslmode=require` 及以上。
|
||||
5. **管理员会话默认 7 天**(可在管理端调 1~365 天);修改管理员密码会使全部旧 token 失效。
|
||||
6. **上传/登录限流**依赖内存或 Redis 缓存;Redis 故障时自动降级为进程内限流(单实例语义)。
|
||||
7. **后台清理任务**:服务内置每 10 分钟的过期容量预留 / 超时上传会话 / 直传残留对象清理,
|
||||
无需额外 cron。
|
||||
@@ -0,0 +1,144 @@
|
||||
# 文件快传 Go 版部署编排(v2,需求 ⑧):
|
||||
# docker compose up -d --build # 默认:SQLite 零依赖(仅 server 容器)
|
||||
# docker compose --profile postgres up -d --build # 可选:Postgres 模式(自动注入 FCB_DB_DSN)
|
||||
# docker compose --profile redis up -d # 可选:Redis 缓存增强
|
||||
# docker compose --profile minio up -d # 可选:S3 引擎冒烟(FCB_STORAGE_ENGINE=s3)
|
||||
# docker compose --profile webdav up -d # 可选:WebDAV 引擎冒烟(FCB_STORAGE_ENGINE=webdav)
|
||||
#
|
||||
# 数据库默认 SQLite(modernc.org/sqlite 纯 Go 驱动,数据落 serverdata 卷 /app/data/fileshare.db);
|
||||
# postgres 为可选 profile 服务,启用时 server 经 FCB_DB_DRIVER/FCB_DB_DSN 自动接入;
|
||||
# Redis 同为可选,未配置时服务端内存降级。
|
||||
name: fileshare
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
# 可选服务(需求 ⑧):默认 SQLite 模式不会启动 postgres;
|
||||
# 需要时 docker compose --profile postgres up -d --build
|
||||
profiles: ["postgres"]
|
||||
environment:
|
||||
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:-fileshare}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
server:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
# postgres 为可选 profile 服务:--profile postgres 启用时等它健康,
|
||||
# 默认 SQLite 模式不启用该 profile,不阻塞启动
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
# redis 为可选 profile 服务:启用时等它健康,未启用不阻塞启动
|
||||
redis:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://127.0.0.1:8466/api/v1/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 5s
|
||||
environment:
|
||||
# 需求 ⑧:数据库驱动 sqlite(默认,零依赖)| postgres(需 --profile postgres)
|
||||
# 默认 SQLite:DSN 留空 → 数据库文件落 /app/data/fileshare.db(serverdata 卷)
|
||||
# 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:-}
|
||||
FCB_LISTEN: ":8466"
|
||||
FCB_STORAGE_ENGINE: ${FCB_STORAGE_ENGINE:-local}
|
||||
# Redis 可选增强:.env 设 FCB_REDIS_ADDR=redis:6379 且 --profile redis 启用时接入;
|
||||
# 默认留空=内存缓存(需求 ⑧)
|
||||
FCB_REDIS_ADDR: ${FCB_REDIS_ADDR:-}
|
||||
FCB_REDIS_DB: ${FCB_REDIS_DB:-0}
|
||||
FCB_TRUSTED_PROXIES: ${FCB_TRUSTED_PROXIES:-}
|
||||
# 本地引擎根目录(容器内)
|
||||
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:-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}
|
||||
# WebDAV 引擎(dufs profile)
|
||||
FCB_WEBDAV_URL: ${FCB_WEBDAV_URL:-}
|
||||
FCB_WEBDAV_USERNAME: ${FCB_WEBDAV_USERNAME:-admin}
|
||||
FCB_WEBDAV_PASSWORD: ${FCB_WEBDAV_PASSWORD:-admin123}
|
||||
FCB_WEBDAV_ROOT_PATH: ${FCB_WEBDAV_ROOT_PATH:-filebox_storage}
|
||||
ports:
|
||||
- "${WEB_PORT:-8466}:8466"
|
||||
volumes:
|
||||
- serverdata:/app/data
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
profiles: ["redis"]
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
restart: unless-stopped
|
||||
profiles: ["minio"]
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${FCB_S3_ACCESS_KEY_ID:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${FCB_S3_SECRET_ACCESS_KEY:-minioadmin}
|
||||
ports:
|
||||
- "${MINIO_API_PORT:-9000}:9000"
|
||||
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||
volumes:
|
||||
- miniodata:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
# MinIO 建桶初始化(mc;一次性任务)
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
profiles: ["minio"]
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
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:-fileshare} &&
|
||||
echo 'MinIO 桶已就绪'"
|
||||
|
||||
# WebDAV 冒烟服务器(dufs,简单读写 + Basic 认证)
|
||||
webdav:
|
||||
image: sigoden/dufs:latest
|
||||
restart: unless-stopped
|
||||
profiles: ["webdav"]
|
||||
command: /data -A -a admin:admin123@/:rw
|
||||
ports:
|
||||
- "${WEBDAV_PORT:-5005}:5000"
|
||||
volumes:
|
||||
- webdavdata:/data
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
redisdata:
|
||||
miniodata:
|
||||
webdavdata:
|
||||
serverdata:
|
||||
@@ -0,0 +1,116 @@
|
||||
# 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
|
||||
|
||||
- 服务默认监听 `:8466`,Base URL 为 `http://<host>:8466`(下文示例统一用 `http://localhost:8466`)。
|
||||
- **业务路由挂根路径**(与参考实现一致):`/share/*`、`/chunk/*`、`/presign/*`、`/admin/*`、`/setup`。
|
||||
- 仅两个公共接口带 `/api/v1` 前缀:`/api/v1/health`、`/api/v1/config`。
|
||||
|
||||
## 统一响应封装
|
||||
|
||||
所有 JSON 接口返回统一结构,HTTP 状态码与 `code` 一致;失败时 `data` 缺省:
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": { } }
|
||||
```
|
||||
|
||||
失败示例(404):
|
||||
|
||||
```json
|
||||
{ "code": 404, "msg": "文件不存在" }
|
||||
```
|
||||
|
||||
个别端点直接返回原始内容而非 JSON 封装(文档中已单独标注):
|
||||
|
||||
| 端点 | 响应形式 |
|
||||
|---|---|
|
||||
| `GET /share/select?code=`(文本分享) | `text/plain; charset=utf-8` 正文 |
|
||||
| `GET /share/select?code=`(文件分享) | 文件二进制流(200/206,支持 Range) |
|
||||
| `GET /share/download?key=&code=`(文件分享) | 文件二进制流(200/206,支持 Range) |
|
||||
| `GET /admin/file/download?id=`(文件分享) | 文件二进制流 |
|
||||
| `GET /setup` / `POST /setup`(表单) | HTML 向导/成功页 |
|
||||
|
||||
## 字段命名约定
|
||||
|
||||
- 接口字段以 **snake_case** 为主(`file_code`、`size_bytes`)。
|
||||
- 文件列表与审计日志的行字段同时输出 **snake_case 与 camelCase 双份**(如 `expired_at` 与 `expiredAt`),文档以 snake_case 为准,camelCase 仅为前端兼容保留。
|
||||
|
||||
## 认证
|
||||
|
||||
- 游客接口无需认证;是否允许游客上传由配置 `openUpload` 控制(关闭时上传类接口要求管理员 `Authorization: Bearer <token>`,否则 403)。
|
||||
- 管理接口(`/admin/login` 除外)一律要求 `Authorization: Bearer <JWT>`,无效/缺失返回 401。
|
||||
- 详情见《认证与限流》。
|
||||
|
||||
## 限流
|
||||
|
||||
按 IP(可信代理场景解析 `X-Forwarded-For`)维度限流,超限返回 **423**:
|
||||
|
||||
| 规则 | 计数时机 | 默认(次/窗口) | 相关配置 |
|
||||
|---|---|---|---|
|
||||
| `upload` | **上传成功**后计数 | 10 次 / 1 分钟 | `uploadCount` / `uploadMinute` |
|
||||
| `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` |
|
||||
|
||||
## 初始化守卫
|
||||
|
||||
系统未初始化(未设置管理员密码)时,除 `GET|POST /setup` 与 `GET /api/v1/health` 外,
|
||||
**所有接口一律返回 428**:
|
||||
|
||||
```json
|
||||
{ "code": 428, "msg": "系统未初始化,请先完成初始化" }
|
||||
```
|
||||
|
||||
首次部署请先访问 `GET /setup` 获取 HTML 向导,或直接 `POST /setup` 完成初始化(见《管理后台 API》初始化章节)。
|
||||
|
||||
## 审计
|
||||
|
||||
所有上传/下载端点经审计中间件自动落库(操作时间/IP/UA/设备解析/动作/结果/字节数/耗时/角色),
|
||||
失败与被拒绝的请求同样记录;管理端经 `GET /admin/audit/list` 查询,详见《审计日志》。
|
||||
|
||||
## 端点总览
|
||||
|
||||
| 模块 | 端点 |
|
||||
|---|---|
|
||||
| 公共 | `GET /api/v1/health` · `GET /api/v1/config` · `GET /robots.txt`(输出 `robotsText` 配置) |
|
||||
| 初始化 | `GET /setup` · `POST /setup` |
|
||||
| 文本分享 | `POST /share/text` |
|
||||
| 文件分享 | `POST /share/file` |
|
||||
| 查询与取件 | `GET/POST /share/metadata` · `GET /share/select` · `POST /share/select` · `GET /share/download` |
|
||||
| 分片上传 | `POST /chunk/upload/init` · `POST /chunk/upload/{uploadID}/{chunkIndex}` · `GET /chunk/upload/status/{uploadID}` · `POST /chunk/upload/complete/{uploadID}` · `DELETE /chunk/upload/{uploadID}` |
|
||||
| 预签名直传 | `POST /presign/upload/init` · `PUT /presign/upload/proxy/{uploadID}` · `POST /presign/upload/confirm/{uploadID}` · `GET /presign/upload/status/{uploadID}` · `DELETE /presign/upload/{uploadID}` |
|
||||
| 管理后台 | `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 秒。
|
||||
- 请求体支持 `application/json` 与 `application/x-www-form-urlencoded`(上传类为 `multipart/form-data`),文档示例以 JSON/curl 为主。
|
||||
- CORS:公开接口放开(Bearer 认证,无 Cookie CSRF 面);**管理端 `/admin/*` 已收紧**——携带 Origin 且既不同源也不在 `site_domain` 白名单时不下发 CORS 头(浏览器拦截跨域读取)。
|
||||
|
||||
## 交互式文档
|
||||
|
||||
- 站内文档页:`/docs`(渲染本目录 markdown,构建时内嵌)。
|
||||
- Swagger UI:`/openapi`(渲染 `docs/openapi.yaml`,构建时内嵌)。
|
||||
- OpenAPI 规范源文件为仓库内 `docs/openapi.yaml`;如需经后端直接下载,
|
||||
需在部署时把它拷贝进前端静态产物 `web/dist/`(未拷贝时该路径按 SPA 回退返回页面)。
|
||||
@@ -0,0 +1,105 @@
|
||||
# 认证与限流
|
||||
|
||||
## 角色
|
||||
|
||||
| 角色 | 能力 |
|
||||
|---|---|
|
||||
| 游客(无 Authorization 头) | 取件、查询元信息;`openUpload=1` 时可上传 |
|
||||
| 管理员(`Authorization: Bearer <JWT>`) | 全部能力 + `/admin/*` 管理接口 |
|
||||
|
||||
## 管理员令牌
|
||||
|
||||
- 由 `POST /admin/login` 用管理员密码换取,HS256 JWT,默认有效期 **7 天**(`adminSessionExpire`,1~365 整天,26.9 起由 30 天缩短)。
|
||||
- 请求头格式:`Authorization: Bearer <token>`。
|
||||
- **改密/重置管理员密码会轮换 `jwt_secret`,所有已签发令牌立即失效**(401)。
|
||||
- 密码存储为 bcrypt(cost 12);历史 `sha256$`/明文格式在登录成功后自动升级重哈希,无需手动迁移。
|
||||
- 游客上传关闭(`openUpload=0`)时,上传类接口也可用管理员 Bearer 令牌通过鉴权。
|
||||
|
||||
## 认证失败语义
|
||||
|
||||
| 场景 | 状态码 |
|
||||
|---|---|
|
||||
| `/admin/*` 缺失/无效令牌 | 401 |
|
||||
| `POST /admin/login` 密码错误 | 401(并计入 login 限流) |
|
||||
| 游客上传被关闭且未携带有效令牌 | 403 |
|
||||
| 代理下载 `key` 校验失败 | 403 |
|
||||
|
||||
## 未初始化(428)
|
||||
|
||||
管理员密码未设置(`admin_token` 为空)时,除 `GET|POST /setup` 与 `GET /api/v1/health` 外全部接口返回 428。
|
||||
完成 `POST /setup` 初始化后自动解除。
|
||||
|
||||
## 限流规则
|
||||
|
||||
限流按 **客户端 IP** 维度(配置 `FCB_TRUSTED_PROXIES` 声明可信代理 CIDR,命中时解析 `X-Forwarded-For` 取真实 IP),
|
||||
窗口计数原子化存储于缓存(未配置 Redis 时为进程内存)。**超限一律返回 423**:
|
||||
|
||||
```json
|
||||
{ "code": 423, "msg": "请求次数过多,请稍后再试" }
|
||||
```
|
||||
|
||||
| 规则 | 生效端点 | 计数时机 | 默认 | 配置键 |
|
||||
|---|---|---|---|---|
|
||||
| `upload` | `/share/text`、`/share/file`、`/chunk/upload/*`、`/presign/upload/*` | **成功后**计数(进入时仅检查) | 10 次 / 1 分钟 | `uploadCount`、`uploadMinute` |
|
||||
| `error` | `/share/select`、`/share/download` | 取件失败(不存在/过期/鉴权失败)时计数 | 10 次 / 1 分钟 | `errorCount`、`errorMinute` |
|
||||
| `login` | `/admin/login` | 登录失败时计数 | 5 次 / 15 分钟 | `loginCount`、`loginMinute` |
|
||||
| `metadata` | `/share/metadata` | **每次访问即计数**(含失败) | 同 `error` | `errorCount`、`errorMinute` |
|
||||
|
||||
- 规则值可由管理端 `PATCH /admin/config/update` 运行时修改,立即生效(无需重启)。
|
||||
- 取件成功(`/share/select`、`/share/download`)不计入 `error` 限流。
|
||||
|
||||
## 代理下载令牌(key)
|
||||
|
||||
`GET /share/download` 的 `key` 由服务端按窗口生成:
|
||||
`sha256(code + timeFactor + "000" + jwt_secret)`,`timeFactor = unix秒 / 1000`(约 16.7 分钟一个窗口)。
|
||||
服务端**同时接受当前与上一窗口**的令牌,避免窗口边界竞态。令牌通过 `POST /share/select` 的响应
|
||||
`download_url` 下发,客户端不应自行构造。
|
||||
|
||||
## 示例
|
||||
|
||||
登录获取令牌:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8466/admin/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"password":"your-admin-password"}'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"id": "admin", "username": "admin",
|
||||
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"token_type": "Bearer",
|
||||
"expires_at": 1750000000,
|
||||
"expires_in": 604800
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
携带令牌调用管理接口:
|
||||
|
||||
```bash
|
||||
TOKEN="eyJhbGciOiJIUzI1NiIs..."
|
||||
curl -s http://localhost:8466/admin/dashboard -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
校验令牌是否有效:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8466/admin/verify -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": { "id": "admin", "username": "admin", "token": "eyJhbGciOiJIUzI1NiIs...", "token_type": "Bearer", "expires_at": 1750000000 }
|
||||
}
|
||||
```
|
||||
|
||||
令牌失效时:
|
||||
|
||||
```json
|
||||
{ "code": 401, "msg": "令牌无效或已过期" }
|
||||
```
|
||||
@@ -0,0 +1,91 @@
|
||||
# 文本分享
|
||||
|
||||
创建纯文本分享,返回取件码。文本大小上限 **222KB**(超限建议改用文件分享);请求体全局上限 1MiB,`Content-Length` >441KB 时读前直接 403。
|
||||
经审计中间件落库(action=upload)。
|
||||
|
||||
## POST /share/text
|
||||
|
||||
**请求参数**(`application/x-www-form-urlencoded`,亦支持 multipart;`text` 为必需):
|
||||
|
||||
| 参数 | 类型 | 必需 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `code` | string,可选;自定义提取码,5-8 位字母或数字(空=随机生成;占用 400「该提取码已被占用」) |
|
||||
| `text` | string | ✅ | - | 文本内容(≤222KB,按 UTF-8 字节数) |
|
||||
| `expire_value` | int | ❌ | `1` | 过期值(配合 `expire_style`) |
|
||||
| `expire_style` | string | ❌ | `day` | `day`/`hour`/`minute`/`count`/`forever`(须在站点允许列表内) |
|
||||
|
||||
过期语义:
|
||||
|
||||
- `day`/`hour`/`minute`:按时间过期,`expired_count = -1`。
|
||||
- `count`:按次数过期,取件 `expire_value` 次后失效(`expired_count = expire_value`);
|
||||
**26.9 需求 ④**:`max_save_count>0` 时 `expire_value` 不得超出该上限,超限 403。
|
||||
- `forever`:永久(需站点允许;`max_save_seconds>0` 时其他方式受最长保存上限约束,超限 403)。
|
||||
|
||||
> 可选值与上限来自公开配置 `GET /api/v1/config`(`expireStyle`、`max_save_seconds`、
|
||||
> `max_save_count`),上传页动态读取并在范围内选择;管理端改策略后立即生效。
|
||||
|
||||
**curl 示例**:
|
||||
|
||||
```bash
|
||||
# 自定义提取码(可选):-d 'code=MYCODE1'
|
||||
curl -s -X POST http://localhost:8466/share/text \
|
||||
-d 'text=你好,文件快传' \
|
||||
-d 'expire_value=1' \
|
||||
-d 'expire_style=day'
|
||||
```
|
||||
|
||||
**成功响应**(200):
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": { "code": "8XQ2M" } }
|
||||
```
|
||||
|
||||
`data.code` 为 5 位取件码(数字或大写字母+数字,取决于 `code_generate_type`)。
|
||||
|
||||
**错误响应**:
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "过期时间类型错误" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "过期时间值必须大于 0" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 403, "msg": "内容过多,建议采用文件形式" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 403, "msg": "限制最长时间为 7天,可换用其他方式" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 403, "msg": "限制次数最多为 5 次" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 423, "msg": "请求次数过多,请稍后再试" }
|
||||
```
|
||||
|
||||
> 游客上传关闭(`openUpload=0`)时需携带管理员令牌,否则 403:
|
||||
> `{"code":403,"msg":"本站未开启游客上传,如需上传请先登录后台"}`
|
||||
|
||||
## 取回文本
|
||||
|
||||
文本分享的取回走统一的取件接口(消耗次数):
|
||||
|
||||
- `GET /share/select?code=<code>` → `text/plain` 正文即文本内容(响应头 `Content-Disposition` 带文件名,无扩展名时为 `<prefix>.txt`)。
|
||||
- `POST /share/select`(`{"code":"8XQ2M"}`)→ JSON,`data.text` / `data.content` 为文本内容。
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:8466/share/select?code=8XQ2M"
|
||||
```
|
||||
|
||||
```text
|
||||
你好,文件快传
|
||||
```
|
||||
|
||||
**26.9 变更**:① 支持 JSON 提交(`Content-Type: application/json`,字段同名);② 空文本 400「分享内容不能为空」;③ 可选 `code` 自定义提取码(5-8 位字母数字,占用 400)。
|
||||
@@ -0,0 +1,131 @@
|
||||
# 文件分享
|
||||
|
||||
上传单个文件并创建分享。支持扩展名/MIME 白名单 + **magic bytes 防伪**(读文件前 64 字节校验,
|
||||
伪造类型返回 403)。经审计中间件落库(action=upload,记录文件总大小与实际传输字节)。
|
||||
|
||||
## POST /share/file
|
||||
|
||||
**请求参数**(`multipart/form-data`):
|
||||
|
||||
| 参数 | 类型 | 必需 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `code` | string,可选;自定义提取码,5-8 位字母或数字(空=随机生成;占用 400) |
|
||||
| `file` | file | ✅ | - | 上传的文件(大小 ≤ 生效上限:`max_file_size>0` 时为其,否则 `uploadSize`) |
|
||||
| `expire_value` | int | ❌ | `1` | 过期值(配合 `expire_style`;`count` 型受 `max_save_count` 约束) |
|
||||
| `expire_style` | string | ❌ | `day` | `day`/`hour`/`minute`/`count`/`forever`(须在 `expireStyle` 白名单内) |
|
||||
|
||||
**curl 示例**:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8466/share/file \
|
||||
-F 'file=@./report.pdf;type=application/pdf' \
|
||||
-F 'expire_value=7' \
|
||||
-F 'expire_style=day'
|
||||
```
|
||||
|
||||
**成功响应**(200):
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": { "code": "K3P9W", "name": "report.pdf" } }
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "缺少上传文件 file 字段" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
|
||||
```
|
||||
|
||||
> 大小上限为动态策略(26.9 需求 ④⑩):管理端改 `max_file_size`(0=回落 `uploadSize`)后
|
||||
> **下一次上传立即按新上限执行**,无需重启;上限值可经 `GET /api/v1/config` 的
|
||||
> `max_file_size`/`maxFileSize` 字段读取。
|
||||
|
||||
```json
|
||||
{ "code": 403, "msg": "不允许上传该类型文件" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 403, "msg": "文件内容与扩展名不匹配,疑似伪造类型" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 403, "msg": "限制最长时间为 7天,可换用其他方式" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 403, "msg": "限制次数最多为 5 次" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 403, "msg": "请求次数过多,请稍后再试" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 507, "msg": "存储空间已达到管理员设置的容量上限" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 503, "msg": "存储服务不可用,请稍后再试" }
|
||||
```
|
||||
|
||||
## 文件类型白名单
|
||||
|
||||
由配置 `allowed_file_types` 控制(管理端可改):
|
||||
|
||||
- `*`:不限制(默认)。
|
||||
- 扩展名规则:`.png`、`pdf`(自动补点)等,按文件名后缀匹配。
|
||||
- MIME 规则:`image/*`、`application/pdf` 等,按请求 `Content-Type` 通配匹配。
|
||||
|
||||
已知类型(png/jpg/gif/webp/bmp/pdf/zip/rar/7z/gz/mp3/mp4/exe/elf)会做 **magic bytes 交叉校验**:
|
||||
扩展名或 Content-Type 声明了已知类型,但文件头不匹配时拒绝(403「疑似伪造类型」)。
|
||||
|
||||
## 下载取件
|
||||
|
||||
- `GET /share/select?code=<code>`:消耗 1 次取件,返回文件流(`200` 全量 / `206` 区间,
|
||||
支持 `Range` 请求头;响应含 `Accept-Ranges: bytes`、`Content-Disposition: attachment; filename*=UTF-8''...`)。
|
||||
- `POST /share/select`:返回详情 JSON,`download_url` 为代理下载地址(见下)。
|
||||
- `GET /share/download?key=<token>&code=<code>`:代理下载,消耗 1 次,同样支持 Range。
|
||||
|
||||
Range 示例(取前 1024 字节):
|
||||
|
||||
```bash
|
||||
curl -s -H 'Range: bytes=0-1023' -o part.bin \
|
||||
"http://localhost:8466/share/select?code=K3P9W"
|
||||
```
|
||||
|
||||
区间越界返回:
|
||||
|
||||
```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` 会剥离文件名中的路径分隔符,多级路径无法成体保存。
|
||||
@@ -0,0 +1,173 @@
|
||||
# 分享查询与取件
|
||||
|
||||
查询分享元信息(不消耗次数)与真正取件(消耗次数)的完整接口。
|
||||
除 `metadata` 每次 423 限流计数外,取件失败还会计入 `error` 限流。
|
||||
|
||||
## 元信息:GET /share/metadata
|
||||
|
||||
按取件码查询元信息,**不消耗次数**。每次访问即计入 `metadata` 限流。
|
||||
|
||||
**参数**:`code`(query,必需)。
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:8466/share/metadata?code=K3P9W"
|
||||
```
|
||||
|
||||
**成功响应**(200):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"code": "K3P9W",
|
||||
"name": "report.pdf",
|
||||
"size": 1048576,
|
||||
"type": "file",
|
||||
"is_text": false,
|
||||
"created_at": "2025-06-01T12:00:00+08:00",
|
||||
"expired_at": "2025-06-08T12:00:00+08:00",
|
||||
"expires_at": "2025-06-08T12:00:00+08:00",
|
||||
"expired_count": -1,
|
||||
"used_count": 3,
|
||||
"remaining_downloads": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `type` | `text`(文本分享)/ `file`(文件分享) |
|
||||
| `is_text` | 是否文本分享 |
|
||||
| `size` | 字节数 |
|
||||
| `expired_at` / `expires_at` | 过期时间(RFC 3339);永久分享为 `null` |
|
||||
| `expired_count` | `-1` 按时间/永久;`>0` 剩余可取次数(原始限额) |
|
||||
| `used_count` | 已取次数 |
|
||||
| `remaining_downloads` | 剩余可取次数(仅次数型分享有值,否则 `null`) |
|
||||
|
||||
> 不返回存储路径等敏感字段。
|
||||
|
||||
**错误响应**:
|
||||
|
||||
```json
|
||||
{ "code": 404, "msg": "文件不存在" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 404, "msg": "文件已过期" }
|
||||
```
|
||||
|
||||
## 元信息(POST):POST /share/metadata
|
||||
|
||||
等价的 JSON 版本(`code` 放请求体):
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8466/share/metadata \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"code":"K3P9W"}'
|
||||
```
|
||||
|
||||
响应与 GET 版本一致。
|
||||
|
||||
## 取件(消耗次数):GET /share/select
|
||||
|
||||
**每调用一次消耗 1 次取件**(次数型分享扣减 `expired_count`;时间型扣减不计)。
|
||||
|
||||
- 文本分享:返回 `text/plain; charset=utf-8` 正文(非 JSON 封装),`Content-Disposition` 携带文件名。
|
||||
- 文件分享:返回文件流(`200` 全量 / `206` 区间),支持 `Range`。
|
||||
|
||||
```bash
|
||||
curl -s -OJ "http://localhost:8466/share/select?code=K3P9W"
|
||||
```
|
||||
|
||||
次数耗尽或已过期:
|
||||
|
||||
```json
|
||||
{ "code": 404, "msg": "文件已过期" }
|
||||
```
|
||||
|
||||
超限(计入 error 限流):
|
||||
|
||||
```json
|
||||
{ "code": 423, "msg": "请求次数过多,请稍后再试" }
|
||||
```
|
||||
|
||||
## 取件详情:POST /share/select
|
||||
|
||||
返回元信息 + 文本内容/下载地址的 JSON 详情。**消耗语义**:次数型分享(`expired_count >= 0`)
|
||||
返回代理地址 `download_url` 且本次**不消耗**(消耗发生在访问代理地址时);时间型/永久/文本分享在本次消耗。
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8466/share/select \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"code":"K3P9W"}'
|
||||
```
|
||||
|
||||
**文件分享响应**(200):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"code": "K3P9W",
|
||||
"name": "report.pdf",
|
||||
"size": 1048576,
|
||||
"type": "file",
|
||||
"is_text": false,
|
||||
"created_at": "2025-06-01T12:00:00+08:00",
|
||||
"expired_at": "2025-06-08T12:00:00+08:00",
|
||||
"expires_at": "2025-06-08T12:00:00+08:00",
|
||||
"expired_count": -1,
|
||||
"used_count": 4,
|
||||
"remaining_downloads": null,
|
||||
"text": "/share/download?key=9f2c…&code=K3P9W",
|
||||
"download_url": "/share/download?key=9f2c…&code=K3P9W"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> S3 引擎下时间型/永久分享的 `download_url` 可能是预签名直链(1 小时有效)而非代理地址;
|
||||
> 次数型分享恒为代理地址 `"/share/download?key=…&code=…"`。
|
||||
|
||||
**文本分享响应**(200):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"code": "8XQ2M", "name": "Text.txt", "size": 24, "type": "text", "is_text": true,
|
||||
"created_at": "2025-06-01T12:00:00+08:00",
|
||||
"expired_at": "2025-06-02T12:00:00+08:00", "expires_at": "2025-06-02T12:00:00+08:00",
|
||||
"expired_count": -1, "used_count": 1, "remaining_downloads": null,
|
||||
"text": "你好,文件快传",
|
||||
"content": "你好,文件快传",
|
||||
"download_url": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 代理下载:GET /share/download
|
||||
|
||||
`POST /share/select` 返回的代理地址,**每次访问消耗 1 次**,支持 Range。
|
||||
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `key` | 窗口令牌(服务端下发,双窗口校验) |
|
||||
| `code` | 取件码 |
|
||||
|
||||
```bash
|
||||
curl -s -OJ "http://localhost:8466/share/download?key=9f2c…&code=K3P9W"
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
|
||||
```json
|
||||
{ "code": 403, "msg": "下载鉴权失败" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 404, "msg": "文件已过期" }
|
||||
```
|
||||
|
||||
> `key` 鉴权失败会计入 error 限流;文本分享经该接口返回 JSON 封装 `data` 为文本内容。
|
||||
@@ -0,0 +1,212 @@
|
||||
# 分片上传
|
||||
|
||||
大文件分片上传:客户端把文件切成固定大小的分片逐个上传,服务端按索引合并并做 SHA256 校验。
|
||||
支持**断点续传**(相同 `file_hash` + 大小 + 文件名的未完成会话自动续传)。
|
||||
需站点开启 `enableChunk`(公共配置 `enableChunk` 返回 `true`)。
|
||||
分片会话保留 24 小时。全部端点经审计中间件落库(action=upload)。
|
||||
|
||||
## 上传流程
|
||||
|
||||
```text
|
||||
POST /chunk/upload/init → upload_id, total_chunks
|
||||
POST /chunk/upload/{id}/{index} → 逐片上传(0 起,可并发)
|
||||
GET /chunk/upload/status/{id} → 断点续传时查进度
|
||||
POST /chunk/upload/complete/{id} → 合并 + SHA256 → 取件码
|
||||
DELETE /chunk/upload/{id} → 取消(可选)
|
||||
```
|
||||
|
||||
## 初始化:POST /chunk/upload/init
|
||||
|
||||
**请求体**(JSON,亦兼容表单):
|
||||
|
||||
| 参数 | 类型 | 必需 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `file_name` | string | ✅ | - | 文件名(会做清理与白名单校验) |
|
||||
| `file_size` | int | ✅ | - | 文件总字节数(>0;服务端按分片数校验上限) |
|
||||
| `chunk_size` | int | ❌ | `5242880`(5MB) | 每片大小(字节),硬上限 32MiB(超出 400「chunk_size 过大」) |
|
||||
| `file_hash` | string | ❌ | - | 整文件 SHA256(断点续传的匹配键) |
|
||||
|
||||
**curl 示例**:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8466/chunk/upload/init \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"file_name":"movie.mp4","file_size":15728640,"chunk_size":5242880,"file_hash":"<sha256>"}'
|
||||
```
|
||||
|
||||
**成功响应**(200,新建会话):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"existed": false,
|
||||
"upload_id": "3f6b8c2a4d5e6f708192a3b4c5d6e7f8",
|
||||
"chunk_size": 5242880,
|
||||
"total_chunks": 3,
|
||||
"uploaded_chunks": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**断点续传响应**(200,命中未完成会话):返回既有会话,`uploaded_chunks` 为已传分片索引列表,
|
||||
客户端只需补传缺失分片(注意:`existed` 字段恒为 `false`,是否续传以 `upload_id` 复用且
|
||||
`uploaded_chunks` 非空为准):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"existed": false,
|
||||
"upload_id": "3f6b8c2a4d5e6f708192a3b4c5d6e7f8",
|
||||
"chunk_size": 5242880,
|
||||
"total_chunks": 3,
|
||||
"uploaded_chunks": [0, 1]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "file_size 必须大于 0" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 403, "msg": "分片上传未启用" }
|
||||
```
|
||||
|
||||
## 上传分片:POST /chunk/upload/{uploadID}/{chunkIndex}
|
||||
|
||||
主路径(与参考实现语义一致)。`chunkIndex` 从 `0` 起。
|
||||
|
||||
**multipart 字段**:`chunk`(必需,该分片的二进制数据)。
|
||||
|
||||
**curl 示例**:
|
||||
|
||||
```bash
|
||||
split -b 5242880 movie.mp4 part- # 本地分片
|
||||
curl -s -X POST http://localhost:8466/chunk/upload/3f6b8c2a4d5e6f708192a3b4c5d6e7f8/0 \
|
||||
-F 'chunk=@./part-aa'
|
||||
```
|
||||
|
||||
**成功响应**(200):
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": { "chunk_hash": "9af1…", "chunk_index": 0 } }
|
||||
```
|
||||
|
||||
重复上传已完成的分片(幂等跳过):
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": { "chunk_hash": "9af1…", "chunk_index": 0, "skipped": true } }
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
|
||||
```json
|
||||
{ "code": 404, "msg": "上传会话不存在" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "无效的分片索引" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "分片大小超过声明值: 最大 5242880, 实际 5300000" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "缺少分片文件字段 chunk" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
|
||||
```
|
||||
|
||||
> 约束:单分片 ≤ `chunk_size`(init 声明值)且 ≤ **32MiB 硬上限**(init 时 `chunk_size>33554432` 直接 400「chunk_size 过大」);
|
||||
> 总大小(init 按分片数上限、上传/合并按累计)受**动态策略上限**约束——`max_file_size>0` 时为其,
|
||||
> 否则回落 `uploadSize`(26.9 需求 ④⑩,管理端改后立即生效,超限清理会话);首个分片做 magic bytes
|
||||
> 防伪校验(403「文件内容校验失败:…」)。分片哈希由服务端计算;合并时与分片记录交叉校验,不一致报 400。
|
||||
>
|
||||
> **enableChunk 开关**:管理端关闭分片上传后,`/chunk/upload/*` 全部端点返回 403「分片上传未启用」(后端强制,前端仅隐藏入口)。
|
||||
|
||||
## 查询进度:GET /chunk/upload/status/{uploadID}
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8466/chunk/upload/status/3f6b8c2a4d5e6f708192a3b4c5d6e7f8
|
||||
```
|
||||
|
||||
**成功响应**(200):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"upload_id": "3f6b8c2a4d5e6f708192a3b4c5d6e7f8",
|
||||
"file_name": "movie.mp4",
|
||||
"file_size": 15728640,
|
||||
"chunk_size": 5242880,
|
||||
"total_chunks": 3,
|
||||
"uploaded_chunks": [0, 1],
|
||||
"progress": 66.66666666666667
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 完成合并:POST /chunk/upload/complete/{uploadID}
|
||||
|
||||
全部分片到齐后调用。服务端按索引有序合并 + SHA256 校验,成功后创建分享并清理分片。
|
||||
|
||||
**请求体**(JSON,亦兼容表单):
|
||||
|
||||
| 参数 | 类型 | 必需 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `expire_value` | int | ❌ | `1` | 过期值 |
|
||||
| `expire_style` | string | ❌ | `day` | 过期方式(同文件分享) |
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8466/chunk/upload/complete/3f6b8c2a4d5e6f708192a3b4c5d6e7f8 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"expire_value":1,"expire_style":"day"}'
|
||||
```
|
||||
|
||||
**成功响应**(200):
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": { "code": "R7T2K", "name": "movie.mp4" } }
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "分片不完整" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "分片哈希校验失败,请重新上传" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
|
||||
```
|
||||
|
||||
## 取消上传:DELETE /chunk/upload/{uploadID}
|
||||
|
||||
清理分片文件与上传记录,释放容量预留。
|
||||
|
||||
```bash
|
||||
curl -s -X DELETE http://localhost:8466/chunk/upload/3f6b8c2a4d5e6f708192a3b4c5d6e7f8
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": { "message": "上传已取消" } }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 404, "msg": "上传会话不存在" }
|
||||
```
|
||||
@@ -0,0 +1,210 @@
|
||||
# 预签名直传
|
||||
|
||||
服务端预生成上传地址,客户端直接向存储引擎(或服务端代理)上传文件,最后确认建分享。
|
||||
两种模式:
|
||||
|
||||
| 模式 | 引擎 | 上传方式 |
|
||||
|---|---|---|
|
||||
| `direct` | S3(含 MinIO/R2 等 S3 兼容存储) | 客户端 `PUT` 到预签名 URL,直连对象存储 |
|
||||
| `proxy` | 本地 / WebDAV | 客户端 `PUT` multipart 到服务端代理接口 |
|
||||
|
||||
> 本地 / WebDAV 引擎不支持预签名直链,init 返回正常 `proxy` 模式(仅当引擎预签名调用本身异常时才报错)。
|
||||
> 会话有效期 **900 秒**(15 分钟)。proxy 模式响应另含 `legacy_proxy_upload_url`
|
||||
> (`/api` 前缀的兼容别名,已废弃,与 `upload_url` 等价)。
|
||||
> 全部端点经审计中间件落库(action=upload)。
|
||||
|
||||
## 上传流程
|
||||
|
||||
```text
|
||||
direct 模式:
|
||||
POST /presign/upload/init → upload_url(S3 预签名 PUT)
|
||||
PUT <upload_url> → 客户端直传 S3(无认证头)
|
||||
POST /presign/upload/confirm/{id} → 确认 → 取件码
|
||||
|
||||
proxy 模式:
|
||||
POST /presign/upload/init → upload_url = /presign/upload/proxy/{id}
|
||||
PUT /presign/upload/proxy/{id} → multipart 上传,服务端转存并直接建分享
|
||||
```
|
||||
|
||||
## 初始化:POST /presign/upload/init
|
||||
|
||||
**请求体**(JSON,亦兼容表单):
|
||||
|
||||
| 参数 | 类型 | 必需 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `file_name` | string | ✅ | - | 文件名(清理 + 白名单校验) |
|
||||
| `file_size` | int | ✅ | - | 文件字节数(≤ 生效上限:`max_file_size>0` 时为其,否则 `uploadSize`) |
|
||||
| `expire_value` | int | ❌ | `1` | 过期值(`count` 型受 `max_save_count` 约束) |
|
||||
| `expire_style` | string | ❌ | `day` | 过期方式(须在 `expireStyle` 白名单内) |
|
||||
|
||||
**curl 示例**:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8466/presign/upload/init \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"file_name":"backup.zip","file_size":20971520,"expire_value":7,"expire_style":"day"}'
|
||||
```
|
||||
|
||||
**S3(direct)成功响应**(200):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"upload_id": "6a1e…",
|
||||
"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…"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**local/WebDAV(proxy)成功响应**(200):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"upload_id": "6a1e…",
|
||||
"upload_url": "/presign/upload/proxy/6a1e…",
|
||||
"proxy_upload_url": "/presign/upload/proxy/6a1e…",
|
||||
"mode": "proxy",
|
||||
"expires_in": 900,
|
||||
"file_path": "share/data/2025/06/01/6a1e…"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
|
||||
```json
|
||||
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
|
||||
```
|
||||
|
||||
> 大小上限为动态策略(26.9 需求 ④⑩):管理端改 `max_file_size`(0=回落 `uploadSize`)后
|
||||
> 立即按新上限校验 init 声明的 `file_size`。
|
||||
|
||||
```json
|
||||
{ "code": 403, "msg": "不允许上传该类型文件" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 507, "msg": "存储空间已达到管理员设置的容量上限" }
|
||||
```
|
||||
|
||||
## 直传确认:POST /presign/upload/confirm/{uploadID}
|
||||
|
||||
`direct` 模式专用:客户端向 S3 `PUT` 完成后调用。服务端会核对实际对象(多引擎一致):
|
||||
|
||||
- **大小核对**:实际大小与声明差 >1KB → 400「文件大小与声明不符」;超过策略上限(`max_file_size`)→ **删除对象、释放容量预留**并 403;
|
||||
- **内容校验**:取对象前 64 字节做 magic bytes 白名单校验(失败删除对象并报错);
|
||||
- 全部通过后创建分享记录。
|
||||
|
||||
```bash
|
||||
# 1) 直传 S3(注意:不要带 Authorization 头,预签名 URL 自带鉴权)
|
||||
curl -X PUT "<data.upload_url>" --upload-file ./backup.zip
|
||||
# 2) 确认
|
||||
curl -s -X POST http://localhost:8466/presign/upload/confirm/6a1e…
|
||||
```
|
||||
|
||||
**成功响应**(200):
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": { "code": "B4N8Q", "name": "backup.zip" } }
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
|
||||
```json
|
||||
{ "code": 404, "msg": "文件未上传或上传失败" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "文件大小与声明不符" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 403, "msg": "文件大小超过限制" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "此会话不支持direct模式" }
|
||||
```
|
||||
|
||||
## 代理上传:PUT /presign/upload/proxy/{uploadID}
|
||||
|
||||
`proxy` 模式专用:multipart 上传到服务端,服务端流式转存到存储引擎后立即创建分享记录。
|
||||
|
||||
**multipart 字段**:`file`(必需)。
|
||||
|
||||
**curl 示例**:
|
||||
|
||||
```bash
|
||||
curl -s -X PUT http://localhost:8466/presign/upload/proxy/6a1e… -F 'file=@./backup.zip'
|
||||
```
|
||||
|
||||
**成功响应**(200):
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": { "code": "B4N8Q", "name": "backup.zip" } }
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "缺少上传文件 file 字段" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "文件大小与声明不符" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
|
||||
```
|
||||
|
||||
> 文件实际大小须与 init 声明的 `file_size` 一致(±1KB 容差);成功后会话即删除,不可重复使用。
|
||||
|
||||
## 查询会话:GET /presign/upload/status/{uploadID}
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8466/presign/upload/status/6a1e…
|
||||
```
|
||||
|
||||
**成功响应**(200):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"upload_id": "6a1e…",
|
||||
"file_name": "backup.zip",
|
||||
"file_size": 20971520,
|
||||
"mode": "proxy",
|
||||
"created_at": "2025-06-01T12:00:00+08:00",
|
||||
"expires_at": "2025-06-01T12:15:00+08:00",
|
||||
"is_expired": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 取消会话:DELETE /presign/upload/{uploadID}
|
||||
|
||||
删除会话并释放容量预留;`direct` 模式会尽力清理已直传到 S3 的对象。
|
||||
|
||||
```bash
|
||||
curl -s -X DELETE http://localhost:8466/presign/upload/6a1e…
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": { "message": "上传会话已取消" } }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 404, "msg": "上传会话不存在" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 404, "msg": "上传会话已过期" }
|
||||
```
|
||||
@@ -0,0 +1,451 @@
|
||||
# 管理后台 API
|
||||
|
||||
管理端接口:除 `POST /admin/login` 与初始化向导 `/setup` 外,一律要求
|
||||
`Authorization: Bearer <token>`(见《认证与限流》),无效令牌 401。
|
||||
|
||||
## 初始化向导:GET /setup
|
||||
|
||||
未初始化时返回 HTML 配置页(站点名称、管理员密码、上传/限流/保存策略);
|
||||
已初始化时 `303` 重定向到 `/`。
|
||||
|
||||
```bash
|
||||
curl -i http://localhost:8466/setup
|
||||
```
|
||||
|
||||
## 初始化提交:POST /setup
|
||||
|
||||
表单(浏览器向导)或 JSON 均可;成功后写入库配置 KV 并生成密码哈希与 `jwt_secret`。
|
||||
表单提交返回成功 HTML 页;JSON 提交返回 JSON。
|
||||
|
||||
**主要字段**:
|
||||
|
||||
| 字段 | 必需 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `admin_password` | ✅ | - | 管理员密码(≥8 位) |
|
||||
| `confirm_password` | ✅ | - | 确认密码(须一致) |
|
||||
| `site_name` | ❌ | 文件快传 | 站点名称 |
|
||||
| `upload_size_value` / `upload_size_unit` | ❌ | 10 / MB | 单文件大小限制(单位 KB/MB/GB) |
|
||||
| `save_time_value` / `save_time_unit` | ❌ | 0 / day | 最长保存秒数(0=不限) |
|
||||
| `expireStyle` | ❌ | day,hour,minute,forever,count | 过期方式(可多值/逗号分隔) |
|
||||
| `code_generate_type` | ❌ | secret | 取件码类型 `number`/`secret` |
|
||||
| `errorCount` / `errorMinute` | ❌ | 10 / 1 | 取件错误限流 |
|
||||
| `loginCount` / `loginMinute` | ❌ | 5 / 15 | 登录失败限流 |
|
||||
| `uploadCount` / `uploadMinute` | ❌ | 10 / 1 | 上传限流 |
|
||||
| `allowed_file_types` | ❌ | `*` | 逗号分隔白名单 |
|
||||
| `openUpload` / `enableChunk` | ❌ | 1 / 0 | 游客上传 / 分片开关(`1`/`true`/`on`/`yes`) |
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8466/setup \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"admin_password":"admin12345","confirm_password":"admin12345","site_name":"我的文件柜","upload_size_value":10,"upload_size_unit":"MB"}'
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": { "ok": true, "admin": "/#/admin" } }
|
||||
```
|
||||
|
||||
**错误响应**(400,HTML 表单时内嵌错误提示):
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "管理员密码至少 8 位" }
|
||||
```
|
||||
|
||||
## 登录:POST /admin/login
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8466/admin/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"password":"admin12345"}'
|
||||
```
|
||||
|
||||
**成功响应**(200):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"id": "admin", "username": "admin",
|
||||
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"token_type": "Bearer",
|
||||
"expires_at": 1750000000,
|
||||
"expires_in": 604800
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
|
||||
```json
|
||||
{ "code": 401, "msg": "密码错误" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 423, "msg": "请求次数过多,请稍后再试" }
|
||||
```
|
||||
|
||||
## 校验会话:GET /admin/verify
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8466/admin/verify -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": { "id": "admin", "username": "admin", "token": "eyJ…", "token_type": "Bearer", "expires_at": 1750000000 } }
|
||||
```
|
||||
|
||||
## 登出:POST /admin/logout
|
||||
|
||||
无状态 JWT,服务端仅返回确认(客户端应丢弃令牌):
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": { "ok": true } }
|
||||
```
|
||||
|
||||
## 仪表盘:GET /admin/dashboard
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8466/admin/dashboard -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"totalFiles": 42,
|
||||
"storageUsed": "123456789",
|
||||
"sysUptime": 1750000000000,
|
||||
"yesterdayCount": 5, "yesterdaySize": "1048576",
|
||||
"todayCount": 12, "todaySize": "5242880",
|
||||
"activeCount": 40, "expiredCount": 2,
|
||||
"textCount": 10, "fileCount": 32, "chunkedCount": 3,
|
||||
"usedCount": 156,
|
||||
"storageBackend": "local",
|
||||
"uploadSizeLimit": 10485760,
|
||||
"openUpload": 1, "enableChunk": 1,
|
||||
"maxSaveSeconds": 0,
|
||||
"topSuffixes": [ { "suffix": ".pdf", "count": 12 }, { "suffix": "Text", "count": 10 } ],
|
||||
"recentFiles": [ { "id": 42, "code": "K3P9W", "name": "report.pdf", "size": 1048576, "created_at": "2025-06-01T12:00:00+08:00", "expired_at": "2025-06-08T12:00:00+08:00", "is_expired": false, "expired_count": -1, "used_count": 3, "is_text": false, "is_chunked": false, "is_permanent": false, "has_download_limit": false, "remaining_downloads": null, "file_hash": null, "prefix": "report", "suffix": ".pdf", "text": false, "createdAt": "2025-06-01T12:00:00+08:00", "expiredAt": "2025-06-08T12:00:00+08:00", "isExpired": false, "expiredCount": -1, "usedCount": 3, "isText": false, "isChunked": false, "isPermanent": false, "hasDownloadLimit": false, "remainingDownloads": null, "fileHash": null } ],
|
||||
"recentActivities": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `storageUsed`/`todaySize`/`yesterdaySize` 为字符串字节数;`sysUptime` 为服务启动时刻的 Unix 毫秒。
|
||||
|
||||
## 文件列表:GET /admin/file/list
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `page` / `size` | 1 / 10 | 分页(size 1~100) |
|
||||
| `keyword` | - | 模糊匹配取件码/文件名/哈希/文本内容 |
|
||||
| `status` | - | `active` / `expired` |
|
||||
| `type` | - | `text` / `file` / `chunked` |
|
||||
| `sortBy` | `created_at` | `created_at`/`expired_at`/`name`/`size`/`used_count`/`code` |
|
||||
| `sortOrder` | `desc` | `asc` / `desc` |
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:8466/admin/file/list?page=1&size=10&status=active&sortBy=size&sortOrder=desc" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"page": 1, "size": 10, "total": 40,
|
||||
"summary": { "totalFiles": 42, "activeCount": 40, "expiredCount": 2, "textCount": 10, "fileCount": 32, "chunkedCount": 3, "storageUsed": 123456789, "usedCount": 156 },
|
||||
"data": [
|
||||
{ "id": 42, "code": "K3P9W", "name": "report.pdf", "prefix": "report", "suffix": ".pdf", "size": 1048576, "is_text": false, "is_chunked": false, "is_expired": false, "expired_at": "2025-06-08T12:00:00+08:00", "expired_count": -1, "used_count": 3, "created_at": "2025-06-01T12:00:00+08:00", "has_download_limit": false, "is_permanent": false, "remaining_downloads": null, "file_hash": null, "text": false, "isText": false, "isChunked": false, "isExpired": false, "expiredAt": "2025-06-08T12:00:00+08:00", "expiredCount": -1, "usedCount": 3, "createdAt": "2025-06-01T12:00:00+08:00", "hasDownloadLimit": false, "isPermanent": false, "remainingDownloads": null, "fileHash": null }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 文件详情:GET /admin/file/detail
|
||||
|
||||
`GET ?id=42` 或 `POST {"id":42}`。返回列表条目字段;文本分享额外含 `content`(全文)。
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": { "id": 42, "code": "K3P9W", "name": "report.pdf", "size": 1048576, "is_text": false, "expired_at": "2025-06-08T12:00:00+08:00", "expired_count": -1, "used_count": 3, "created_at": "2025-06-01T12:00:00+08:00", "file_hash": null } }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 404, "msg": "文件不存在" }
|
||||
```
|
||||
|
||||
## 更新文件:PATCH /admin/file/update
|
||||
|
||||
更新取件码/文件名(前后缀)/过期策略。**PATCH 为主名,POST 为兼容别名**。
|
||||
|
||||
**请求体**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 必需 |
|
||||
| `code` | string | 新取件码(冲突 400「code已存在」) |
|
||||
| `prefix` / `suffix` | string | 文件名前后缀 |
|
||||
| `expired_at` | string | 过期时间(ISO 8601,如 `2025-07-01T00:00:00+08:00`) |
|
||||
| `expired_count` | int | 取件次数上限(`-1` 按时间/永久) |
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH http://localhost:8466/admin/file/update \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"id":42,"expired_at":"2025-07-01T00:00:00+08:00","expired_count":10}'
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": "更新成功" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "code已存在" }
|
||||
```
|
||||
|
||||
## 删除文件:DELETE /admin/file/delete
|
||||
|
||||
删除分享记录并连带删除存储文件(文本分享无存储文件)。`DELETE` 或 `POST`,请求体 `{"id":42}`。
|
||||
|
||||
```bash
|
||||
curl -s -X DELETE http://localhost:8466/admin/file/delete \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"id":42}'
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": null }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "请选择要删除的文件" }
|
||||
```
|
||||
|
||||
## 批量删除:POST /admin/file/batch-delete
|
||||
|
||||
`POST` 或 `DELETE`,请求体 `{"ids":[41,42,43]}`。返回逐条统计:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"requestedCount": 3, "deletedCount": 2, "missingCount": 1, "failedCount": 0,
|
||||
"deleted": [41, 42], "missing": [43], "failed": [],
|
||||
"requested_count": 3, "deleted_count": 2, "missing_count": 1, "failed_count": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 批量更新:PATCH /admin/file/batch-update
|
||||
|
||||
`PATCH` 或 `POST`。请求体:`ids[]` 必需;`expired_at`(ISO 8601)/`expired_count` 二选一;
|
||||
`clearExpiredAt: true`(或 `clear_expired_at`)= 清空过期时间并置 `expired_count=-1`(永久)。
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH http://localhost:8466/admin/file/batch-update \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"ids":[41,42],"clearExpiredAt":true}'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": { "requestedCount": 2, "updatedCount": 2, "missingCount": 0, "failedCount": 0, "updated": 2, "missing": [], "failed": [], "requested_count": 2, "updated_count": 2, "missing_count": 0, "failed_count": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
## 过期策略动作:PATCH /admin/file/policy-action
|
||||
|
||||
对单个文件执行快捷策略。`PATCH` 或 `POST`。批量版为 `/admin/file/batch-policy-action`(`{"ids":[…]}`),响应结构同批量更新。
|
||||
|
||||
**请求体**:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `id` | 文件 ID |
|
||||
| `action` | `extend_24h` / `extend_7d` / `make_permanent` / `reset_download_limit` |
|
||||
| `downloadLimit` | 仅 `reset_download_limit` 用:新取件次数(默认 5,须 >0) |
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH http://localhost:8466/admin/file/policy-action \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"id":42,"action":"reset_download_limit","downloadLimit":3}'
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": { "id": 42, "action": "reset_download_limit" } }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "不支持的策略动作" }
|
||||
```
|
||||
|
||||
> `extend_24h`/`extend_7d` 在当前过期时间(未过期时)基础上顺延;`make_permanent` 清空过期时间并置次数 -1。
|
||||
|
||||
## 管理员下载:GET /admin/file/download?id=
|
||||
|
||||
下载原文件(**不消耗取件次数**);文件返回二进制流(支持 Range),文本分享返回 JSON(`data` 为文本内容)。
|
||||
|
||||
```bash
|
||||
curl -s -OJ "http://localhost:8466/admin/file/download?id=42" -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
## 文本预览:GET /admin/file/preview
|
||||
|
||||
仅文本分享可用(文件分享返回 400)。`maxChars` 截断长度(默认 4000,1~20000)。
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:8466/admin/file/preview?id=41&maxChars=100" -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"id": 41, "code": "8XQ2M", "name": "Text.txt", "type": "text",
|
||||
"content": "你好,文件快传",
|
||||
"length": 24, "previewLength": 24, "truncated": false,
|
||||
"maxChars": 100, "max_chars": 100,
|
||||
"created_at": "2025-06-01T12:00:00+08:00", "createdAt": "2025-06-01T12:00:00+08:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "仅文本分享支持预览" }
|
||||
```
|
||||
|
||||
## 读取配置:GET /admin/config/get
|
||||
|
||||
返回运行时配置 KV(含默认值与管理端修改)。`admin_token` 恒返回空串(屏蔽);
|
||||
`jwt_secret` 不下发;存储引擎为进程级单例,`_engine_hint` 提示引擎配置修改需重启。
|
||||
26.9 新增键(需求 ①②③④⑩)一并返回:`background_url`、`footer_text`、`footer_beian`、
|
||||
`notify_enabled`、`max_save_count`、`max_file_size` 等。
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"site_name": "文件快传",
|
||||
"name": "文件快传",
|
||||
"description": "开箱即用的文件快传系统",
|
||||
"page_explain": "…", "keywords": "…",
|
||||
"notify_title": "系统通知", "notify_content": "…", "notify_enabled": 1,
|
||||
"logo_url": "",
|
||||
"favicon_url": "",
|
||||
"background_url": "", "footer_text": "", "footer_beian": "",
|
||||
"openUpload": 1, "uploadSize": 10485760,
|
||||
"max_file_size": 0, "max_save_count": 0,
|
||||
"allowed_file_types": ["*"], "expireStyle": ["day","hour","minute","forever","count"],
|
||||
"code_generate_type": "secret", "enableChunk": 1,
|
||||
"uploadMinute": 1, "uploadCount": 10,
|
||||
"errorMinute": 1, "errorCount": 10,
|
||||
"loginCount": 5, "loginMinute": 15,
|
||||
"max_save_seconds": 0, "storageLimit": 0,
|
||||
"opacity": 0.9, "background": "", "showAdminAddr": 0, "robotsText": "User-agent: *\nDisallow: /",
|
||||
"adminSessionExpire": 604800,
|
||||
"storage_path": "", "local_storage_path": "/app/data",
|
||||
"file_storage": "local",
|
||||
"admin_token": "",
|
||||
"_engine_hint": { "storage_backend": "local", "note": "存储引擎为进程级单例,修改存储引擎相关配置后需重启服务生效" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 存储引擎热切换:POST /admin/storage/switch(26.9)
|
||||
|
||||
运行时切换存储引擎,**无需重启**:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8466/admin/storage/switch \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"engine":"s3"}'
|
||||
# 成功:{"code":200,"msg":"ok","data":{"ok":true,"engine":"s3"}}
|
||||
# 失败:503 {"code":503,"msg":"存储引擎切换失败,已保持原引擎: …"}
|
||||
```
|
||||
|
||||
- `engine` 仅接受 `local|s3|webdav`(400 中文错误)。
|
||||
- 切换流程:用最新 KV 参数构建新引擎 → 健康检查 → 通过才替换当前引擎并持久化 `storage_engine`。
|
||||
- 失败(构建/健康检查不过)返回 503,**原引擎与 KV 均保持不变**。
|
||||
- 切到当前引擎为幂等操作(直接返回 200)。
|
||||
- 旧文件按归属引擎(`file_codes.engine`)取回,切换后旧引擎文件仍可下载。
|
||||
|
||||
## 更新配置:PATCH /admin/config/update
|
||||
|
||||
部分更新(JSON 对象,未提供的键不变;表单亦可)。**PATCH 为主名,POST 为兼容别名**。
|
||||
|
||||
- 仅接受管理端可见键(见上响应键集合),未知键忽略。
|
||||
- 数值型键自动转型:`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`)可经本端点保存——保存后对应引擎实例缓存失效,
|
||||
下次切换/构建生效;敏感键空串或 `******` 表示不修改。
|
||||
- **26.9 schema 校验**(`settings.KVSchema`,越界一律 400,中文错误信息):
|
||||
- 整型边界:`max_file_size` ≤ 10GiB(10737418240)、`max_save_count` ≤ 100000、`max_save_seconds` ≤ 31536000(365 天)、`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`:须为字符串数组(或逗号分隔串)且至少保留一项;
|
||||
- 错误示例:`{"code":400,"msg":"max_file_size 必须是整数"}`、`{"code":400,"msg":"max_file_size 不能大于 10737418240"}`、`{"code":400,"msg":"footer_beian 长度不能超过 128 字符"}`、`{"code":400,"msg":"notify_enabled 不能大于 1"}`。
|
||||
- `background_url` 协议白名单(需求 ①,防 `javascript:` 注入):仅 `http(s)://`、`data:image/*` 与站内相对路径(`/`开头);空串=清除背景。非法值 400:
|
||||
`{"code":400,"msg":"background_url 仅支持 http(s) 地址、data:image 图片或站内相对路径"}`
|
||||
- `admin_token`:明文密码自动哈希,**并轮换 `jwt_secret`(全部管理员令牌立即失效)**;空串忽略;已是哈希格式则原样保存。
|
||||
- `adminSessionExpire` 须为 1~365 的整天秒数(86400 的整数倍),否则 400。
|
||||
- `storageLimit` 不能小于 0。
|
||||
- 修改限流/策略配置**立即生效**(无需重启:限流规则运行时同步,策略由上传链路每次实时读取);引擎相关(`file_storage`/`s3_*`/`webdav_*`/`storage_path`/`local_storage_path`)需重启。
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH http://localhost:8466/admin/config/update \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"site_name":"我的文件柜","uploadSize":52428800,"openUpload":1,"footer_beian":"京ICP备20240001号","max_file_size":10485760,"max_save_count":5}'
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": { "ok": true } }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "adminSessionExpire 必须是 1 到 365 个整天" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "background_url 仅支持 http(s) 地址、data:image 图片或站内相对路径" }
|
||||
```
|
||||
|
||||
## 修改管理员密码:PATCH /admin/settings/password
|
||||
|
||||
`PATCH` 或 `POST`。新密码 ≥8 位;成功后哈希保存并**轮换 `jwt_secret`,所有旧令牌失效(401)**,需重新登录。
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH http://localhost:8466/admin/settings/password \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"old_password":"admin12345","new_password":"new-pass-6789"}'
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": { "ok": true } }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "新密码长度至少 8 位" }
|
||||
```
|
||||
|
||||
```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`(引用计数开关)。
|
||||
@@ -0,0 +1,121 @@
|
||||
# 审计日志查询
|
||||
|
||||
所有上传/下载请求由审计中间件自动落库(需求 ③),管理端分页查询。
|
||||
认证:`Authorization: Bearer <token>`。
|
||||
|
||||
## 审计记录内容
|
||||
|
||||
每条审计日志覆盖以下维度(需求 ③):
|
||||
|
||||
| 维度 | 字段 | 说明 |
|
||||
|---|---|---|
|
||||
| 操作时间 | `created_at` | RFC 3339 |
|
||||
| 客户端 | `ip` | 可信代理场景解析 XFF 后的真实 IP |
|
||||
| 终端信息 | `user_agent` | 原始 UA |
|
||||
| 设备解析 | `device_os` / `device_browser` / `device_type` | 由 UA 解析(如 Windows/Chrome/desktop) |
|
||||
| 动作 | `action` | `upload`(上传类) / `download`(取件/下载类) / `admin`(管理端敏感操作,26.9 新增) |
|
||||
| 结果 | `result` | `success` / `denied`(拒绝:401/403/423/428)/ `failed`(失败:其余 4xx/5xx 或业务报错) |
|
||||
| 字节数 | `size_bytes` | 文件总大小;`transferred_bytes` 实际传输(**Range 下载只计实际区间字节**;下载由中间件自动统计,上传由各 handler 填充) |
|
||||
| 耗时 | `duration_ms` | 毫秒 |
|
||||
| 角色 | `actor` | `admin`(有效管理员令牌)/ `guest` |
|
||||
| 业务 | `file_code` / `file_name` | 取件码 / 文件名(分片上传时 `file_code` 为 `upload_id`) |
|
||||
| 错误 | `error_msg` | 失败/拒绝原因 |
|
||||
|
||||
**命中审计的端点**:上传类 `POST /share/text`、`POST /share/file`、`/chunk/upload*`、`/presign*`(POST/PUT);
|
||||
下载类 `GET /share/download`、`GET /share/select`、`GET /share/metadata`;
|
||||
管理类(`action=admin`)`POST /admin/login`、`POST /admin/logout`、`PATCH|POST /admin/config/update`、
|
||||
`PATCH|POST /admin/settings/password`、`POST /admin/storage/switch`、`PATCH|DELETE /admin/file/update|delete|batch-delete|batch-update|policy-action|batch-policy-action`。
|
||||
管理类动作未显式填结果时按 HTTP 状态兜底落库(401/403/423 → `denied`,5xx → `failed`,其余 → `success`)。
|
||||
失败与被拒绝的请求同样落库。
|
||||
|
||||
## 查询接口:GET /admin/audit/list
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `page` | 1 | 页码(≥1) |
|
||||
| `size` | 20 | 每页条数(1~200;兼容 `pageSize`) |
|
||||
| `action` | - | `upload` / `download` / `admin` |
|
||||
| `result` | - | `success` / `denied` / `failed` |
|
||||
| `ip` | - | 按客户端 IP 过滤 |
|
||||
| `start_time` / `end_time` | - | 时间范围,ISO 8601(如 `2025-06-01T00:00:00+08:00`;也接受 `2006-01-02 15:04:05` / 日期) |
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:8466/admin/audit/list?page=1&size=20&action=download&result=success&start_time=2025-06-01T00:00:00%2B08:00" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
**成功响应**(200):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"data": [
|
||||
{
|
||||
"id": 318,
|
||||
"action": "download",
|
||||
"file_code": "K3P9W",
|
||||
"file_name": "report.pdf",
|
||||
"size_bytes": 1048576,
|
||||
"transferred_bytes": 524288,
|
||||
"ip": "203.0.113.7",
|
||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
||||
"device_os": "Windows",
|
||||
"device_browser": "Chrome",
|
||||
"device_type": "desktop",
|
||||
"actor": "guest",
|
||||
"result": "success",
|
||||
"error_msg": "",
|
||||
"duration_ms": 128,
|
||||
"created_at": "2025-06-01T12:03:45+08:00",
|
||||
"fileCode": "K3P9W",
|
||||
"fileName": "report.pdf",
|
||||
"sizeBytes": 1048576,
|
||||
"transferredBytes": 524288,
|
||||
"userAgent": "Mozilla/5.0 …",
|
||||
"deviceOs": "Windows",
|
||||
"deviceBrowser": "Chrome",
|
||||
"deviceType": "desktop",
|
||||
"errorMsg": "",
|
||||
"durationMs": 128,
|
||||
"createdAt": "2025-06-01T12:03:45+08:00"
|
||||
}
|
||||
],
|
||||
"total": 1180,
|
||||
"page": 1,
|
||||
"size": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 行字段以 snake_case 为准;camelCase 为兼容双份输出(文档不再重复列出)。
|
||||
|
||||
**错误响应**:
|
||||
|
||||
```json
|
||||
{ "code": 400, "msg": "start_time 时间格式错误" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 401, "msg": "令牌无效或已过期" }
|
||||
```
|
||||
|
||||
## 别名:GET /admin/audit/logs
|
||||
|
||||
与 `/admin/audit/list` 完全相同(同一 handler 的兼容别名),参数与响应一致。
|
||||
|
||||
## 典型查询
|
||||
|
||||
```bash
|
||||
# 最近的下载行为
|
||||
curl -s "http://localhost:8466/admin/audit/list?action=download&size=50" -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# 某 IP 的全部被拒请求(限流/鉴权失败)
|
||||
curl -s "http://localhost:8466/admin/audit/list?ip=203.0.113.7&result=denied" -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# 今天 0 点以来的上传失败
|
||||
curl -s "http://localhost:8466/admin/audit/list?action=upload&result=failed&start_time=2025-06-01T00:00:00%2B08:00" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
@@ -0,0 +1,119 @@
|
||||
# 存储引擎配置
|
||||
|
||||
存储引擎只支持三种:**本地磁盘 / S3 / WebDAV**,由进程级环境变量 `FCB_STORAGE_ENGINE` 选择。
|
||||
引擎与引擎相关配置在启动时一次性读取(`storage.SetEngineOptions` → `NewEngine`),
|
||||
**运行时修改引擎 KV 需重启服务**(管理端 `GET /admin/config/get` 的 `_engine_hint` 亦有提示)。
|
||||
|
||||
## 引擎选择
|
||||
|
||||
```bash
|
||||
FCB_STORAGE_ENGINE=local # 启动默认(KV storage_engine 为空时生效)
|
||||
FCB_STORAGE_ENGINE=s3
|
||||
FCB_STORAGE_ENGINE=webdav
|
||||
```
|
||||
|
||||
非法值直接启动失败:`FCB_STORAGE_ENGINE 无效值 "xxx",仅支持 local|s3|webdav`。
|
||||
|
||||
**26.9 运行时热切换**:管理端 `POST /admin/storage/switch`(或后台设置页「存储引擎」卡)可在不重启的情况下切换引擎——
|
||||
先构建新引擎并健康检查,通过才生效;失败 503 保持原引擎。当前引擎持久化在 settings KV `storage_engine`(空=回落启动值)。
|
||||
各引擎参数(存储目录/服务地址/存储桶/密钥)同样在后台设置页运行时可改;保存后对应引擎实例缓存失效,下次切换/构建生效。
|
||||
|
||||
## 文件归属引擎(26.9)
|
||||
|
||||
每条分享记录(`file_codes.engine`)与上传会话(`upload_chunks.engine` / `presign_upload_sessions.engine`)
|
||||
在创建时戳记当时的引擎名。下载、分片合并、删除按**归属引擎**操作——切换引擎后,旧引擎里的文件仍可正常下载与删除
|
||||
(空戳为历史数据,回落当前引擎)。
|
||||
|
||||
## 按日期目录存储
|
||||
|
||||
文件落盘路径:`[storage_path/]share/data/YYYY/MM/DD/<uuid>/<文件名>`(如 `share/data/2026/09/04/…`)。
|
||||
按日嵌套目录自然排序、无日月歧义、避免单日海量文件挤在单目录;三种引擎一致适用;历史路径记录在
|
||||
`file_codes.file_path`,不受路径规则调整影响。
|
||||
|
||||
## 配置键与环境变量
|
||||
|
||||
引擎相关配置键(DB settings KV)可由环境变量种子注入(优先级:默认 < 环境变量 < DB KV):
|
||||
|
||||
| KV 键 | 环境变量 | 引擎 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `local_storage_path` | `FCB_LOCAL_STORAGE_PATH` | local | 本地存储根目录(容器内默认 `/app/data`) |
|
||||
| `storage_path` | `FCB_STORAGE_PATH` | 全部 | 存储相对路径前缀(空 = `share/data/…`) |
|
||||
| `s3_access_key_id` | `FCB_S3_ACCESS_KEY_ID` | s3 | 访问密钥 |
|
||||
| `s3_secret_access_key` | `FCB_S3_SECRET_ACCESS_KEY` | s3 | 私有密钥 |
|
||||
| `aws_session_token` | `FCB_AWS_SESSION_TOKEN` | s3 | 可选临时会话令牌 |
|
||||
| `s3_bucket_name` | `FCB_S3_BUCKET_NAME` | s3 | 桶名 |
|
||||
| `s3_endpoint_url` | `FCB_S3_ENDPOINT_URL` | s3 | S3 兼容端点(MinIO/R2 等;AWS 原生可空) |
|
||||
| `s3_region_name` | `FCB_S3_REGION_NAME` | s3 | 区域(默认 `auto`) |
|
||||
| `s3_addressing_style` | `FCB_S3_ADDRESSING_STYLE` | s3 | `auto`/`path`/`virtual` |
|
||||
| `webdav_url` | `FCB_WEBDAV_URL` | webdav | WebDAV 服务地址(如 `http://webdav:5000`) |
|
||||
| `webdav_username` | `FCB_WEBDAV_USERNAME` | webdav | 用户名 |
|
||||
| `webdav_password` | `FCB_WEBDAV_PASSWORD` | webdav | 密码 |
|
||||
| `webdav_root_path` | `FCB_WEBDAV_ROOT_PATH` | webdav | 根目录(默认 `filebox_storage`,不存在自动逐级创建) |
|
||||
|
||||
`FCB_STORAGE_ENGINE` 本身不落库(`GET /admin/config/get` 中的 `file_storage` 为 KV 记忆键,
|
||||
进程实际引擎以 `FCB_STORAGE_ENGINE` 与 `_engine_hint.storage_backend` 为准)。
|
||||
|
||||
## 各引擎要点
|
||||
|
||||
### 本地引擎(local)
|
||||
|
||||
- 原子写:临时文件 + fsync + rename,避免半写文件。
|
||||
- 路径安全:`SanitizePath` + 符号链接逃逸双重防穿越。
|
||||
- Range 下载基于 `SectionReader`;分片按索引有序合并 + SHA256 校验,合并后清理分片目录。
|
||||
|
||||
### S3 引擎(s3)
|
||||
|
||||
- 原生 multipart 流式合并(失败自动 Abort),分片落临时文件保证精确 Content-Length 与可重放。
|
||||
- 预签名 GET/PUT 直链(预签名直传唯一 `direct` 模式引擎)。
|
||||
- SDK 内置 5xx 指数退避重试;`when_required` 校验模式兼容 MinIO/R2 与纯流式转发。
|
||||
|
||||
### WebDAV 引擎(webdav,重点优化)
|
||||
|
||||
- **连接复用**:池化 Transport,连接复用(实测 25 次请求仅 1 条 TCP 连接)。
|
||||
- **认证**:Basic + Digest(RFC 2617 `qop=auth`,MD5/SHA-256)自动协商,401 挑战驱动。
|
||||
- **Range**:`Range` 头透传 + `206` 解析,支持分块/断点下载。
|
||||
- **重试**:5xx/429/408 指数退避(封顶 2s ± 20% 抖动,尊重 `Retry-After`)。
|
||||
- **流式**:下载经 `io.Pipe` 流式转发不落盘;上下文取消挂到响应体读完之后,防止提前断连。
|
||||
- **目录**:按需逐级 `MKCOL` + 目录缓存,避免重复建目录。
|
||||
- **超时**:可配置(`webdav_url` 同级暂无独立超时键,引擎默认值内置)。
|
||||
|
||||
## 健康检查
|
||||
|
||||
三引擎均实现 `HealthCheck`:local 写探针、s3 `ListObjectsV2`、webdav `PROPFIND`(根目录不存在时自建)。
|
||||
服务启动时预检失败仅告警不阻断;运行状态可经 `GET /api/v1/health` 的 `data.storage` 查看当前引擎名。
|
||||
|
||||
## 预签名直传支持矩阵
|
||||
|
||||
| 引擎 | `PresignPutURL` / `PresignGetURL` | init 返回 mode |
|
||||
|---|---|---|
|
||||
| s3 | ✅ | `direct` |
|
||||
| local / webdav | ❌(`ErrNotSupported`) | `proxy`(走服务端代理上传) |
|
||||
|
||||
引擎不支持的操作经统一映射返回 501:
|
||||
|
||||
```json
|
||||
{ "code": 501, "msg": "当前存储引擎不支持该操作" }
|
||||
```
|
||||
|
||||
## Docker Compose 冒烟编排
|
||||
|
||||
`deploy/docker-compose.yml` 提供可选 profile(详见 deploy/README.md):
|
||||
|
||||
```bash
|
||||
docker compose --profile minio up -d --build # MinIO(含 mc 自动建桶)+ FCB_STORAGE_ENGINE=s3
|
||||
docker compose --profile webdav up -d --build # dufs WebDAV 冒烟(admin/admin123)+ FCB_STORAGE_ENGINE=webdav
|
||||
docker compose --profile redis up -d --build # Redis 缓存增强(非引擎)
|
||||
```
|
||||
|
||||
## 存储哨兵错误 → HTTP 状态
|
||||
|
||||
| 哨兵错误 | HTTP | 文案 |
|
||||
|---|---|---|
|
||||
| `ErrNotFound` | 404 | 文件不存在 |
|
||||
| `ErrInvalidPath` | 400 | 非法文件路径 |
|
||||
| `ErrUnavailable` | 503 | 存储服务不可用,请稍后再试 |
|
||||
| `ErrNotSupported` | 501 | 当前存储引擎不支持该操作 |
|
||||
| `ErrRangeNotSatisfiable` | 416 | 请求范围超出文件大小 |
|
||||
| `ErrHashMismatch` | 400 | 分片哈希校验失败,请重新上传 |
|
||||
|
||||
容量超限(`storageLimit`,经容量预留判定)返回 507:`存储空间已达到管理员设置的容量上限`。
|
||||
@@ -0,0 +1,199 @@
|
||||
# 环境变量与配置项
|
||||
|
||||
配置分三层:**默认值 → `FCB_*` 环境变量 → 数据库 settings KV(管理端运行时修改)**。
|
||||
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/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` 被抢占窗口;初始化完成后建议移除 |
|
||||
| `FCB_LISTEN` | ❌ | `:8466` | HTTP 监听地址 |
|
||||
| `FCB_STORAGE_ENGINE` | ❌ | `local` | 存储引擎:`local` / `s3` / `webdav` |
|
||||
| `FCB_TRUSTED_PROXIES` | ❌ | 空 | 可信代理 CIDR(逗号分隔),命中时从 `X-Forwarded-For` 解析真实客户端 IP |
|
||||
|
||||
- SQLite 连接参数(驱动自动注入):`busy_timeout=10s` + `WAL` 日志模式 + `foreign_keys=1`;连接池 8/4。
|
||||
- Postgres 连接池沿用 v1 参数(32/8,1h 轮换);`FCB_DB_DRIVER=postgres` 且未设 `FCB_DB_DSN` 时**启动直接报错**。
|
||||
|
||||
引擎相关环境变量(种子注入 settings KV,见《存储引擎配置》):`FCB_LOCAL_STORAGE_PATH`、
|
||||
`FCB_STORAGE_PATH`、`FCB_S3_ACCESS_KEY_ID`、`FCB_S3_SECRET_ACCESS_KEY`、`FCB_AWS_SESSION_TOKEN`、
|
||||
`FCB_S3_BUCKET_NAME`、`FCB_S3_ENDPOINT_URL`、`FCB_S3_REGION_NAME`、`FCB_S3_ADDRESSING_STYLE`、
|
||||
`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`(默认 fileshare,仅 `--profile postgres` 时使用)。
|
||||
|
||||
## 配置项(settings KV,默认值对齐参考实现)
|
||||
|
||||
> 键名/类型/默认值/边界以 `server/internal/config/schema.go` 的 `KVSchema()` 为单一事实来源
|
||||
> (schema 同步测试保证与 defaults() 逐键一致);26.9 新增键统一 snake_case。
|
||||
|
||||
### 站点信息与展示(需求 ①②③)
|
||||
|
||||
| 键 | 类型/边界 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `site_name` / `name` | string | 文件快传 | 站点名称(`site_name` 优先) |
|
||||
| `site_domain` | string,≤256 | 空 | **26.9**:站点对外域名(`http(s)://host[:port]`,不带路径;裸主机自动补 `http://`)。配置后分享链接(结果卡/管理端复制)用该域名生成——内网部署也能把公网链接发出去;留空=用当前访问地址 |
|
||||
| `description` | string | 开箱即用的文件快传系统 | 站点描述 |
|
||||
| `page_explain` | string | (合规声明) | 页面说明文案 |
|
||||
| `keywords` | string | 文件快传, 文件分享… | SEO 关键词 |
|
||||
| `logo_url` | string | 空(前端回落本地打包 `/assets/logo-*.svg`,需求 ⑤) | 页面导航 Logo,管理端可设任意 URL |
|
||||
| `favicon_url` | string | 空(前端回落本地打包 `/assets/favicon-*.png`,需求 ⑤) | favicon / 备用 Logo |
|
||||
| `opacity` | float | 0.9 | 界面不透明度 |
|
||||
| `background` | string | 空 | 背景图 URL(参考实现既有键,v1 兼容保留) |
|
||||
| `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` | int(0/1) | 1 | **26.9 需求 ③**:通知开关(1=前台右上角悬浮窗展示 / 0=关闭) |
|
||||
| `notify_title` | string,≤128 字符 | 系统通知 | 通知标题 |
|
||||
| `notify_content` | string,≤2000 字符 | 欢迎使用… | 通知正文(**服务端白名单净化**:仅保留纯文本与 `<a href>` 为 http(s)/站内相对/`#` 锚点的链接,其余标签与事件属性剥离,保存与读取双侧生效) |
|
||||
| `showAdminAddr` | int(0/1) | 0 | 是否展示后台入口 |
|
||||
| `robotsText` | string | `User-agent: *\nDisallow: /` | robots.txt 内容(由公开端点 `GET /robots.txt` 输出) |
|
||||
|
||||
### 保存策略(需求 ④,上传页动态读取并在范围内选择)
|
||||
|
||||
| 键 | 类型/边界 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `max_save_seconds` | int64,0~31536000 | 0 | 最长保存秒数上限(0=仅默认 7 天兜底;>0 时按时间过期超限 403「限制最长时间为 X,可换用其他方式」)。**26.9**:管理界面以「小时/天」下拉单位编辑(≥1 天自动显示天),提交时前端换算为秒——canonical 单位保持秒,接口语义不变 |
|
||||
| `max_save_count` | int,0~100000 | 0 | **26.9 新增**:单次分享最大可取(保存)次数上限(0=不限制;`expire_style=count` 且 `expire_value` 超上限时 403「限制次数最多为 N 次」) |
|
||||
| `expireStyle` | []string | `["day","hour","minute","forever","count"]` | 允许的过期方式白名单(上传时不在白名单 400「过期时间类型错误」) |
|
||||
|
||||
### 存储策略(需求 ④⑩)
|
||||
|
||||
| 键 | 类型/边界 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `uploadSize` | int64,1024~10GiB | 10485760(10MB) | 单文件大小上限(字节),参考实现语义;`max_file_size=0` 时作为生效上限 |
|
||||
| `max_file_size` | int64,0~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` | int(0/1) | 1 | 游客上传开关(0 时上传接口要求管理员令牌 403) |
|
||||
| `enableChunk` | int(0/1) | 0 | 启用分片上传 |
|
||||
|
||||
### 上传频率限制(需求 ④,既有键对齐参考 ip_limit["upload"])
|
||||
|
||||
| 键 | 类型/边界 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `uploadCount` / `uploadMinute` | int(1~10000 / 1~1440) | 10 / 1 | 窗口内允许上传次数 / 窗口分钟(上传成功才计数,超限 423;管理端修改后运行时同步限流规则,立即生效) |
|
||||
| `upload_rate` / `download_rate` | int64(0~1 GiB/s) | 0 / 0 | **26.9**:上下行带宽字节/秒,0=不限速;管理端改后立即生效(每请求动态读 KV)。详见《[带宽限速](13-bandwidth.md)》 |
|
||||
| `recycle_enabled` | 0/1 | 1 | **26.9**:过期分享自动回收开关(定时扫描 + 取件惰性回收) |
|
||||
| `recycle_interval` | int64(60~86400 秒) | 1800 | **26.9**:回收扫描间隔(秒;管理端以分钟展示) |
|
||||
| `retention_days` | int64(0~3650 天) | 0 | **26.9**:最长存储时长(天),上传超过该天数的分享自动回收;0=不限制 |
|
||||
| `dedup_enabled` | 0/1 | 1 | **26.9**:SHA512 内容去重,相同文件仅存储一份(多分享引用同一对象,引用计数删除) |
|
||||
| `direct_download` | 0/1 | 1 | **26.9**:对象存储直链下载(S3 引擎 302 到预签名 URL,文件不经过本站带宽) |
|
||||
| `direct_link_expire` | int64(60~3600 秒) | 900 | **26.9**:直链签名有效期(秒;不超过分享剩余时效) |
|
||||
| `hotlink_enabled` | 0/1 | 0 | **26.9**:下载防盗链(Referer 白名单校验;空 Referer 放行) |
|
||||
| `hotlink_whitelist` | string(≤2048) | 空 | **26.9**:防盗链白名单,逗号分隔域名,支持 `*.example.com` 通配;空=仅同源放行 |
|
||||
| `errorCount` / `errorMinute` | int | 10 / 1 | 取件错误(失败计数)+ metadata 每次计数 |
|
||||
| `loginCount` / `loginMinute` | int | 5 / 15 | 登录失败计数 |
|
||||
|
||||
### 安全与会话
|
||||
|
||||
| 键 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `admin_token` | 空(未初始化) | 管理员密码哈希(`sha256$salt$hash`);GET 配置时屏蔽为空串 |
|
||||
| `jwt_secret` | 空 | JWT 签名密钥(初始化/改密时自动生成轮换;不下发;`settings.SensitiveKeys` 双模式下一致屏蔽) |
|
||||
| `adminSessionExpire` | 604800(7 天) | 管理员会话秒数(须 1~365 整天) |
|
||||
|
||||
### 存储引擎(26.9 运行时可配 + 热切换)
|
||||
|
||||
**`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` 当前名(仅名称,任何引擎参数/凭据不下发)。
|
||||
|
||||
引擎参数键(管理端可改;保存后对应引擎实例缓存失效,下次切换/构建生效):
|
||||
|
||||
| 键 | 默认 |
|
||||
|---|---|
|
||||
| `file_storage` | `local` |
|
||||
| `storage_path` | 空 |
|
||||
| `local_storage_path` | 空(容器内由 `FCB_LOCAL_STORAGE_PATH=/app/data` 注入) |
|
||||
| `s3_access_key_id` / `s3_secret_access_key` / `aws_session_token` | 空 |
|
||||
| `s3_bucket_name` / `s3_endpoint_url` / `s3_hostname` | 空 |
|
||||
| `s3_region_name` | `auto` |
|
||||
| `s3_signature_version` | `s3v4` |
|
||||
| `s3_addressing_style` | `auto` |
|
||||
| `s3_proxy` | 0 |
|
||||
| `webdav_url` / `webdav_username` / `webdav_password` | 空 |
|
||||
| `webdav_root_path` | `filebox_storage` |
|
||||
| `webdav_proxy` | 0 |
|
||||
|
||||
> 敏感键 `webdav_password` / `s3_secret_access_key` / `aws_session_token`(26.9 加入 `settings.SensitiveKeys`):
|
||||
> 管理端 GET 返回掩码 `******`;PATCH 时空串或 `******` 表示不修改。直接写库 settings KV 后重启同样生效。
|
||||
|
||||
## 策略动态生效机制(26.9 需求 ④⑩)
|
||||
|
||||
上传页通过 `GET /api/v1/config` 的 `config` 字段读取**当前策略快照**并在范围内渲染选项;
|
||||
上传链路(`/share/file`、`/chunk/*`、`/presign/*`)**每次请求实时读取** settings KV 同一组值校验:
|
||||
|
||||
- 管理端改策略(`PATCH /admin/config/update`)→ 公开 config 即时反映 → 后续上传立即按新策略执行(含 403/400 拒绝与恢复放行)。
|
||||
- 生效上限:`max_file_size > 0` 时为 `max_file_size`,否则回落 `uploadSize`。
|
||||
- 校验点覆盖:单文件(`/share/file`)、分片 init 按分片数上限、分片上传累计、分片 complete 累计、预签名 init 声明大小,五处口径一致(`api.UploadPolicy.CheckSize`)。
|
||||
|
||||
## 公共配置接口
|
||||
|
||||
前端启动时经 `GET /api/v1/config` 获取站点公开配置(无需认证;26.9 扩展需求 ①②③④⑩):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"config": {
|
||||
"name": "文件快传",
|
||||
"description": "开箱即用的文件快传系统",
|
||||
"explain": "请勿上传或分享违法内容…",
|
||||
"logo_url": "",
|
||||
"favicon_url": "",
|
||||
"background_url": "",
|
||||
"footer_text": "自定义页脚内容",
|
||||
"footer_beian": "京ICP备2024xxxxxx号-1",
|
||||
"notify_enabled": 1,
|
||||
"notify_title": "系统通知",
|
||||
"notify_content": "欢迎使用文件快传…",
|
||||
"uploadSize": 10485760,
|
||||
"max_file_size": 10485760,
|
||||
"maxFileSize": 10485760,
|
||||
"allowedFileTypes": ["*"],
|
||||
"expireStyle": ["day", "hour", "minute", "forever", "count"],
|
||||
"max_save_seconds": 0,
|
||||
"maxSaveSeconds": 0,
|
||||
"max_save_count": 0,
|
||||
"maxSaveCount": 0,
|
||||
"uploadCount": 10,
|
||||
"uploadMinute": 1,
|
||||
"enableChunk": false,
|
||||
"openUpload": true
|
||||
},
|
||||
"meta": {
|
||||
"version": "26.9",
|
||||
"features": { "chunkUpload": false, "guestUpload": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 策略字段 snake_case 与 camelCase 双份下发(前端宽松解析);响应为白名单显式构造,
|
||||
> 任何敏感键(`admin_token`/`jwt_secret`)均不会出现。
|
||||
|
||||
## 健康检查
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8466/api/v1/health
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"status": "ok",
|
||||
"version": "26.9",
|
||||
"storage": "local",
|
||||
"time": "2025-06-01T12:00:00+08:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,62 @@
|
||||
# 错误码
|
||||
|
||||
## 响应结构
|
||||
|
||||
```json
|
||||
{ "code": 404, "msg": "文件不存在" }
|
||||
```
|
||||
|
||||
- `code` 与 HTTP 状态码一致;失败时无 `data` 字段。
|
||||
- `msg` 为中文可读信息,可直接展示给用户。
|
||||
|
||||
## 业务状态码
|
||||
|
||||
| 状态码 | 语义 | 典型场景 |
|
||||
|---|---|---|
|
||||
| 200 | 成功 | 全部正常响应 |
|
||||
| 400 | 参数/格式错误 | 缺字段、过期策略非法、时间格式错误、分片哈希不匹配、code 冲突、`chunk_size` 超 32MiB 上限、presign 实际大小与声明不符、请求体超过大小上限 |
|
||||
| 401 | 未认证 | 管理端令牌缺失/无效;登录密码错误 |
|
||||
| 403 | 拒绝 | 类型白名单拒绝、magic bytes 防伪、游客上传未开启、**分片上传未启用**(enableChunk=0)、presign 直传对象超限(服务端删除对象并释放预留)、下载 `key` 鉴权失败、超过大小/时长限制 |
|
||||
| 404 | 不存在/已过期 | 取件码不存在、文件已过期、上传会话不存在、`/api/*` 未命中路由 |
|
||||
| 409 | 冲突 | 上传容量预留信息不一致 |
|
||||
| 416 | Range 越界 | `Range: bytes=…` 超出文件大小 |
|
||||
| 423 | 限流 | upload/error/login/metadata 任一规则超限 |
|
||||
| 428 | 未初始化 | 系统未初始化时访问除 `/setup`、`/api/v1/health` 外的接口 |
|
||||
| 500 | 服务器错误 | 数据库/内部异常 |
|
||||
| 501 | 引擎不支持 | 引擎不支持预签名等操作(local/webdav 的 `PresignGetURL/PutURL`) |
|
||||
| 503 | 存储不可用 | 存储引擎连接失败/健康检查不通过时的操作 |
|
||||
| 507 | 容量超限 | 达到 `storageLimit` 上限(含上传预留判定) |
|
||||
|
||||
## 存储哨兵错误映射
|
||||
|
||||
存储层哨兵错误统一映射(支持错误包装链判定):
|
||||
|
||||
| 哨兵错误 | HTTP | 响应 msg |
|
||||
|---|---|---|
|
||||
| `ErrNotFound` | 404 | 文件不存在 |
|
||||
| `ErrInvalidPath` | 400 | 非法文件路径 |
|
||||
| `ErrUnavailable` | 503 | 存储服务不可用,请稍后再试 |
|
||||
| `ErrNotSupported` | 501 | 当前存储引擎不支持该操作 |
|
||||
| `ErrRangeNotSatisfiable` | 416 | 请求范围超出文件大小 |
|
||||
| `ErrHashMismatch` | 400 | 分片哈希校验失败,请重新上传 |
|
||||
|
||||
未识别的存储错误归入 500(`存储操作失败: …`)。
|
||||
|
||||
## 错误结果的审计归类
|
||||
|
||||
错误响应同时写入审计日志(需求 ③):
|
||||
|
||||
- `denied`:401 / 403 / 423 / 429 / 428(拒绝类)。
|
||||
- `failed`:其余 4xx / 5xx 及业务显式报错。
|
||||
|
||||
## 常见排障
|
||||
|
||||
| 现象 | 原因与处理 |
|
||||
|---|---|
|
||||
| 全部接口 428 | 未初始化:访问 `GET /setup` 或 `POST /setup` 完成向导 |
|
||||
| 上传 403「本站未开启游客上传」 | `openUpload=0`,携带管理员 Bearer 令牌或后台开启 |
|
||||
| 上传 423 | 触发 upload 限流,等待窗口或调大 `uploadCount/uploadMinute` |
|
||||
| 取件 404「文件已过期」 | 分享过期/次数耗尽;管理员可 `PATCH /admin/file/update` 调整 |
|
||||
| 下载 403「下载鉴权失败」 | `key` 窗口令牌过期/伪造:重新 `POST /share/select` 获取新地址 |
|
||||
| 预签名 init 返回 proxy | local/webdav 引擎不支持直链,按 proxy 流程走服务端代理上传 |
|
||||
| 503 存储服务不可用 | 检查引擎配置与远端服务(S3/WebDAV)连通性;`GET /api/v1/health` 的 `storage` 字段确认引擎 |
|
||||
@@ -0,0 +1,61 @@
|
||||
# Logo 自定义
|
||||
|
||||
## 默认 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 回落 |
|
||||
|
||||
26.9 起默认不再引用远程 URL:`GET /api/v1/config` 中 `logo_url`/`favicon_url` 默认下发空串,
|
||||
前端 `displayLogoUrl`/`displayFaviconUrl` 判空后回落到打包的本地资源。
|
||||
管理端仍可设置任意 URL 全站替换(三步如下)。
|
||||
|
||||
## 管理端自定义(三步)
|
||||
|
||||
1. **登录后台**:`POST /admin/login` 获取 Bearer 令牌。
|
||||
2. **保存配置**:`PATCH /admin/config/update` 更新 `logo_url`(与可选 `favicon_url`),值为图片 URL 或经管理端上传后得到的地址。
|
||||
3. **全站生效**:保存即写入 settings KV 并热更新,前端读取公共配置立即换新 Logo,无需重启。
|
||||
|
||||
curl 示例:
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH http://localhost:8466/admin/config/update \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"logo_url":"https://cdn.example.com/logo.svg","favicon_url":"https://cdn.example.com/favicon.png"}'
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": { "ok": true } }
|
||||
```
|
||||
|
||||
> 也可以在管理界面「系统设置」页操作(上传图片或填写 URL),效果相同。
|
||||
|
||||
## 校验生效
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8466/api/v1/config
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "msg": "ok",
|
||||
"data": { "config": { "logo_url": "https://cdn.example.com/logo.svg", "favicon_url": "https://cdn.example.com/favicon.png" } }
|
||||
}
|
||||
```
|
||||
|
||||
## 恢复默认
|
||||
|
||||
把 `logo_url` / `favicon_url` 置回默认值(空串,前端回落本地打包资源)即可:
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH http://localhost:8466/admin/config/update \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"logo_url":"","favicon_url":""}'
|
||||
```
|
||||
|
||||
## 相关行为
|
||||
|
||||
- 前端运行时优先读取配置值;空值回退前端打包的本地资源(`web/src/assets/brand/logo.svg` + `favicon.png`,经 `displayLogoUrl`/`displayFaviconUrl` 判空回落)。
|
||||
- `site_name` 同样支持运行时自定义(`PATCH /admin/config/update` 的 `site_name` 键)。
|
||||
- Logo/favicon 仅涉及展示层,修改不影响会话与令牌(不轮换 `jwt_secret`)。
|
||||
@@ -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` | int64(0 ~ 1 073 741 824,即 1 GiB/s) | 0 | 上行字节/秒;0=不限速 |
|
||||
| `download_rate` | int64(0 ~ 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>"
|
||||
# 期望 ~10s(2 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
|
||||
+1967
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
# FileCodeBox Go 重写版 · 安全审计报告
|
||||
|
||||
- 审计日期:2026-09-05
|
||||
- 审计范围:`server/`(Go 1.27.1 Gin+GORM 后端,56 个文件约 13,900 行)、`web/`(Vue 3 前端)、`deploy/`(Dockerfile / docker-compose)
|
||||
- 审计方式:人工代码审读(认证/会话、上传下载全链路、存储引擎、配置与注入面)+ 工具佐证(`go vet`、`govulncheck`、`npm audit`)
|
||||
- 结论速览:**未发现可直接导致 RCE、SQL 注入、路径穿越或认证绕过的高危问题**;发现 5 项中危问题(密码哈希强度、S3 直传校验缺口、请求体无上限 DoS、依赖漏洞、上传会话资源滥用)与若干低危/加固建议。
|
||||
- **修复状态(2026-09-05 第二轮):M1–M5、L1–L10 及可行动 Info 项已全部修复**,逐项见各条目「✅ 修复」标记;验证:`gofmt`/`go vet`/`go test ./...` 全绿,`govulncheck` 0 命中,二进制端到端冒烟(初始化→登录→审计落库→限流锁定→XSS 净化→robots→分享取件)通过。前端 `markdown.ts` 净化器加固需重建前端产物(已重建 `web/dist` 与 `server/web/dist`)方可进入 go:embed 二进制。
|
||||
|
||||
---
|
||||
|
||||
## 一、中危(Medium)
|
||||
|
||||
### M1 管理员密码哈希强度不足(单轮 SHA256+盐)✅ 已修复
|
||||
|
||||
- 位置:`server/internal/settings/password.go:15-21`
|
||||
- 原状:`HashPassword` 生成 `sha256$salt$hash`,单轮 SHA256 + 16 字节随机盐。GPU 单卡对 SHA256 可达 10¹⁰ 次/秒,若数据库泄露(SQLite 文件/Postgres 备份),弱口令可被瞬间离线爆破。
|
||||
- ✅ 修复:`HashPassword` 改为 **bcrypt(cost 12)**,输出 `bcrypt$` + 原生 `$2a$12$…` 哈希串;`VerifyPassword` 兼容 bcrypt$/sha256$/明文三种历史格式实现平滑迁移;新增 `NeedsRehash`,管理员登录成功时自动将旧格式重哈希写回(`adminLogin` 内触发);密码长度 >72 字节按 bcrypt 语义截断(`bcryptBytes`);`GenerateJWTSecret` 的 rand 错误不再忽略(panic 显式失败)。测试:settings 包 + `TestAdminPasswordAutoUpgrade`。
|
||||
|
||||
### M2 S3 预签名直传(direct 模式)绕过大小与类型校验 ✅ 已修复
|
||||
|
||||
- 位置:`server/internal/api/presign.go:59-171`(init)、`presign.go:275-345`(confirm)、`server/internal/storage/s3.go:538-554`(PresignPutURL)
|
||||
- 原状:
|
||||
- init 只校验**声明**的 `file_size`;预签名 PUT URL 未签入 Content-Length 约束,客户端实际可 PUT 任意大小对象;
|
||||
- confirm 仅 `FileExists`,不 `Stat` 校验实际对象大小,分享记录 `Size` 直接取声明值;
|
||||
- magic bytes / 类型白名单校验在 direct 模式完全不生效(内容不经过服务器)。
|
||||
- 影响:开启游客上传 + S3 引擎的部署中,任意访客可绕过 `max_file_size` 与 `storageLimit`(配额按声明值记账),向桶内塞入任意大小/内容的对象(存储成本攻击、策略绕过)。
|
||||
- ✅ 修复(多引擎一致,local/S3/WebDAV 全覆盖):
|
||||
1. `Storage` 接口新增 `HeadMeta(ctx, savePath, headBytes)`;S3 用 Range GET `bytes=0-(n-1)`,WebDAV 用 Stat + Range,local 直接 Open;
|
||||
2. `presignConfirm` 现在:HeadMeta 取实际大小 → 超策略上限则 **DeleteFile + 释放配额 + 403**;实际大小与声明差 >1KB → **DeleteFile + 400**;前 64 字节补做 `validateFileMagic`;
|
||||
3. `presignInit` 拒绝 `file_size <= 0` 声明;
|
||||
4. 测试:`TestPresignConfirmRejectsOversizeObject`、`TestPresignConfirmRejectsSizeMismatch`。
|
||||
|
||||
### M3 请求体无全局大小上限,多处“先整读后校验”可被 DoS ✅ 已修复
|
||||
|
||||
- 位置:
|
||||
- `server/internal/api/helpers.go:681-721`(`bindJSONOrForm` 对 text/plain 形态 `io.ReadAll` 全量进内存;JSON 绑定同样全量读入)
|
||||
- `server/internal/api/chunk.go:314`(`io.ReadAll(LimitReader(f, ChunkSize+1))` 单分片全量进内存,`chunk_size` 上界=策略 `max_file_size`,schema 允许至 10GiB)
|
||||
- `server/internal/api/share.go:100-128`(文本 222KB 限制在读完整个 body 之后才判定)
|
||||
- 原状:全服务无 `http.MaxBytesReader`,也无上传前的 Content-Length 预检;multipart 大文件会先被完整解析(>32MB 落临时盘)后才被 `CheckSize` 拒绝。
|
||||
- 影响:默认 `openUpload=1` 的部署下,未认证攻击者可用大 body 消耗内存/磁盘/带宽。
|
||||
- ✅ 修复:
|
||||
1. 新增 `middleware.BodyLimit(limitFn)`,`main.go` 全局装配(在 GuardNotInitialized 之后、Audit 之前):`/setup`、`/admin/*`、`/share/text|metadata|select` 一律 1MiB,其余端点 `maxFileSize(+2MiB 开销)`(前端单请求单分片,已核实安全);
|
||||
2. `/share/text` 入口先查 `Content-Length > 441KB` 直接 403(读前预检);
|
||||
3. 单分片 `chunk_size` 硬上限 32MiB(超出 400),不再跟随 10GiB 的文件策略;
|
||||
4. 测试:`TestChunkSizeCap`。
|
||||
|
||||
### M4 依赖漏洞(govulncheck 实际命中 3 个 + 8 个 imported 级)✅ 已修复
|
||||
|
||||
- 工具输出(`govulncheck ./...`):
|
||||
- `golang.org/x/text v0.30.0`:GO-2026-5970 非法输入死循环 DoS(经 gorm 归一化路径可达),修复于 **v0.39.0**;
|
||||
- `github.com/quic-go/quic-go v0.54.0`:GO-2026-5676(修复 v0.59.1)、GO-2025-4233(修复 v0.57.0)QPACK 扩张 DoS(实际未启用 HTTP/3 监听,实践影响低);
|
||||
- `golang.org/x/net v0.45.0`:8 个 imported 级漏洞(GO-2026-5030/5029/5028/5027/5026/5025/4918 等,含 HTTP/2 传输死循环),修复于 v0.53–v0.55。
|
||||
- ✅ 修复:`x/text v0.41.0`、`x/net v0.58.0`、`x/crypto v0.56.0`(转为直接依赖,供 bcrypt)、`quic-go v0.59.1`;`govulncheck ./...` 复扫 **0 个可达漏洞**(模块级仅剩 1 个未调用项)。前端 `npm audit` 仍报 2 个 moderate(`vue-i18n → @intlify/core-base`,上游暂无修复版,保持关注升级)。
|
||||
|
||||
### M5 上传会话与容量预留可被滥用(init 不计数、无过期清理)✅ 已修复
|
||||
|
||||
- 位置:`server/internal/api/chunk.go:39-157`(chunkInit 从不 `Limiter.Add`,上传限流仅在 complete/presign-init/shareFile 成功时计数)、`server/internal/api/helpers.go:380-436`(预留 TTL:chunk 24h / presign 15min)
|
||||
- 原状:
|
||||
- 游客可无限次 `POST /chunk/upload/init` 创建会话(每次写入 `upload_chunks` 行 + 24h 容量预留),无后台任务回收过期预留、未完成会话与孤儿分片对象(local `chunks/` 目录、S3 `*.part`);
|
||||
- 若配置了 `storageLimit`,攻击者可用多次 init 把全部配额占用满 24 小时 → 全站上传 507(拒绝服务);默认 `storageLimit=0` 时则是磁盘/DB 垃圾持续累积。
|
||||
- ✅ 修复:
|
||||
1. `chunkInit` 在新会话保留成功后即 `Limiter.Add(c, LimitUpload)` 计数;
|
||||
2. chunk 预留 TTL 24h → **2h**(续传刷新);
|
||||
3. 新增 `internal/janitor` 后台清理循环(默认 10 分钟):过期 `storage_reservations`、超时(>24h 未完成)`upload_chunks` 会话(连带清理分片对象)、过期直传 presign 会话(连带删除残留对象);`main.go` 启动时随 ctx 拉起。
|
||||
|
||||
---
|
||||
|
||||
## 二、低危(Low)
|
||||
|
||||
### L1 初始化向导(/setup)存在接管窗口 ✅ 已修复
|
||||
|
||||
- 位置:`server/internal/api/setup.go:47-63`、`middleware/audit.go:222-236`
|
||||
- 原状:服务公开到公网后、管理员完成 /setup 前,任何人可抢先完成初始化并设置管理员密码(经典 setup race;双检查只防并发写坏,不防抢占)。
|
||||
- ✅ 修复:新增 `autoInitIfNeeded`(`main.go`,SystemStart 后执行)——设置 `FCB_ADMIN_PASSWORD`(≥8 位,否则告警跳过)即可在服务启动瞬间完成管理员初始化并生成 jwt_secret,消除 /setup 被抢占窗口;`deploy/.env.example` 与 README 安全清单已补充说明(初始化后建议移除该变量)。
|
||||
|
||||
### L2 下载令牌为非 HMAC 拼接哈希且非常量时间比较 ✅ 已修复
|
||||
|
||||
- 位置:`server/internal/api/helpers.go:249-253`(`sha256(code‖timeFactor‖"000"‖secret)`)、`share.go:458`(`key != GetSelectToken(...)`)
|
||||
- 原状:secret 后置拼接,长度扩展不适用、256 位密钥不可爆破,当前**不可实际利用**;但拼接串存在理论歧义(code 与时间窗数字边界重叠),且字符串比较非常量时间。
|
||||
- ✅ 修复:`GetSelectToken` 改为 **HMAC-SHA256(secret, code‖timeFactor)**;新增 `VerifySelectToken` 用 `hmac.Equal` 常量时间比较(允许当前/上一两个时间窗,防临界失效);`shareDownload` 已切换到 `VerifySelectToken`。
|
||||
|
||||
### L3 数字取件码空间过小,防撞库完全依赖单 IP 限流 ✅ 已修复
|
||||
|
||||
- 位置:`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 万空间);测试 `custom_code_test.go` 与 `TestPickupCodeMinLen` 同步更新。
|
||||
|
||||
### L4 `enableChunk` 开关后端不强制 ✅ 已修复
|
||||
|
||||
- 位置:`server/internal/api/router.go:56-67`
|
||||
- 原状:`/chunk/*` 路由不检查 `cfg.EnableChunk()`,关闭开关后接口仍可用(仅前端隐藏入口)。若该开关被当作安全策略,需在 handler 层强制(403)。
|
||||
- ✅ 修复:新增 `requireChunkEnabled` 守卫(关闭时 403「分片上传未启用」),挂到 `chunkInit`(首检查)、`chunkUpload`、`chunkComplete` 三个端点;测试 `TestChunkToggleEnforced` 验证 0→403 / 1→200。
|
||||
|
||||
### L5 管理端安全事件未入审计日志 ✅ 已修复
|
||||
|
||||
- 位置:`server/internal/middleware/audit.go:55-78`(DefaultClassifier 仅覆盖 upload/download)
|
||||
- 原状:管理员登录失败、配置修改、密码修改、引擎切换、文件删除等管理操作均不落审计。
|
||||
- ✅ 修复:`DefaultClassifier` 扩展 `adminAuditActions`(login/logout、config/update、settings/password、storage/switch、file/update/delete/batch-*、policy-action 等 POST/PATCH/DELETE 敏感操作 → `audit.ActionAdmin`);`Audit` 中间件跳过条件由「非 upload/download 即跳过」改为「分类未命中才跳过」,admin 动作同样建 `auditEntry` 并按 HTTP 状态兜底落库;新增 `audit_l5_test.go` 回归。端到端实测:登录失败 401 落 `admin/denied`、成功落 `admin/success`。
|
||||
|
||||
### L6 CORS 对所有接口(含 /admin/*)放开 `*` ✅ 已修复
|
||||
|
||||
- 位置:`server/internal/middleware/cors.go:10`
|
||||
- 原状:Bearer 模式下无 CSRF 风险,但一旦 token 泄露(见 L8),任意网站均可跨域携带 token 调用管理 API。
|
||||
- ✅ 修复:`Cors` 重写——`/admin/*` 请求带 Origin 且既不同源也不在白名单(`site_domain` 配置注入)时**不回任何 CORS 头**(浏览器拦截跨域读取),预检直接 204;公开接口维持 `*`(Bearer 认证,无 Cookie CSRF 面);无 Origin 的非浏览器请求不受影响。
|
||||
|
||||
### L7 管理端通知内容 `v-html` 直出(存储型 XSS 面)✅ 已修复
|
||||
|
||||
- 位置:`web/src/components/NotifyPop.vue:23`(`notify_content` 来自 `GET /api/v1/config`,管理端可设)
|
||||
- 原状:设计上"允许 `<a>` 等受控 HTML",但服务端/前端均无净化。管理员账号被盗即可对全站访客注入脚本。
|
||||
- ✅ 修复(双侧):
|
||||
1. 服务端新增 `settings.SanitizeInlineHTML` 白名单净化器(`sanitize.go` + 18 个单测):仅保留文本与 `<a href="http(s)://|/|#">`(引号内 `>`、未闭合标签、`script/style/iframe/svg/math` 等危险标签连内容整体丢弃、事件属性不透传、`javascript:/data:` 拒绝);在 **adminConfigUpdate 写入侧** 与 **publicConfig 读取侧** 双重调用(覆盖历史存量与直改库数据);
|
||||
2. 前端 `web/src/utils/markdown.ts` 净化器加固:黑名单补 `svg/math/frame/applet/template/noscript` 等,新增 `srcdoc/sandbox/formaction/action/xlink:href/srcset` 等危险属性表,URL 校验改为协议白名单(http/mailto/相对/锚点,src 另许 `data:image/`)。端到端实测:`<script>alert(1)</script>` 与 `javascript:` 链接被剔除、合法 `<a href="https://…">` 保留。
|
||||
- 注:前端净化器改动需重建前端产物(已重建 `web/dist` 并同步 `server/web/dist`)方进入 go:embed 二进制;未来渲染任何外部内容前建议仍替换为 DOMPurify。
|
||||
|
||||
### L8 管理员令牌存 localStorage、默认会话 30 天 ✅ 已修复
|
||||
|
||||
- 位置:`web/src/api/http.ts:14-33`、`server/internal/config/config.go:110`
|
||||
- 原状:XSS 可窃取且有效期长(可配 1–365 天)。
|
||||
- ✅ 修复:`AdminSessionExpireDefault` 30 天 → **7 天**(仍可配 1–365 天,config 包测试通过);敏感操作改密已有旧密码校验 + jwt_secret 轮换(全部旧 token 失效)。`FCB_ADMIN_SESSION_EXPIRE` 环境变量种子同步支持(`main.go` + `.env.example` 注释)。
|
||||
|
||||
### L9 缓存故障时限流 fail-open ✅ 已修复
|
||||
|
||||
- 位置:`server/internal/middleware/ratelimit.go:148-164`
|
||||
- 原状:Redis/缓存异常时 `Check` 一律放行(登录爆破防护随之失效)。
|
||||
- ✅ 修复:`Check`/`Add` 区分 `cache.ErrNotFound`(窗口内无计数,正常放行)与真实缓存故障;故障时降级为**进程内固定窗口计数**(带过期清理与容量上限 8192,单实例语义),缓存恢复自动回到共享缓存;测试 `TestRateLimiterFallbackOnCacheFailure` 证明故障降级下超限后 fail-close 拒绝。端到端实测:连续错误密码 3 次 401 后第 4 次起 423 锁定。
|
||||
|
||||
### L10 反向代理场景的限流与审计 IP 失真 ✅ 已修复(文档 + 配置面)
|
||||
|
||||
- 位置:`server/internal/middleware/ratelimit.go:32-96`、`config.go:52`
|
||||
- 原状:实现(仅信任 `FCB_TRUSTED_PROXIES` 命中的直连地址才采信 XFF)是正确的;但部署文档未强调反代后必须配置可信代理,否则全体用户共享代理 IP 的限流桶(互相误伤)且审计 IP 全是代理地址;反向配置错误则可伪造 XFF 绕过限流。
|
||||
- ✅ 修复:`deploy/README.md` 新增「安全清单(生产部署必读)」7 条(可信代理、立即初始化/FCB_ADMIN_PASSWORD、修改组件默认凭据与端口发布、Postgres TLS、会话有效期、限流降级语义、内置清理任务);`deploy/.env.example` 为 `FCB_TRUSTED_PROXIES`、`FCB_ADMIN_PASSWORD`、`FCB_ADMIN_SESSION_EXPIRE` 补充注释说明,弱凭据处(MinIO/WebDAV/Postgres)加「仅限本机冒烟,生产必须修改」警示。
|
||||
|
||||
---
|
||||
|
||||
## 三、提示 / 信息(Info)修复情况
|
||||
|
||||
1. `version`/`storage_engine` 公开暴露(小信息收集面):**保留**(前端展示与诊断需要,风险极低)。
|
||||
2. compose 弱凭据/端口发布:✅ 已在 `.env.example` 与 README 安全清单加警示(凭据为示例值,端口建议删除或绑 127.0.0.1)。
|
||||
3. `robotsText` 无路由:✅ 已新增 `GET /robots.txt`(`router.go`,输出配置内容,text/plain);端到端实测 200 且内容生效。
|
||||
4. `adminFileList` LIKE 未转义:✅ 已加 `escapeLike`(`\`→`\\`、`%`→`\%`、`_`→`\_`)+ `ESCAPE '\'`(admin-only,防御性修复)。
|
||||
5. Postgres 并发配额超额记账:✅ `reserveStorage` 包事务并对 Postgres 加 `pg_advisory_xact_lock`("FCBQ" 键)串行化配额判定;SQLite 写串行不受影响。
|
||||
6. `crypto/rand` 错误忽略:✅ `GenerateJWTSecret` 已改 panic 显式失败(见 M1);`generateCode`/`randomHex` 维持重试回退(非安全关键路径)。
|
||||
|
||||
---
|
||||
|
||||
## 四、确认到位的安全设计(无需修改)
|
||||
|
||||
- **JWT**:HS256 + `WithValidMethods` 双重防算法混淆;密钥为 64 位 hex 随机;改密自动轮换(使全部旧会话失效);过期/签名校验完备。
|
||||
- **SQL**:全部 GORM/占位符参数化;排序字段白名单(`normalizeSortBy`);无字符串拼接 SQL。
|
||||
- **路径安全**:`SanitizePath`/`SanitizeFileName` + `withinRoot` + `EvalSymlinks` 符号链接逃逸双重校验;S3 key、WebDAV 路径同样清洗;未发现穿越。
|
||||
- **下载响应**:统一 `application/octet-stream` + `attachment` + RFC 5987 文件名编码,杜绝分享文件被当 HTML 渲染的存储型 XSS;取件文本以 `text/plain` 下发且前端 `<pre>{{ }}</pre>` 渲染。
|
||||
- **认证/授权**:`/admin/*` 全组 Bearer 鉴权;`/setup` 受 GuardNotInitialized 白名单约束;旧默认密码 `FileCodeBox2023` 视为未初始化;`verifyLegacyDefault`/密码比较用 `hmac.Equal`。
|
||||
- **上传策略**:大小上限五处口径一致(share/chunk-init/chunk-累计/presign/complete),magic bytes 防伪造,自定义码查重 + 唯一索引兜底并发。
|
||||
- **限流**:取件错误/登录失败/上传/metadata 四类固定窗口;clientIP 仅信任显式配置的代理。
|
||||
- **容量**:单条 INSERT..SELECT 原子预留判定,防超卖。
|
||||
- **部署**:多阶段构建、非 root(uid 10001)运行、`.dockerignore` 合理、`.env` 未含真实密钥(当前也非 git 仓库;初始化 git 后应将 `deploy/.env` 加入忽略清单)。
|
||||
|
||||
---
|
||||
|
||||
## 五、修复优先级建议(原计划,已全部落实)
|
||||
|
||||
| 优先级 | 事项 | 状态 |
|
||||
|---|---|---|
|
||||
| 立即 | M4 依赖升级;M2 的 confirm 大小校验 | ✅ 已完成 |
|
||||
| 短期 | M1 密码哈希迁移 bcrypt;M3 全局 MaxBytesReader;M5 init 计数 + 清理循环 | ✅ 已完成 |
|
||||
| 计划 | L1–L10 按运营形态取舍 | ✅ 已全部修复 |
|
||||
|
||||
---
|
||||
|
||||
## 六、修复验证记录(2026-09-05 第二轮)
|
||||
|
||||
- **静态检查**:`gofmt -l .` 无输出、`go vet ./...` 通过、`go build ./...` 通过。
|
||||
- **单元/集成测试**:`go test ./...` 全绿(api/audit/cache/config/database/middleware/settings/storage 全部 ok),新增修复回归测试:
|
||||
- `settings/sanitize_test.go`(L7 净化器 19 例)
|
||||
- `middleware/audit_l5_test.go`(L5 admin 审计落库)
|
||||
- `middleware/ratelimit_fallback_test.go`(L9 缓存故障降级 fail-close)
|
||||
- `api/security_fixes_test.go`(L4 开关强制、M3 chunk 上限、L3 提码长度、M2 confirm 大小/超限、M1 密码迁移)
|
||||
- **依赖扫描**:`govulncheck ./...` 0 可达漏洞;`npm audit --omit=dev` 剩 2 moderate(vue-i18n 上游未发布修复,跟踪中)。
|
||||
- **端到端冒烟**(编译二进制 + SQLite 实跑):
|
||||
1. `/setup` 初始化 200(M1 生效:DB 中 `admin_token = bcrypt$$2a$12$…`);
|
||||
2. 错误密码登录 401 → 审计落 `admin/denied`;成功登录落 `admin/success`(L5);
|
||||
3. 连续 3 次错误后第 4 次起 423 锁定(L9 限流);
|
||||
4. 配置写入 `<script>alert(1)</script>` + `javascript:` 链接 → `/api/v1/config` 输出已剔除、合法 `<a href>` 保留(L7);
|
||||
5. `/robots.txt` 200 且内容生效(Info3);
|
||||
6. 文本分享创建 + 取件下载全链路 200。
|
||||
- **遗留跟踪**:`vue-i18n` 上游修复版本发布后升级(当前 npm audit 的 2 个 moderate 均来源于此)。
|
||||
@@ -0,0 +1,38 @@
|
||||
// 验证 docs/openapi.yaml 与 Go 路由一致性(t4 验收辅助)
|
||||
const fs = require('fs');
|
||||
const YAML = require('/opt/homebrew/lib/node_modules/@deepseek-ai/dsh/node_modules/yaml');
|
||||
|
||||
const doc = YAML.parse(fs.readFileSync('docs/openapi.yaml', 'utf8'));
|
||||
|
||||
const goRoutes = new Set();
|
||||
function scan(src) {
|
||||
let group = '';
|
||||
for (const raw of src.split('\n')) {
|
||||
const line = raw.trim();
|
||||
const gm = line.match(/^(\w+)\s*:=\s*r\.Group\("([^"]+)"/);
|
||||
if (gm) { group = gm[2]; continue; }
|
||||
const am = line.match(/^admin\.POST\("([^"]+)"/);
|
||||
if (am) { goRoutes.add('POST ' + group + am[1]); continue; }
|
||||
const m = line.match(/^(r|share|chunk|presign|authed)\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"/);
|
||||
if (m) {
|
||||
const base = (m[1] === 'r') ? '' : group;
|
||||
goRoutes.add(m[2] + ' ' + base + m[3].replace(/:([A-Za-z]+)/g, '{$1}'));
|
||||
}
|
||||
}
|
||||
}
|
||||
scan(fs.readFileSync('server/internal/api/router.go', 'utf8'));
|
||||
scan(fs.readFileSync('server/internal/api/admin.go', 'utf8'));
|
||||
scan(fs.readFileSync('server/internal/api/setup.go', 'utf8'));
|
||||
|
||||
const oaRoutes = new Set();
|
||||
for (const [p, item] of Object.entries(doc.paths)) {
|
||||
for (const method of Object.keys(item)) {
|
||||
if (['get', 'post', 'put', 'patch', 'delete'].includes(method)) oaRoutes.add(method.toUpperCase() + ' ' + p);
|
||||
}
|
||||
}
|
||||
const goOnly = [...goRoutes].filter(r => !oaRoutes.has(r));
|
||||
const oaOnly = [...oaRoutes].filter(r => !goRoutes.has(r));
|
||||
console.log('Go 路由数:', goRoutes.size, ' OpenAPI 操作数:', oaRoutes.size);
|
||||
console.log('Go 有而 OpenAPI 缺:', goOnly.length ? goOnly : '无');
|
||||
console.log('OpenAPI 有而 Go 缺:', oaOnly.length ? oaOnly : '无');
|
||||
console.log('(注:POST /chunk/upload 为扁平兼容端点,文档按 go-api 定稿仅写主路径 /chunk/upload/{uploadID}/{chunkIndex})');
|
||||
@@ -0,0 +1,177 @@
|
||||
# 文件快传 Go 后端(server/)
|
||||
|
||||
Go 1.27.1 + Gin + GORM 重写的文件快传后端。数据库**默认 SQLite**(modernc.org/sqlite
|
||||
纯 Go 驱动,CGO_ENABLED=0 交叉编译友好,零外部依赖),配置 `FCB_DB_DRIVER=postgres` 后
|
||||
切换 Postgres(需求 ⑧);Redis 为**可选**增强(未配置 `FCB_REDIS_ADDR` 时自动降级为进程内存缓存)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
server/
|
||||
├── cmd/server/main.go # 入口:装配 配置→DB→缓存→设置→审计→中间件→路由
|
||||
└── internal/
|
||||
├── config/ # 配置:FCB_* 环境变量基线 + DB settings KV 运行时覆盖
|
||||
│ └── schema.go # 26.9 新配置键 schema(键名/类型/默认值/边界)
|
||||
├── model/ # GORM 模型 + 双方言 AutoMigrate
|
||||
├── cache/ # 缓存统一接口:redis.go / memory.go 双实现
|
||||
├── database/ # 双方言连接与迁移(sqlite 默认 / postgres 可选)
|
||||
├── settings/ # settings KV 读写、密码哈希(sha256$salt$hash)、键 schema re-export
|
||||
├── audit/ # 审计服务(Sink 抽象、失败重试队列)+ UA 设备解析
|
||||
├── middleware/ # JWT 认证、IP 限流、审计中间件、CORS、IP 解析
|
||||
├── storage/ # 存储引擎契约(interface.go 为 go-storage 的实现契约)
|
||||
└── response/ # 统一响应 {"code":200,"msg":"","data":...}
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 必需 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `FCB_DB_DRIVER` | ❌ | `sqlite` | 数据库驱动:`sqlite` \| `postgres`(需求 ⑧) |
|
||||
| `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` |
|
||||
| `FCB_TRUSTED_PROXIES` | ❌ | 空 | 可信代理 CIDR(逗号分隔),用于解析真实客户端 IP |
|
||||
|
||||
### 数据库模式(需求 ⑧)
|
||||
|
||||
- **SQLite(默认)**:`FCB_DB_DRIVER=sqlite`(或缺省)。零 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、全部业务查询方言无关;
|
||||
唯一原生 DDL(migrates 台账表)在 `database.createMigratesTable` 内部分支处理。
|
||||
|
||||
## 26.9 新增配置键(internal/config/schema.go 为单一事实来源)
|
||||
|
||||
| 键 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `background_url` | string | `""` | 需求 ①:背景图 URL/上传地址(空=主题默认;legacy `background` 键兜底) |
|
||||
| `footer_text` | string | `""` | 需求 ②:页脚自定义内容(≤2000 字符) |
|
||||
| `footer_beian` | string | `""` | 需求 ②:备案号(≤128 字符) |
|
||||
| `notify_enabled` | int | `1` | 需求 ③:通知开关(1 开 / 0 关) |
|
||||
| `notify_title` / `notify_content` | string | 见 defaults | 需求 ③:通知标题/内容(沿用参考实现语义) |
|
||||
| `max_save_seconds` | int64 | `0` | 需求 ④:最长保存秒数上限(0=仅默认 7 天兜底,≤365 天) |
|
||||
| `max_save_count` | int | `0` | 需求 ④:单次分享最大可取次数上限(0=不限制,≤100000) |
|
||||
| `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()` 逐键一致。
|
||||
|
||||
## 数据模型
|
||||
|
||||
- `file_codes`:文件/文本分享(对齐参考 `apps/base/models.py::FileCodes`)
|
||||
- `upload_chunks`:分片上传记录
|
||||
- `key_values`:运行时配置 KV(`settings` / `sys_start` 键)
|
||||
- `presign_upload_sessions`:预签名直传会话
|
||||
- `storage_reservations`:上传容量预留
|
||||
- `audit_logs`:上传/下载审计(时间/IP/UA/设备/动作/结果/字节数/耗时),需求 ③
|
||||
- `migrates`:迁移台账表(双方言 DDL 分支,见 `database.createMigratesTable`)
|
||||
|
||||
## 存储引擎契约(internal/storage/interface.go)
|
||||
|
||||
`go-storage` 按此契约实现 local/s3/webdav 三引擎(**接口签名已冻结**):
|
||||
|
||||
```go
|
||||
type Storage interface {
|
||||
SaveFile(ctx, r io.Reader, savePath) (int64, error) // 流式保存
|
||||
DeleteFile(ctx, savePath) error
|
||||
Open(ctx, savePath, rng *Range) (*Download, error) // Range 下载
|
||||
Stat(ctx, savePath) (*FileMeta, error)
|
||||
SaveChunk(ctx, uploadID, chunkIndex, r io.Reader, savePath) (int64, error)
|
||||
MergeChunks(ctx, uploadID, total, verifyHash, savePath) (int64, string, error)
|
||||
CleanChunks(ctx, uploadID, savePath) error
|
||||
FileExists(ctx, savePath) (bool, error)
|
||||
PresignGetURL(ctx, savePath, expires) (string, error) // 不支持→ErrNotSupported
|
||||
PresignPutURL(ctx, savePath, expires) (string, error)
|
||||
HealthCheck(ctx) error
|
||||
}
|
||||
```
|
||||
|
||||
错误映射约定:`ErrNotFound`→404、`ErrInvalidPath`→400、`ErrUnavailable`→503、`ErrNotSupported`→501、`ErrRangeNotSatisfiable`→416。
|
||||
|
||||
引擎通过 `storage.RegisterEngine("local"|"s3"|"webdav", factory)` 注册,
|
||||
`storage.NewEngine(ctx, name)` 构造。分片路径约定:`<父目录>/chunks/<uploadID>/<index>.part`。
|
||||
|
||||
### 引擎构造选项(t3 API 层接入)
|
||||
|
||||
构造引擎前必须先注入选项(否则 local 退到系统临时目录、s3/webdav 因缺配置失败):
|
||||
|
||||
```go
|
||||
storage.SetEngineOptions(storage.EngineOptions{
|
||||
Local: storage.LocalOptions{Root: cfg.GetString("local_storage_path")},
|
||||
S3: storage.S3Options{
|
||||
AccessKeyID: cfg.GetString("s3_access_key_id"), SecretAccessKey: cfg.GetString("s3_secret_access_key"),
|
||||
Bucket: cfg.GetString("s3_bucket_name"), Endpoint: cfg.GetString("s3_endpoint_url"),
|
||||
Region: cfg.GetString("s3_region_name"), AddressingStyle: cfg.GetString("s3_addressing_style"),
|
||||
},
|
||||
WebDAV: storage.WebDAVOptions{
|
||||
BaseURL: cfg.GetString("webdav_url"), Username: cfg.GetString("webdav_username"),
|
||||
Password: cfg.GetString("webdav_password"), RootPath: cfg.GetString("webdav_root_path"),
|
||||
},
|
||||
})
|
||||
st, err := storage.NewEngine(ctx, cfg.Engine()) // 构造后调 st.HealthCheck(ctx) 完成启动自检
|
||||
```
|
||||
|
||||
引擎要点:local 原子写(临时文件+fsync+rename)、防穿越+符号链接逃逸校验;
|
||||
s3 原生 multipart 流式合并、预签名直链、SDK 内置 5xx 重试;webdav 连接池复用、
|
||||
Basic/Digest 自动协商、Range 透传、按需逐级 MKCOL(带缓存)、5xx/429 指数退避重试、
|
||||
下载 io.Pipe 流式不落盘。三引擎均支持分片上传/合并(SHA256 校验)与 Range 下载。
|
||||
|
||||
## 审计埋点用法(API 层)
|
||||
|
||||
```go
|
||||
r.Use(middleware.Audit(auditSvc, nil)) // nil=默认按路由前缀分类 upload/download
|
||||
|
||||
// handler 内填充业务字段并显式落库(推荐):
|
||||
middleware.AuditSet(c, func(e *audit.Entry) { e.FileCode = code; e.FileName = name })
|
||||
middleware.AuditRecordRequest(c, auditSvc, model.AuditResultSuccess, "")
|
||||
// 或不显式落库:中间件按 HTTP 状态兜底(4xx/5xx→failed,401/403/423/429/428→denied)
|
||||
```
|
||||
|
||||
下载响应字节数由中间件自动统计;上传字节数由 handler 填 `TransferredBytes`。
|
||||
|
||||
## 限流语义(对齐参考实现)
|
||||
|
||||
- `error`(取件错误)/`login`(登录失败):**仅在失败时计数**,handler 调用 `limiter.Add(c, kind)`
|
||||
- `upload`:**成功上传才计数**(先 `Check` 放行,成功后 `Add`)
|
||||
- `metadata`:每次访问即计数,可用 `RequireRateLimit` 中间件
|
||||
- 超限返回 HTTP 423;规则来自 settings KV(errorCount/errorMinute 等),可运行时调整
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
# 默认 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/fileshare?sslmode=disable'
|
||||
go run ./cmd/server
|
||||
|
||||
# 双方言单测:sqlite 始终执行;postgres 需真实实例(FCB_TEST_PG_DSN 指向测试库)
|
||||
FCB_TEST_PG_DSN='postgres://postgres:postgres@localhost:5432/fcb_test?sslmode=disable' go test ./internal/database/ ./internal/settings/
|
||||
go test ./... # 全量单测(未设置 FCB_TEST_PG_DSN 时自动跳过 PG 用例)
|
||||
```
|
||||
|
||||
系统未初始化时除 `/setup` 与 `/api/v1/health` 外一律返回 428,初始化路由由 API 层任务接入。
|
||||
|
||||
## 开发注意
|
||||
|
||||
- **GOCACHE/GOMODCACHE 必须用 `export` 设置**(沙箱环境):本地与 CI 沙箱通常禁止写默认 Go 缓存
|
||||
目录(`~/Library/Caches/go-build`)。注意一个隐蔽的坑——用**内联前缀变量**的方式
|
||||
(`GOCACHE=... GOMODCACHE=... go build ./... && go vet ./... && go test ./...`)时,
|
||||
只有第一条命令继承这些变量,`&&` 后续命令(vet/test)会丢失前缀、落回默认缓存路径,
|
||||
报错 `operation not permitted`(指向 `~/Library/Caches/...`)却极易误判为代码问题。
|
||||
正确写法:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
export GOCACHE=$(pwd)/../.gocache GOMODCACHE=$(pwd)/../.gomodcache GOSUMDB=off
|
||||
go build ./... && go vet ./... && go test ./... # 后续命令也能继承 export 的变量
|
||||
```
|
||||
@@ -0,0 +1,312 @@
|
||||
// 文件快递柜 Go 重写版服务入口:
|
||||
// 装配 配置 → Postgres → 缓存 → 设置 → 审计 → 存储引擎 → 中间件 → API 路由。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"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。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)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// 1. 配置(环境变量基线;引擎相关 env 种子在此注入,DB KV 仍可覆盖)
|
||||
cfg, err := config.New()
|
||||
if err != nil {
|
||||
log.Fatalf("[boot] 配置加载失败: %v", err)
|
||||
}
|
||||
applyEnvEngineSeeds(cfg)
|
||||
|
||||
// 2. 数据库(需求 ⑧:默认 SQLite 零依赖;配置 FCB_DB_DRIVER=postgres+DSN 后走 Postgres)
|
||||
db, err := database.Open(ctx, database.Options{Driver: cfg.Env.DBDriver, DSN: cfg.Env.DBDSN})
|
||||
if err != nil {
|
||||
log.Fatalf("[boot] 数据库初始化失败: %v", err)
|
||||
}
|
||||
defer func() { _ = database.Close(db) }()
|
||||
if err := database.Migrate(ctx, db); err != nil {
|
||||
log.Fatalf("[boot] 数据库迁移失败: %v", err)
|
||||
}
|
||||
if cfg.Env.DBDriver == config.DBDriverPostgres {
|
||||
log.Printf("[boot] Postgres 数据库已启用")
|
||||
} else {
|
||||
log.Printf("[boot] SQLite 数据库: %s", cfg.SQLitePath())
|
||||
}
|
||||
|
||||
// 3. 缓存:Redis 可选,未配置则内存降级(需求 ⑧:默认即内存,Redis 为增强项)
|
||||
cacheImpl, err := cache.New(ctx, cache.RedisOptions{Addr: cfg.Env.RedisAddr, DB: cfg.Env.RedisDB})
|
||||
if err != nil {
|
||||
log.Printf("[boot] Redis 连接失败,降级为内存缓存: %v", err)
|
||||
cacheImpl, err = cache.NewMemory(), nil
|
||||
if err != nil {
|
||||
log.Fatalf("[boot] 内存缓存初始化失败: %v", err)
|
||||
}
|
||||
}
|
||||
defer func() { _ = cacheImpl.Close() }()
|
||||
if cfg.Env.RedisAddr != "" {
|
||||
if cfg.Env.RedisDB != 0 {
|
||||
log.Printf("[boot] Redis 缓存已启用: %s (db=%d)", cfg.Env.RedisAddr, cfg.Env.RedisDB)
|
||||
} else {
|
||||
log.Printf("[boot] Redis 缓存已启用: %s", cfg.Env.RedisAddr)
|
||||
}
|
||||
} else {
|
||||
log.Println("[boot] 未配置 FCB_REDIS_ADDR,使用内存缓存")
|
||||
}
|
||||
|
||||
// 4. 设置管理器(env 基线 + DB settings KV 运行时覆盖)
|
||||
mgr, err := settings.NewManager(ctx, db, cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("[boot] 设置加载失败: %v", err)
|
||||
}
|
||||
mgr.SystemStart(ctx)
|
||||
autoInitIfNeeded(mgr)
|
||||
|
||||
// 5. 审计服务 + 重试循环(需求 ③)
|
||||
auditSvc := audit.NewService(audit.NewDBSink(db))
|
||||
auditStop := make(chan struct{})
|
||||
defer close(auditStop)
|
||||
auditSvc.StartRetryLoop(auditStop)
|
||||
|
||||
// 6. 限流器(规则来自 settings KV,可被管理端动态调整)
|
||||
limiter := middleware.NewRateLimiter(cacheImpl, map[string]middleware.LimitRule{
|
||||
middleware.LimitError: {Count: cfg.GetInt("errorCount"), Window: minutes(cfg.GetInt("errorMinute"))},
|
||||
middleware.LimitUpload: {Count: cfg.GetInt("uploadCount"), Window: minutes(cfg.GetInt("uploadMinute"))},
|
||||
middleware.LimitLogin: {Count: cfg.GetInt("loginCount"), Window: minutes(cfg.GetInt("loginMinute"))},
|
||||
middleware.LimitMeta: {Count: cfg.GetInt("errorCount"), Window: minutes(cfg.GetInt("errorMinute"))},
|
||||
})
|
||||
|
||||
// 7. 存储引擎:注入配置 → 构造 → 健康预检(需求 ④;26.9 包装为可热切换 Manager)
|
||||
storage.SetEngineOptions(buildEngineOptions(cfg))
|
||||
bootStore, err := storage.NewEngine(ctx, cfg.Engine())
|
||||
if err != nil {
|
||||
log.Fatalf("[boot] 存储引擎 %s 初始化失败: %v", cfg.Engine(), err)
|
||||
}
|
||||
if err := bootStore.HealthCheck(ctx); err != nil {
|
||||
// 预检失败仅告警不阻断启动(存储可能在运行中恢复)
|
||||
log.Printf("[boot] 警告: 存储引擎 %s 健康检查未通过: %v", cfg.Engine(), err)
|
||||
} else {
|
||||
log.Printf("[boot] 存储引擎 %s 健康检查通过", cfg.Engine())
|
||||
}
|
||||
// 26.9:Manager 包装——保存/读取委托当前引擎;管理端可热切换
|
||||
//(构建闭包在每次切换前用最新 KV 刷新 EngineOptions,参数改动即时生效)
|
||||
store := storage.NewManager(cfg.Engine(), bootStore, func(name string) (storage.Storage, error) {
|
||||
storage.SetEngineOptions(buildEngineOptions(cfg))
|
||||
return storage.NewEngine(ctx, name)
|
||||
})
|
||||
|
||||
// 8. 路由装配
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
r.Use(middleware.ClientIP(middleware.ParseTrustedProxies(cfg.Env.TrustedProxies)))
|
||||
// L6:管理端 CORS 收紧(允许站点对外域名 site_domain 跨域调用,其余拒绝)
|
||||
r.Use(middleware.Cors(cfg.SiteDomain()))
|
||||
// 未初始化守卫:除 /setup 与 /api/v1/health 外一律 428
|
||||
r.Use(middleware.GuardNotInitialized(mgr.IsInitialized))
|
||||
// M3:全局请求体大小上限(管理端 1MiB;上传类 = 单文件上限 + 表单开销)
|
||||
r.Use(middleware.BodyLimit(bodyLimitFn(cfg)))
|
||||
// 审计中间件:按 DefaultClassifier 路由模式自动分类 upload/download(需求 ③)
|
||||
r.Use(middleware.Audit(auditSvc, nil))
|
||||
|
||||
api.Register(r, &api.Deps{
|
||||
DB: db,
|
||||
Cfg: cfg,
|
||||
Mgr: mgr,
|
||||
AuditSvc: auditSvc,
|
||||
Limiter: limiter,
|
||||
Store: store,
|
||||
Version: APP_VERSION,
|
||||
})
|
||||
|
||||
// 9. HTTP 服务
|
||||
// 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,
|
||||
ReadHeaderTimeout: 15 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
log.Printf("[boot] 文件快递柜 %s 启动于 %s(存储引擎 %s)", APP_VERSION, cfg.Env.Listen, cfg.Engine())
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("[boot] HTTP 服务异常退出: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
log.Println("[boot] 收到退出信号,开始优雅关闭...")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("[boot] 优雅关闭超时: %v", err)
|
||||
}
|
||||
log.Println("[boot] 服务已退出")
|
||||
}
|
||||
|
||||
// autoInitIfNeeded 安全审计 L1:系统未初始化且配置了 FCB_ADMIN_PASSWORD 时,
|
||||
// 启动即自动完成管理员初始化,消除「部署到公网后被抢先访问 /setup 接管」的窗口。
|
||||
// 密码不足 8 位时拒绝并保持 /setup 可用(不静默采用弱口令)。
|
||||
func autoInitIfNeeded(mgr *settings.Manager) {
|
||||
if mgr.IsInitialized() {
|
||||
return
|
||||
}
|
||||
pwd := strings.TrimSpace(os.Getenv("FCB_ADMIN_PASSWORD"))
|
||||
if pwd == "" {
|
||||
return
|
||||
}
|
||||
if len(pwd) < 8 {
|
||||
log.Printf("[boot] 警告: FCB_ADMIN_PASSWORD 少于 8 位,已忽略;/setup 初始化向导保持可用")
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := mgr.UpdateKV(ctx, map[string]any{
|
||||
"admin_token": settings.HashPassword(pwd),
|
||||
"jwt_secret": settings.GenerateJWTSecret(),
|
||||
}); err != nil {
|
||||
log.Printf("[boot] 警告: FCB_ADMIN_PASSWORD 自动初始化写入失败: %v(/setup 仍可用)", err)
|
||||
return
|
||||
}
|
||||
if err := mgr.Reload(ctx); err != nil {
|
||||
log.Printf("[boot] 警告: 自动初始化后配置重载失败: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[boot] 已通过 FCB_ADMIN_PASSWORD 自动完成管理员初始化")
|
||||
}
|
||||
|
||||
// envKVSeeds 容器编排常用的 FCB_* 环境变量 → settings KV 键映射。
|
||||
// config 包只解析进程必需的 5 个变量;引擎相关配置通过本桥接注入,
|
||||
// 使 docker-compose 无需进库即可完成引擎配置。优先级:defaults < 环境变量种子 < DB settings KV。
|
||||
var envKVSeeds = []struct {
|
||||
envKey string
|
||||
kvKey string
|
||||
}{
|
||||
{"FCB_LOCAL_STORAGE_PATH", "local_storage_path"},
|
||||
{"FCB_STORAGE_PATH", "storage_path"},
|
||||
{"FCB_S3_ACCESS_KEY_ID", "s3_access_key_id"},
|
||||
{"FCB_S3_SECRET_ACCESS_KEY", "s3_secret_access_key"},
|
||||
{"FCB_AWS_SESSION_TOKEN", "aws_session_token"},
|
||||
{"FCB_S3_BUCKET_NAME", "s3_bucket_name"},
|
||||
{"FCB_S3_ENDPOINT_URL", "s3_endpoint_url"},
|
||||
{"FCB_S3_REGION_NAME", "s3_region_name"},
|
||||
{"FCB_S3_ADDRESSING_STYLE", "s3_addressing_style"},
|
||||
{"FCB_WEBDAV_URL", "webdav_url"},
|
||||
{"FCB_WEBDAV_USERNAME", "webdav_username"},
|
||||
{"FCB_WEBDAV_PASSWORD", "webdav_password"},
|
||||
{"FCB_WEBDAV_ROOT_PATH", "webdav_root_path"},
|
||||
}
|
||||
|
||||
// applyEnvEngineSeeds 把已设置的环境变量作为 KV 种子写入配置(仅种子,不落库)。
|
||||
func applyEnvEngineSeeds(cfg *config.Config) {
|
||||
seeds := map[string]any{}
|
||||
for _, m := range envKVSeeds {
|
||||
if v := strings.TrimSpace(os.Getenv(m.envKey)); v != "" {
|
||||
seeds[m.kvKey] = v
|
||||
}
|
||||
}
|
||||
// L8:管理员会话有效期支持环境变量(秒;1~365 整天校验在 AdminSessionExpireSeconds)
|
||||
if v := strings.TrimSpace(os.Getenv("FCB_ADMIN_SESSION_EXPIRE")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
seeds["adminSessionExpire"] = n
|
||||
}
|
||||
}
|
||||
if len(seeds) > 0 {
|
||||
cfg.ApplyKV(seeds)
|
||||
log.Printf("[boot] 已从环境变量注入 %d 项配置种子", len(seeds))
|
||||
}
|
||||
}
|
||||
|
||||
// buildEngineOptions 从配置构造引擎选项(对齐 go-storage 的注入约定:
|
||||
// 必须在 NewEngine 之前调用;字段来源为 config KV,键名对齐参考实现)。
|
||||
func buildEngineOptions(cfg *config.Config) storage.EngineOptions {
|
||||
return storage.EngineOptions{
|
||||
Local: storage.LocalOptions{
|
||||
Root: cfg.GetString("local_storage_path"),
|
||||
},
|
||||
S3: storage.S3Options{
|
||||
AccessKeyID: cfg.GetString("s3_access_key_id"),
|
||||
SecretAccessKey: cfg.GetString("s3_secret_access_key"),
|
||||
SessionToken: cfg.GetString("aws_session_token"),
|
||||
Bucket: cfg.GetString("s3_bucket_name"),
|
||||
Endpoint: cfg.GetString("s3_endpoint_url"),
|
||||
Region: cfg.GetString("s3_region_name"),
|
||||
AddressingStyle: cfg.GetString("s3_addressing_style"),
|
||||
},
|
||||
WebDAV: storage.WebDAVOptions{
|
||||
BaseURL: cfg.GetString("webdav_url"),
|
||||
Username: cfg.GetString("webdav_username"),
|
||||
Password: cfg.GetString("webdav_password"),
|
||||
RootPath: cfg.GetString("webdav_root_path"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// minutes 分钟数转 Duration。
|
||||
func minutes(n int) time.Duration {
|
||||
if n <= 0 {
|
||||
n = 1
|
||||
}
|
||||
return time.Duration(n) * time.Minute
|
||||
}
|
||||
|
||||
// bodyLimitFn 构造按路径分类的请求体上限函数(M3):
|
||||
// - /admin/*:1MiB(管理接口均为小 JSON/表单);
|
||||
// - /share/text|metadata|select、/setup:1MiB(文本内容本身限 222KB);
|
||||
// - 其余(上传类):max_file_size(0=回落 uploadSize,再 0=64MiB)+ 2MiB 表单开销。
|
||||
func bodyLimitFn(cfg *config.Config) func(c *gin.Context) int64 {
|
||||
const adminLimit = int64(1) << 20
|
||||
const textLimit = int64(1) << 20
|
||||
const formOverhead = int64(2) << 20
|
||||
const fallbackUpload = int64(64) << 20
|
||||
return func(c *gin.Context) int64 {
|
||||
p := c.Request.URL.Path
|
||||
switch {
|
||||
case p == "/setup" || strings.HasPrefix(p, "/admin/"):
|
||||
return adminLimit
|
||||
case p == "/share/text" || p == "/share/metadata" || p == "/share/select":
|
||||
return textLimit
|
||||
}
|
||||
limit := cfg.MaxFileSize()
|
||||
if limit <= 0 {
|
||||
limit = cfg.UploadSize()
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = fallbackUpload
|
||||
}
|
||||
// 分片/直传单请求体 ≤ 单片大小;share/file 的 multipart 有边界开销
|
||||
return limit + formOverhead
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
module fileshare
|
||||
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.45.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.2
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.2
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.23.2
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.110.0
|
||||
github.com/aws/smithy-go v1.28.1
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
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
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.8.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.36.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.41.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.48.0 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
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/net v0.58.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/sqlite v1.34.5 // indirect
|
||||
)
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks=
|
||||
github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.2 h1:Pj4+nF2kc4Z+1BJysVPnX9d5dMN7IYFXR4UJaWK2IpA=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.2/go.mod h1:Igw+HTwbR2tsTU/ydifAS9EHAFJ2s/FCgkwQWFnAdE4=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.2 h1:VQjZODPNfdikCX2ZZrltw4zNLkcwjyUFDUl2vT9yTwg=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.2/go.mod h1:OmeHCn28vZylsBvalLDf7t8fuJ2rHYQprJs+7WuxniI=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 h1:YIEBqcqRnpi4Pfv0YHImtgi6czGCwKHANC7SwmUAVD0=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1/go.mod h1:imEf0oufgAo8KAkCHhrOdqGEC0YWx1PPBQH82shSxGw=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.23.2 h1:yNAPkIRXwrXV3x4NMXi2oAveMy5WUaiBAY6X42K+vUs=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.23.2/go.mod h1:+/m7PPNzeC3wq8n5kgw39kAj7pIE3fkAKHrgCyVnMO0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1/go.mod h1:1+koxpPIbfBdfzP6vojm5/zTpTQ/micYwlxIiNB3TxI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 h1:K0JsbZQj+1h208Ro1zHeA4l7bMp0NvRffHQ91q8Ol1s=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1/go.mod h1:W3/vL6EtCIatICGy9ab29QhMuae+cOKPWcMxv02CO+Q=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 h1:yhw5KD1phVyP9vijxOUzDfEtJx+bt+L63k+VfuiYFAA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1/go.mod h1:ZW2e0d7DYlRxlS9hEiMXE47gTdX5KRN4byUiNbUpG+Q=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 h1:s67hBfG5t9rn1NCvDuB4E3QIep3UFhHPtaIqFDjV3N8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1/go.mod h1:FpvjBMXtSNMLPmDJsWwcY5cRnqJlpS2y1R6n4pvzs4k=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 h1:RmmWQPREQdk9U+PfqeHW3MqZaBaNK7TpV9W3RY+b+7g=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1/go.mod h1:0A3W4F+68ZnNk5XcNL/e9HFMwnP8RlEicFfy6eOEDyw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 h1:ZMbtPZZQRca+3+XYQne9PBvRiYpHZlNJJOZfE9WNfT0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1/go.mod h1:YAGWQdCYlVCoqrzvfv3RLxO6zKwti7gsAULOGWPLYv4=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.110.0 h1:He8vaTTqAAJrux/KdpjFXNWueLJZyKqE49QEXoqAu4I=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.110.0/go.mod h1:CUr46sCpGAg/rHaclRyhJX0LJAmH73uWSJPPSaMUrSk=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.8.0 h1:bSvKIoLuRGFqGwASgeCQncCJDi9YKKBDEmCEZzOX1uU=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.8.0/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.36.0 h1:iivsh357VnfIc18IFWSuoyQEluf8frfWf4cL2Y0JUQw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.36.0/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.41.0 h1:wVxM3QzSKIK8tSN6OGgezp9OK91lCLH2zhmRInN9rFM=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.41.0/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.48.0 h1:RzZVCzYM19vhJCT5s6vO2wN8ie770Li/TmbAZ9B6N7E=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.48.0/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk=
|
||||
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
|
||||
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
|
||||
github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4=
|
||||
gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,671 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"fileshare/internal/middleware"
|
||||
"fileshare/internal/model"
|
||||
"fileshare/internal/response"
|
||||
"fileshare/internal/storage"
|
||||
)
|
||||
|
||||
// chunkExpireTTL 分片会话保留时长(M5:预留窗口由 24h 缩短为 2h;
|
||||
// 会话本身保留 24h 支持断点续传,见 janitor 的清理周期)。
|
||||
const chunkExpireTTL = 2 * time.Hour
|
||||
|
||||
// maxChunkSizeBytes 单分片大小上限 32MB(M3:限制 io.ReadAll 内存占用)。
|
||||
const maxChunkSizeBytes = 32 * 1024 * 1024
|
||||
|
||||
// ============ POST /chunk/upload/init 初始化分片会话 ============
|
||||
|
||||
// requireChunkEnabled L4:enableChunk 开关后端强制(此前仅前端隐藏入口,
|
||||
// 开关关闭后 /chunk/* 接口仍可直接调用)。
|
||||
func (d *Deps) requireChunkEnabled(c *gin.Context) bool {
|
||||
if d.Cfg.EnableChunk() {
|
||||
return true
|
||||
}
|
||||
auditRecordFailed(c, d.AuditSvc, "分片上传未启用")
|
||||
response.Fail(c, http.StatusForbidden, "分片上传未启用")
|
||||
return false
|
||||
}
|
||||
|
||||
// chunkInitRequest init 请求体(JSON 或表单)。
|
||||
type chunkInitRequest struct {
|
||||
FileName string `json:"file_name" form:"file_name"`
|
||||
ChunkSize int64 `json:"chunk_size" form:"chunk_size"`
|
||||
FileSize int64 `json:"file_size" form:"file_size"`
|
||||
FileHash string `json:"file_hash" form:"file_hash"`
|
||||
}
|
||||
|
||||
// chunkInit 创建分片上传会话(对齐参考 init_chunk_upload):
|
||||
// 支持断点续传(相同 hash/大小/文件名的未完成会话直接续传)。
|
||||
func (d *Deps) chunkInit(c *gin.Context) {
|
||||
if !d.requireChunkEnabled(c) {
|
||||
return
|
||||
}
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
if !requireUploadLimit(c, d.Limiter) {
|
||||
return
|
||||
}
|
||||
var req chunkInitRequest
|
||||
if err := bindJSONOrForm(c, &req); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
safeName := storage.SanitizeFileName(req.FileName)
|
||||
if safeName == "" {
|
||||
auditRecordFailed(c, d.AuditSvc, "文件名非法")
|
||||
response.Fail(c, http.StatusBadRequest, "文件名非法")
|
||||
return
|
||||
}
|
||||
// 文件类型白名单(无内容可校验,仅名称)
|
||||
if err := validateFileMagic(d.Cfg, safeName, "", nil); err != nil {
|
||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件类型被拒绝")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
chunkSize := req.ChunkSize
|
||||
if chunkSize <= 0 {
|
||||
chunkSize = 5 * 1024 * 1024 // 默认 5MB(对齐参考 InitChunkUploadModel)
|
||||
}
|
||||
// M3:单片全部读入内存后再落存储,必须限制单片大小(客户端声明的
|
||||
// chunk_size 上界受策略约束,但策略允许至 10GiB → 显式封顶 32MB)。
|
||||
if chunkSize > maxChunkSizeBytes {
|
||||
auditRecordFailed(c, d.AuditSvc, "chunk_size 超过上限")
|
||||
response.Fail(c, http.StatusBadRequest, fmt.Sprintf("chunk_size 过大,最大为 %d MB", maxChunkSizeBytes>>20))
|
||||
return
|
||||
}
|
||||
if req.FileSize <= 0 {
|
||||
auditRecordFailed(c, d.AuditSvc, "file_size 非法")
|
||||
response.Fail(c, http.StatusBadRequest, "file_size 必须大于 0")
|
||||
return
|
||||
}
|
||||
// 服务端按分片数上限校验总大小(防分片声明绕过)
|
||||
totalChunks := (req.FileSize + chunkSize - 1) / chunkSize
|
||||
maxPossible := totalChunks * chunkSize
|
||||
// 26.9 需求 ④⑩:动态策略校验(max_file_size,0=回落 uploadSize)
|
||||
if err := d.CurrentUploadPolicy().CheckSize(maxPossible); err != nil {
|
||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件大小超过限制")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
// 断点续传:查找相同 hash+大小+文件名的未完成会话(chunk_index=-1 为会话头)
|
||||
var existing model.UploadChunk
|
||||
err := d.DB.WithContext(ctx).
|
||||
Where("chunk_hash = ? AND chunk_index = -1 AND file_size = ? AND file_name = ?",
|
||||
req.FileHash, req.FileSize, safeName).
|
||||
First(&existing).Error
|
||||
if err == nil {
|
||||
if existing.SavePath == "" {
|
||||
// 脏会话:清理后按新建处理
|
||||
_ = d.DB.WithContext(ctx).
|
||||
Where("upload_id = ?", existing.UploadID).
|
||||
Delete(&model.UploadChunk{}).Error
|
||||
releaseStorage(ctx, d.DB, "chunk:"+existing.UploadID)
|
||||
} else {
|
||||
if err := reserveStorage(ctx, d.DB, d.Cfg, "chunk:"+existing.UploadID, existing.FileSize, chunkExpireTTL); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
uploaded := d.uploadedChunkIndexes(ctx, existing.UploadID)
|
||||
auditUploadEntry(c, existing.UploadID, safeName, req.FileSize, 0)
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
response.OK(c, gin.H{
|
||||
"existed": false,
|
||||
"upload_id": existing.UploadID,
|
||||
"chunk_size": existing.ChunkSize,
|
||||
"total_chunks": existing.TotalChunks,
|
||||
"uploaded_chunks": uploaded,
|
||||
})
|
||||
return
|
||||
}
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
respondError(c, errInternal("查询上传会话失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 新建会话
|
||||
uploadID := uuidHex()
|
||||
resToken := "chunk:" + uploadID
|
||||
if err := reserveStorage(ctx, d.DB, d.Cfg, resToken, req.FileSize, chunkExpireTTL); err != nil {
|
||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "容量预留失败")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// M5:init 即计入上传限流(此前仅 complete 成功时计数,
|
||||
// 恶意客户端可无限创建会话占用容量预留)
|
||||
d.Limiter.Add(c, middleware.LimitUpload)
|
||||
_, _, _, _, savePath := buildSavePath(d.Cfg, safeName, uploadID)
|
||||
session := model.UploadChunk{
|
||||
UploadID: uploadID,
|
||||
ChunkIndex: -1,
|
||||
TotalChunks: int(totalChunks),
|
||||
FileSize: req.FileSize,
|
||||
ChunkSize: int(chunkSize),
|
||||
ChunkHash: req.FileHash,
|
||||
FileName: safeName,
|
||||
SavePath: savePath,
|
||||
Engine: d.Store.CurrentName(), // 26.9:会话归属引擎(分片/合并全程走同一引擎)
|
||||
}
|
||||
if err := d.DB.WithContext(ctx).Create(&session).Error; err != nil {
|
||||
releaseStorage(ctx, d.DB, resToken)
|
||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "会话创建失败")
|
||||
respondError(c, errInternal("创建上传会话失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
auditUploadEntry(c, uploadID, safeName, req.FileSize, 0)
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
response.OK(c, gin.H{
|
||||
"existed": false,
|
||||
"upload_id": uploadID,
|
||||
"chunk_size": chunkSize,
|
||||
"total_chunks": totalChunks,
|
||||
"uploaded_chunks": []int{},
|
||||
})
|
||||
}
|
||||
|
||||
// uploadedChunkIndexes 查询会话中已完成分片的索引列表。
|
||||
func (d *Deps) uploadedChunkIndexes(ctx context.Context, uploadID string) []int {
|
||||
var rows []model.UploadChunk
|
||||
if err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ? AND completed = ?", uploadID, true).
|
||||
Order("chunk_index ASC").Find(&rows).Error; err != nil {
|
||||
return []int{}
|
||||
}
|
||||
out := make([]int, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.ChunkIndex)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ============ POST /chunk/upload/{uploadID}/{index}(及扁平兼容)============
|
||||
|
||||
// chunkUploadFlat 扁平模式:POST /chunk/upload,upload_id/chunk_index 走表单或 query。
|
||||
// 多文件字段(chunk/chunks)时按 base_chunk_index 顺序批量接收。
|
||||
func (d *Deps) chunkUploadFlat(c *gin.Context) {
|
||||
c.Params = append(c.Params, gin.Param{Key: "uploadID", Value: resolveUploadID(c)})
|
||||
c.Params = append(c.Params, gin.Param{Key: "chunkIndex", Value: resolveChunkIndex(c)})
|
||||
d.chunkUpload(c)
|
||||
}
|
||||
|
||||
// resolveUploadID 解析 upload_id:路径参数 → multipart 表单 → query。
|
||||
func resolveUploadID(c *gin.Context) string {
|
||||
if v := c.Param("uploadID"); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := c.PostForm("upload_id"); v != "" {
|
||||
return v
|
||||
}
|
||||
return c.Query("upload_id")
|
||||
}
|
||||
|
||||
// resolveChunkIndex 解析 chunk_index:路径参数 → multipart 表单 → query。
|
||||
func resolveChunkIndex(c *gin.Context) string {
|
||||
if v := c.Param("chunkIndex"); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := c.PostForm("chunk_index"); v != "" {
|
||||
return v
|
||||
}
|
||||
return c.Query("chunk_index")
|
||||
}
|
||||
|
||||
// chunkUpload 上传单个(或批量)分片(对齐参考 upload_chunk)。
|
||||
// multipart 文件字段:chunk(主)或 file(回退);批量用 chunk[]/chunks 数组 + chunk_index 为起始索引。
|
||||
func (d *Deps) chunkUpload(c *gin.Context) {
|
||||
if !d.requireChunkEnabled(c) {
|
||||
return
|
||||
}
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
uploadID := resolveUploadID(c)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var session model.UploadChunk
|
||||
if err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ? AND chunk_index = -1", uploadID).
|
||||
First(&session).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
auditUploadEntry(c, uploadID, "", 0, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "上传会话不存在")
|
||||
response.Fail(c, http.StatusNotFound, "上传会话不存在")
|
||||
return
|
||||
}
|
||||
respondError(c, errInternal("查询上传会话失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if err := reserveStorage(ctx, d.DB, d.Cfg, "chunk:"+uploadID, session.FileSize, chunkExpireTTL); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 收集分片文件:chunk(单)→ file(回退)→ chunk[]/chunks(批量)
|
||||
form, err := c.MultipartForm()
|
||||
if err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "multipart 解析失败")
|
||||
response.Fail(c, http.StatusBadRequest, "multipart 表单解析失败")
|
||||
return
|
||||
}
|
||||
files := form.File["chunk"]
|
||||
single := len(files) == 0
|
||||
if single {
|
||||
files = form.File["file"]
|
||||
}
|
||||
if len(files) == 0 {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "缺少 chunk 分片字段")
|
||||
response.Fail(c, http.StatusBadRequest, "缺少分片文件字段 chunk")
|
||||
return
|
||||
}
|
||||
|
||||
baseIndex, err := strconv.Atoi(resolveChunkIndex(c))
|
||||
if err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "无效的分片索引")
|
||||
response.Fail(c, http.StatusBadRequest, "无效的分片索引")
|
||||
return
|
||||
}
|
||||
|
||||
results := make([]gin.H, 0, len(files))
|
||||
for i, fh := range files {
|
||||
// 单分片模式严格使用请求索引;批量模式从 base 递增
|
||||
idx := baseIndex
|
||||
if !single && len(files) > 1 {
|
||||
idx = baseIndex + i
|
||||
}
|
||||
res, status, msg := d.saveOneChunk(c, ctx, &session, idx, fh)
|
||||
if status != 0 {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, msg)
|
||||
response.Fail(c, status, msg)
|
||||
return
|
||||
}
|
||||
results = append(results, res)
|
||||
}
|
||||
// 审计:传输字节数为本次请求分片总和
|
||||
var transferred int64
|
||||
for _, fh := range files {
|
||||
transferred += fh.Size
|
||||
}
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, transferred)
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
if len(results) == 1 {
|
||||
response.OK(c, results[0])
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"chunks": results})
|
||||
}
|
||||
|
||||
// saveOneChunk 保存一个分片:查重→读数据→校验→存储→记录。
|
||||
// 返回 (响应体, HTTP错误状态码, 错误信息);成功时状态码为 0。
|
||||
func (d *Deps) saveOneChunk(c *gin.Context, ctx context.Context, session *model.UploadChunk, idx int, fh *multipart.FileHeader) (gin.H, int, string) {
|
||||
if idx < 0 || idx >= session.TotalChunks {
|
||||
return nil, http.StatusBadRequest, "无效的分片索引"
|
||||
}
|
||||
// 已上传分片:断点续传直接跳过
|
||||
var existing model.UploadChunk
|
||||
err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ? AND chunk_index = ? AND completed = ?", session.UploadID, idx, true).
|
||||
First(&existing).Error
|
||||
if err == nil {
|
||||
return gin.H{"chunk_hash": existing.ChunkHash, "skipped": true, "chunk_index": idx}, 0, ""
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, http.StatusInternalServerError, "查询分片记录失败"
|
||||
}
|
||||
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
return nil, http.StatusBadRequest, "分片数据读取失败"
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
data, err := io.ReadAll(io.LimitReader(f, int64(session.ChunkSize)+1))
|
||||
if err != nil {
|
||||
return nil, http.StatusBadRequest, "分片数据读取失败"
|
||||
}
|
||||
// 校验分片大小不超过声明值
|
||||
if int64(len(data)) > int64(session.ChunkSize) {
|
||||
return nil, http.StatusBadRequest,
|
||||
"分片大小超过声明值: 最大 " + strconv.Itoa(session.ChunkSize) + ", 实际 " + strconv.Itoa(len(data))
|
||||
}
|
||||
// 累计大小校验(已传分片数×chunk_size + 当前分片;动态策略上限)
|
||||
var uploadedCount int64
|
||||
_ = d.DB.WithContext(ctx).Model(&model.UploadChunk{}).
|
||||
Where("upload_id = ? AND completed = ?", session.UploadID, true).
|
||||
Count(&uploadedCount).Error
|
||||
if err := d.CurrentUploadPolicy().CheckSize(uploadedCount*int64(session.ChunkSize) + int64(len(data))); err != nil {
|
||||
return nil, http.StatusForbidden, err.Error()
|
||||
}
|
||||
// 首分片做 magic bytes 防伪造
|
||||
if idx == 0 {
|
||||
head := data
|
||||
if len(head) > 64 {
|
||||
head = head[:64]
|
||||
}
|
||||
if err := validateFileMagic(d.Cfg, session.FileName, "", head); err != nil {
|
||||
return nil, http.StatusForbidden, "文件内容校验失败:" + err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(data)
|
||||
chunkHash := hex.EncodeToString(sum[:])
|
||||
if _, err := d.Store.SaveChunk(ctx, session.UploadID, idx, bytes.NewReader(data), session.SavePath); err != nil {
|
||||
return nil, http.StatusInternalServerError, "分片保存失败: " + err.Error()
|
||||
}
|
||||
// 保存成功后再记录(对齐参考:先存储后落库)。
|
||||
// 注意:不能用结构体 Where 条件(GORM 会忽略零值字段,chunk_index=0 会被
|
||||
// 丢弃从而误匹配 -1 会话行),必须用字符串条件 + 完整目标结构体。
|
||||
rec := model.UploadChunk{
|
||||
UploadID: session.UploadID,
|
||||
ChunkIndex: idx,
|
||||
ChunkHash: chunkHash,
|
||||
Completed: true,
|
||||
FileSize: session.FileSize,
|
||||
TotalChunks: session.TotalChunks,
|
||||
ChunkSize: session.ChunkSize,
|
||||
FileName: session.FileName,
|
||||
SavePath: session.SavePath,
|
||||
Engine: session.Engine, // 26.9:继承会话引擎
|
||||
}
|
||||
if err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ? AND chunk_index = ?", session.UploadID, idx).
|
||||
FirstOrCreate(&rec).Error; err != nil {
|
||||
return nil, http.StatusInternalServerError, "分片记录写入失败"
|
||||
}
|
||||
return gin.H{"chunk_hash": chunkHash, "chunk_index": idx}, 0, ""
|
||||
}
|
||||
|
||||
// ============ GET /chunk/upload/status/{uploadID} ============
|
||||
|
||||
// chunkStatus 查询上传进度(对齐参考 get_upload_status)。
|
||||
func (d *Deps) chunkStatus(c *gin.Context) {
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
uploadID := c.Param("uploadID")
|
||||
if uploadID == "" {
|
||||
uploadID = c.Query("upload_id")
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
var session model.UploadChunk
|
||||
if err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ? AND chunk_index = -1", uploadID).
|
||||
First(&session).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, "上传会话不存在")
|
||||
return
|
||||
}
|
||||
respondError(c, errInternal("查询上传会话失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
uploaded := d.uploadedChunkIndexes(ctx, uploadID)
|
||||
var progress float64
|
||||
if session.TotalChunks > 0 {
|
||||
progress = float64(len(uploaded)) / float64(session.TotalChunks) * 100
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"upload_id": uploadID,
|
||||
"file_name": session.FileName,
|
||||
"file_size": session.FileSize,
|
||||
"chunk_size": session.ChunkSize,
|
||||
"total_chunks": session.TotalChunks,
|
||||
"uploaded_chunks": uploaded,
|
||||
"progress": progress,
|
||||
})
|
||||
}
|
||||
|
||||
// ============ POST /chunk/upload/complete/{uploadID} ============
|
||||
|
||||
// chunkCompleteRequest complete 请求体。
|
||||
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"` // 26.9:自定义提取码(4-8 位字母数字,空=随机)
|
||||
}
|
||||
|
||||
// chunkComplete 合并分片并创建分享(对齐参考 complete_upload)。
|
||||
func (d *Deps) chunkComplete(c *gin.Context) {
|
||||
if !d.requireChunkEnabled(c) {
|
||||
return
|
||||
}
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
if !requireUploadLimit(c, d.Limiter) {
|
||||
return
|
||||
}
|
||||
uploadID := c.Param("uploadID")
|
||||
if uploadID == "" {
|
||||
uploadID = resolveUploadID(c)
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var session model.UploadChunk
|
||||
if err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ? AND chunk_index = -1", uploadID).
|
||||
First(&session).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
auditUploadEntry(c, uploadID, "", 0, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "上传会话不存在")
|
||||
response.Fail(c, http.StatusNotFound, "上传会话不存在")
|
||||
return
|
||||
}
|
||||
respondError(c, errInternal("查询上传会话失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
var req chunkCompleteRequest
|
||||
if err := bindJSONOrForm(c, &req); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
exp, err := resolveExpire(d.Cfg, req.ExpireValue, req.ExpireStyle)
|
||||
if err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "过期策略非法")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// 26.9:自定义提取码(合并前校验,失败快速返回)
|
||||
if err := validatePickupCode(req.Code); err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "提取码非法")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
if err := reserveStorage(ctx, d.DB, d.Cfg, "chunk:"+uploadID, session.FileSize, chunkExpireTTL); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 分片完整性校验(chunk_index >= 0;-1 为会话头,completed 恒为 false)
|
||||
var completed []model.UploadChunk
|
||||
if err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ? AND completed = ? AND chunk_index >= 0", uploadID, true).
|
||||
Find(&completed).Error; err != nil {
|
||||
respondError(c, errInternal("查询分片记录失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if len(completed) != session.TotalChunks {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "分片不完整")
|
||||
response.Fail(c, http.StatusBadRequest, "分片不完整")
|
||||
return
|
||||
}
|
||||
// 累计大小上限校验(超限清理会话,对齐参考;动态策略上限)
|
||||
if err := d.CurrentUploadPolicy().CheckSize(int64(len(completed)) * int64(session.ChunkSize)); err != nil {
|
||||
if cs, ce := d.storeFor(session.Engine); ce == nil {
|
||||
_ = cs.CleanChunks(ctx, uploadID, session.SavePath)
|
||||
}
|
||||
_ = d.DB.WithContext(ctx).Where("upload_id = ?", uploadID).Delete(&model.UploadChunk{}).Error
|
||||
releaseStorage(ctx, d.DB, "chunk:"+uploadID)
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "实际上传大小超过限制")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 合并(引擎负责按索引有序合并+SHA256 校验)
|
||||
verifyHash := func(index int) (string, error) {
|
||||
var rec model.UploadChunk
|
||||
err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ? AND chunk_index = ?", uploadID, index).
|
||||
First(&rec).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rec.ChunkHash, nil
|
||||
}
|
||||
// 26.9:合并走会话归属引擎(会话创建时的引擎,即使中途热切换也不受影响)
|
||||
mergeStore, sErr := d.storeFor(session.Engine)
|
||||
if sErr != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, session.FileSize)
|
||||
auditRecordFailed(c, d.AuditSvc, "存储引擎不可用: "+sErr.Error())
|
||||
respondError(c, mapStorageError(sErr))
|
||||
return
|
||||
}
|
||||
size, fileHash, err := mergeStore.MergeChunks(ctx, uploadID, session.TotalChunks, verifyHash, session.SavePath)
|
||||
if err != nil {
|
||||
_ = mergeStore.CleanChunks(ctx, uploadID, session.SavePath)
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, session.FileSize)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件合并失败")
|
||||
respondError(c, mapStorageError(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 创建分享记录(26.9:支持自定义提取码)
|
||||
code, err := pickCustomCode(ctx, d.DB, d.Cfg, req.Code)
|
||||
if err == nil {
|
||||
fc := model.FileCodes{
|
||||
Code: code,
|
||||
FileHash: &fileHash,
|
||||
IsChunked: true,
|
||||
UploadID: &uploadID,
|
||||
Size: session.FileSize,
|
||||
ExpiredAt: exp.ExpiredAt,
|
||||
ExpiredCount: exp.ExpiredCount,
|
||||
UsedCount: exp.UsedCount,
|
||||
Engine: session.Engine, // 26.9:归属引擎戳
|
||||
}
|
||||
// 拆分路径与文件名(对齐参考:path=dirname(save_path), uuid=basename)
|
||||
dir, name := splitDirBase(session.SavePath)
|
||||
ext := baseExt(name)
|
||||
fc.FilePath = &dir
|
||||
fc.UUIDFileName = &name
|
||||
fc.Prefix = trimExt(name)
|
||||
fc.Suffix = ext
|
||||
err = d.DB.WithContext(ctx).Create(&fc).Error
|
||||
err = mapCodeConflict(err) // 26.9
|
||||
if err == nil {
|
||||
// 26.9:SHA512 内容去重(命中则复用旧对象并删除本次副本)
|
||||
d.applyDedup(ctx, mergeStore, session.SavePath, &fc)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
// 成功:清理分片与记录(走归属引擎)
|
||||
_ = mergeStore.CleanChunks(ctx, uploadID, session.SavePath)
|
||||
_ = d.DB.WithContext(ctx).Where("upload_id = ?", uploadID).Delete(&model.UploadChunk{}).Error
|
||||
}
|
||||
releaseStorage(ctx, d.DB, "chunk:"+uploadID)
|
||||
if err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, size)
|
||||
auditRecordFailed(c, d.AuditSvc, "创建分享失败")
|
||||
respondError(c, errInternal("创建分享失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
d.Limiter.Add(c, middleware.LimitUpload)
|
||||
auditUploadEntry(c, code, session.FileName, session.FileSize, size)
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
response.OK(c, gin.H{"code": code, "name": session.FileName})
|
||||
}
|
||||
|
||||
// splitDirBase 拆分相对路径为目录与文件名。
|
||||
func splitDirBase(p string) (dir, base string) {
|
||||
for i := len(p) - 1; i >= 0; i-- {
|
||||
if p[i] == '/' {
|
||||
return p[:i], p[i+1:]
|
||||
}
|
||||
}
|
||||
return "", p
|
||||
}
|
||||
|
||||
// baseExt 提取扩展名(含点)。
|
||||
func baseExt(name string) string {
|
||||
for i := len(name) - 1; i >= 0; i-- {
|
||||
if name[i] == '.' {
|
||||
return name[i:]
|
||||
}
|
||||
if name[i] == '/' {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// trimExt 去除扩展名。
|
||||
func trimExt(name string) string {
|
||||
ext := baseExt(name)
|
||||
return name[:len(name)-len(ext)]
|
||||
}
|
||||
|
||||
// ============ DELETE /chunk/upload/{uploadID} 取消上传 ============
|
||||
|
||||
// chunkCancel 取消上传并清理临时文件(对齐参考 cancel_upload)。
|
||||
func (d *Deps) chunkCancel(c *gin.Context) {
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
uploadID := c.Param("uploadID")
|
||||
if uploadID == "" {
|
||||
uploadID = c.Query("upload_id")
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
var session model.UploadChunk
|
||||
if err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ? AND chunk_index = -1", uploadID).
|
||||
First(&session).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, "上传会话不存在")
|
||||
return
|
||||
}
|
||||
respondError(c, errInternal("查询上传会话失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if session.SavePath != "" {
|
||||
if cs, ce := d.storeFor(session.Engine); ce == nil {
|
||||
_ = cs.CleanChunks(ctx, uploadID, session.SavePath)
|
||||
}
|
||||
}
|
||||
if err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ?", uploadID).
|
||||
Delete(&model.UploadChunk{}).Error; err != nil {
|
||||
respondError(c, errInternal("取消上传失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
releaseStorage(ctx, d.DB, "chunk:"+uploadID)
|
||||
response.OK(c, gin.H{"message": "上传已取消"})
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package api
|
||||
|
||||
// 26.9:自定义提取码与站点域名单元测试。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// postForm 以 urlencoded 表单调用 handler(26.9 测试辅助)。
|
||||
func postForm(d *Deps, path string, fields map[string]string) *httptest.ResponseRecorder {
|
||||
form := url.Values{}
|
||||
for k, v := range fields {
|
||||
form.Set(k, v)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
var handler gin.HandlerFunc
|
||||
switch path {
|
||||
case "/share/text":
|
||||
handler = d.shareText
|
||||
default:
|
||||
handler = func(c *gin.Context) { c.AbortWithStatus(http.StatusNotFound) }
|
||||
}
|
||||
return invoke(handler, req)
|
||||
}
|
||||
|
||||
func TestValidatePickupCode(t *testing.T) {
|
||||
// 合法:空(用随机码)
|
||||
if err := validatePickupCode(""); err != nil {
|
||||
t.Fatalf("空码应合法: %v", err)
|
||||
}
|
||||
// 合法:5-8 位字母数字(L3:最小长度由 4 提升至 5)
|
||||
for _, c := range []string{"abcde", "AB123", "12345678", "a1B2c"} {
|
||||
if err := validatePickupCode(c); err != nil {
|
||||
t.Fatalf("合法码 %s 不应报错: %v", c, err)
|
||||
}
|
||||
}
|
||||
// 非法:长度(4 位及以下不再允许)
|
||||
for _, c := range []string{"abcd", "a1B2", "abc", "123456789"} {
|
||||
if err := validatePickupCode(c); err == nil {
|
||||
t.Fatalf("非法长度 %s 应报错", c)
|
||||
}
|
||||
}
|
||||
// 非法:字符
|
||||
for _, c := range []string{"ab c1", "提码", "ab-cd", "ab.cd", "ab+cd"} {
|
||||
if err := validatePickupCode(c); err == nil {
|
||||
t.Fatalf("非法字符 %s 应报错", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSiteDomain(t *testing.T) {
|
||||
// 空 = 当前地址
|
||||
d, err := normalizeSiteDomain("")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if d != "" {
|
||||
t.Fatalf("want empty, got %q", d)
|
||||
}
|
||||
// 完整 URL
|
||||
d, err = normalizeSiteDomain("https://share.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if d != "https://share.example.com" {
|
||||
t.Fatalf("want https://share.example.com, got %q", d)
|
||||
}
|
||||
// 带端口 + 去尾斜杠
|
||||
d, err = normalizeSiteDomain("http://192.168.1.5:8466/")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if d != "http://192.168.1.5:8466" {
|
||||
t.Fatalf("want http://192.168.1.5:8466, got %q", d)
|
||||
}
|
||||
// 裸主机自动补 http
|
||||
d, err = normalizeSiteDomain("share.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if d != "http://share.example.com" {
|
||||
t.Fatalf("want http://share.example.com, got %q", d)
|
||||
}
|
||||
// 非法:路径 / 协议
|
||||
for _, bad := range []string{"https://a.com/path", "ftp://a.com", "javascript:alert(1)"} {
|
||||
if _, err := normalizeSiteDomain(bad); err == nil {
|
||||
t.Fatalf("非法域名 %s 应报错", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShareTextTextPlainCompat 复刻真实浏览器请求形态:
|
||||
// 旧前端 bundle 发 text/plain Content-Type + urlencoded body(fetch 字符串 body 默认头)。
|
||||
// 修复前:该形态被静默存成空文本(bug 1)或 400「分享内容不能为空」(bug 2)。
|
||||
func TestShareTextTextPlainCompat(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
|
||||
body := strings.NewReader("text=111&expire_value=1&expire_style=day&code=")
|
||||
req := httptest.NewRequest(http.MethodPost, "/share/text", body)
|
||||
req.Header.Set("Content-Type", "text/plain;charset=UTF-8")
|
||||
w := invoke(d.shareText, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("text/plain+urlencoded 应 200: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// JSON 体但 Content-Type 缺失/为 text/plain 也应可解析
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/share/text",
|
||||
strings.NewReader(`{"text":"无头JSON","expire_value":1,"expire_style":"day"}`))
|
||||
req2.Header.Set("Content-Type", "text/plain;charset=UTF-8")
|
||||
w2 := invoke(d.shareText, req2)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("text/plain+JSON体 应 200: %d %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
// 取件确认内容真实落库
|
||||
req3 := httptest.NewRequest(http.MethodPost, "/share/select",
|
||||
strings.NewReader(`{"code":"`+codeOf(w)+`"}`))
|
||||
req3.Header.Set("Content-Type", "application/json")
|
||||
w3 := invoke(d.shareSelectPost, req3)
|
||||
if !strings.Contains(w3.Body.String(), "111") {
|
||||
t.Fatalf("落库内容应为 111: %s", w3.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// codeOf 从创建响应提取取件码。
|
||||
func codeOf(w *httptest.ResponseRecorder) string {
|
||||
var env struct {
|
||||
Data struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &env)
|
||||
return env.Data.Code
|
||||
}
|
||||
|
||||
func TestShareTextCustomCode(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
|
||||
// 自定义码成功创建
|
||||
w := postForm(d, "/share/text", map[string]string{"text": "自定义码测试", "code": "MYCODE1"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("自定义码创建失败: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 重复占用 → 400
|
||||
w = postForm(d, "/share/text", map[string]string{"text": "第二条", "code": "MYCODE1"})
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("占用码应 400: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 非法码 → 400
|
||||
w = postForm(d, "/share/text", map[string]string{"text": "第三条", "code": "abc"})
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("过短码应 400: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 空码 → 随机码仍正常
|
||||
w = postForm(d, "/share/text", map[string]string{"text": "第四条", "code": ""})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("空码应回退随机: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,798 @@
|
||||
// Package api 提供文件快传的 HTTP API 层:
|
||||
// 分享(share)、分片上传(chunk)、预签名直传(presign)、管理端(admin)、
|
||||
// 初始化向导(setup)与前端静态资源(web)。
|
||||
//
|
||||
// 接口语义对齐参考实现 apps/base/views.py 与 apps/admin/views.py;
|
||||
// 响应统一 {"code":200,"msg":"","data":...}(internal/response)。
|
||||
// 所有上传/下载端点经 middleware.Audit 落审计日志(需求 ③)。
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"fileshare/internal/audit"
|
||||
"fileshare/internal/config"
|
||||
"fileshare/internal/middleware"
|
||||
"fileshare/internal/model"
|
||||
"fileshare/internal/response"
|
||||
"fileshare/internal/storage"
|
||||
)
|
||||
|
||||
// apiError 统一的业务错误:handler 返回该错误并由 respondError 映射 HTTP 状态。
|
||||
type apiError struct {
|
||||
Status int // HTTP 状态码(同时写入响应体 code)
|
||||
Msg string // 错误描述(中文)
|
||||
}
|
||||
|
||||
func (e *apiError) Error() string { return e.Msg }
|
||||
|
||||
func errBadRequest(msg string) error { return &apiError{Status: http.StatusBadRequest, Msg: msg} }
|
||||
func errForbidden(msg string) error { return &apiError{Status: http.StatusForbidden, Msg: msg} }
|
||||
func errNotFound(msg string) error { return &apiError{Status: http.StatusNotFound, Msg: msg} }
|
||||
func errConflict(msg string) error { return &apiError{Status: http.StatusConflict, Msg: msg} }
|
||||
func errLocked(msg string) error { return &apiError{Status: http.StatusLocked, Msg: msg} }
|
||||
func errInternal(msg string) error {
|
||||
return &apiError{Status: http.StatusInternalServerError, Msg: msg}
|
||||
}
|
||||
func errInsufficient(msg string) error {
|
||||
return &apiError{Status: http.StatusInsufficientStorage, Msg: msg}
|
||||
}
|
||||
func errNotImpl(msg string) error { return &apiError{Status: http.StatusNotImplemented, Msg: msg} }
|
||||
|
||||
// mapStorageError 把存储层哨兵错误映射为 apiError(对齐 README 约定):
|
||||
// ErrNotFound→404、ErrInvalidPath→400、ErrUnavailable→503、ErrNotSupported→501、
|
||||
// ErrRangeNotSatisfiable→416、ErrHashMismatch→400(支持 %w 包装判定)。
|
||||
func mapStorageError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return errNotFound("文件不存在")
|
||||
case errors.Is(err, storage.ErrInvalidPath):
|
||||
return errBadRequest("非法文件路径")
|
||||
case errors.Is(err, storage.ErrUnavailable):
|
||||
return &apiError{Status: http.StatusServiceUnavailable, Msg: "存储服务不可用,请稍后再试"}
|
||||
case errors.Is(err, storage.ErrNotSupported):
|
||||
return errNotImpl("当前存储引擎不支持该操作")
|
||||
case errors.Is(err, storage.ErrRangeNotSatisfiable):
|
||||
return &apiError{Status: http.StatusRequestedRangeNotSatisfiable, Msg: "请求范围超出文件大小"}
|
||||
case errors.Is(err, storage.ErrHashMismatch):
|
||||
return errBadRequest("分片哈希校验失败,请重新上传")
|
||||
}
|
||||
return errInternal("存储操作失败: " + err.Error())
|
||||
}
|
||||
|
||||
// respondError 统一错误出口:apiError 按其状态码响应,其余 500。
|
||||
func respondError(c *gin.Context, err error) {
|
||||
var ae *apiError
|
||||
if !errors.As(err, &ae) {
|
||||
ae = &apiError{Status: http.StatusInternalServerError, Msg: err.Error()}
|
||||
}
|
||||
// 非 2xx 且未标记审计跳过时,兜底给出 result/errorMsg(中间件还会按状态兜底)
|
||||
middleware.AuditSet(c, func(e *audit.Entry) {
|
||||
if e.Result == "" {
|
||||
if ae.Status == 401 || ae.Status == 403 || ae.Status == 423 || ae.Status == 429 || ae.Status == 428 {
|
||||
e.Result = model.AuditResultDenied
|
||||
} else {
|
||||
e.Result = model.AuditResultFailed
|
||||
}
|
||||
}
|
||||
if e.ErrorMsg == "" {
|
||||
e.ErrorMsg = ae.Msg
|
||||
}
|
||||
})
|
||||
response.Fail(c, ae.Status, ae.Msg)
|
||||
}
|
||||
|
||||
// ============ 取件码 / 下载令牌 ============
|
||||
|
||||
// codeChars 随机字符码字符集(对齐参考 string.ascii_uppercase + digits)。
|
||||
const codeChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// generateCode 生成取件码:secret→5 位大写字母+数字;number→5 位数字(10000~99999)。
|
||||
// 与参考 get_random_code 一致:冲突时重试(由调用方控制上限)。
|
||||
func generateCode(style string) string {
|
||||
if style == "number" {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(90000))
|
||||
return strconv.FormatInt(10000+n.Int64(), 10)
|
||||
}
|
||||
b := make([]byte, 5)
|
||||
for i := range b {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(codeChars))))
|
||||
b[i] = codeChars[n.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ============ 自定义提取码(26.9,防撞库)============
|
||||
|
||||
const (
|
||||
// pickupCodeMinLen 最小长度:L3 由 4 提升至 5(4 位码空间仅 168 万,
|
||||
// 多 IP 分布式撞库可在限流窗口内覆盖;5 位起 6000 万空间显著提高成本)
|
||||
pickupCodeMinLen = 5
|
||||
pickupCodeMaxLen = 8
|
||||
)
|
||||
|
||||
// validatePickupCode 校验自定义提取码:
|
||||
// - 长度 5-8 字符(下限保证码空间 36^5≈6000 万起,配合取件错误限流防撞库);
|
||||
// - 仅字母/数字(排除空格与符号,规避中文场景误输与 URL 转义问题);
|
||||
// - 空串合法(空 = 使用系统随机码)。
|
||||
// - 兼容:仅约束新建码,历史 4 位码仍可正常取件。
|
||||
func validatePickupCode(code string) error {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return nil
|
||||
}
|
||||
if l := len(code); l < pickupCodeMinLen || l > pickupCodeMaxLen {
|
||||
return errBadRequest(fmt.Sprintf("提取码长度须为 %d-%d 位", pickupCodeMinLen, pickupCodeMaxLen))
|
||||
}
|
||||
for _, r := range code {
|
||||
if !(r >= '0' && r <= '9') && !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') {
|
||||
return errBadRequest("提取码仅支持字母和数字")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pickCustomCode 解析提取码:自定义非空→查重返回;空→随机码。
|
||||
// 占用时 400(不静默改码——用户可能已把链接发出去,静默改码会导致取不到件)。
|
||||
func pickCustomCode(ctx context.Context, db *gorm.DB, cfg *config.Config, custom string) (string, error) {
|
||||
custom = strings.ToUpper(strings.TrimSpace(custom))
|
||||
if custom == "" {
|
||||
return randomCode(ctx, db, cfg)
|
||||
}
|
||||
var count int64
|
||||
if err := db.WithContext(ctx).Model(&model.FileCodes{}).Where("code = ?", custom).Count(&count).Error; err != nil {
|
||||
return "", errInternal("取件码查重失败: " + err.Error())
|
||||
}
|
||||
if count > 0 {
|
||||
return "", errBadRequest("该提取码已被占用,请换一个")
|
||||
}
|
||||
return custom, nil
|
||||
}
|
||||
|
||||
// mapCodeConflict 26.9:分享记录创建失败时,若是自定义码唯一索引冲突(并发兜底,
|
||||
// pickCustomCode 的预查重未覆盖竞态),转为友好 400;其余错误原样返回。
|
||||
func mapCodeConflict(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "已被占用") ||
|
||||
strings.Contains(msg, "UNIQUE constraint") ||
|
||||
strings.Contains(msg, "duplicate key") ||
|
||||
strings.Contains(msg, "Duplicate entry") {
|
||||
return errBadRequest("该提取码已被占用,请换一个")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ============ 站点对外域名(26.9)============
|
||||
|
||||
// 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])?)*$`)
|
||||
|
||||
func normalizeSiteDomain(raw string) (string, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return "", nil
|
||||
}
|
||||
s = strings.TrimRight(s, "/")
|
||||
if !strings.Contains(s, "://") {
|
||||
s = "http://" + s
|
||||
}
|
||||
u, err := url.Parse(s)
|
||||
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return "", errBadRequest("站点域名格式:http(s)://主机[:端口],如 https://share.example.com")
|
||||
}
|
||||
if (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
|
||||
return "", errBadRequest("站点域名只填主机与端口,不带路径,如 https://share.example.com")
|
||||
}
|
||||
if !siteDomainHostRe.MatchString(u.Hostname()) {
|
||||
return "", errBadRequest("站点域名主机名仅支持字母、数字、点与连字符")
|
||||
}
|
||||
if p := u.Port(); p != "" {
|
||||
if n, perr := strconv.Atoi(p); perr != nil || n < 1 || n > 65535 {
|
||||
return "", errBadRequest("站点域名端口须为 1-65535")
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// randomCode 生成唯一取件码(查库去重,最多尝试 20 次防止极端碰撞)。
|
||||
// style 为空时取配置 code_generate_type(secret/string→secret,其余→number)。
|
||||
func randomCode(ctx context.Context, db *gorm.DB, cfg *config.Config) (string, error) {
|
||||
style := strings.TrimSpace(cfg.GetString("code_generate_type"))
|
||||
if style == "string" {
|
||||
style = "secret"
|
||||
}
|
||||
if style != "secret" && style != "number" {
|
||||
style = "number"
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
code := generateCode(style)
|
||||
var count int64
|
||||
if err := db.WithContext(ctx).Model(&model.FileCodes{}).
|
||||
Where("code = ?", code).Count(&count).Error; err != nil {
|
||||
return "", errInternal("取件码生成失败: " + err.Error())
|
||||
}
|
||||
if count == 0 {
|
||||
return code, nil
|
||||
}
|
||||
}
|
||||
return "", errInternal("取件码生成失败,请重试")
|
||||
}
|
||||
|
||||
// GetSelectToken 生成下载令牌(L2:HMAC-SHA256 替换拼接哈希,消除拼接歧义;
|
||||
// 密钥前置为 HMAC key,窗口语义不变):
|
||||
// HMAC-SHA256(key=secret, msg=code|time_factor),time_factor = unix秒/1000 - offset。
|
||||
// offset=0 当前窗口、offset=1 上一窗口——下载端点同时接受两个窗口,
|
||||
// 避免 ~16.7 分钟窗口边界竞态导致偶发 403。
|
||||
func GetSelectToken(code, secret string, offset int) string {
|
||||
timeFactor := time.Now().Unix()/1000 - int64(offset)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
fmt.Fprintf(mac, "%s|%d", code, timeFactor)
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// VerifySelectToken 常量时间校验下载令牌(当前与上一窗口任一匹配即通过)。
|
||||
func VerifySelectToken(code, secret, key string) bool {
|
||||
return hmac.Equal([]byte(key), []byte(GetSelectToken(code, secret, 0))) ||
|
||||
hmac.Equal([]byte(key), []byte(GetSelectToken(code, secret, 1)))
|
||||
}
|
||||
|
||||
// ============ 过期策略 ============
|
||||
|
||||
// expireResult 过期策略解析结果(对齐参考 get_expire_info)。
|
||||
type expireResult struct {
|
||||
ExpiredAt *time.Time // nil 表示永久
|
||||
ExpiredCount int // <0 按时间过期;>0 按次数
|
||||
UsedCount int
|
||||
}
|
||||
|
||||
// resolveExpire 校验 expire_style 白名单并计算过期信息。
|
||||
// 对齐参考:max_save_seconds>0 时为最长保存上限(超限 403),否则默认 7 天上限;
|
||||
// 26.9 需求 ④:style=count 时 expire_value 不得超出 max_save_count(0=不限制,超限 403)。
|
||||
func resolveExpire(cfg *config.Config, expireValue int, expireStyle string) (*expireResult, error) {
|
||||
allowed := cfg.ExpireStyle()
|
||||
okStyle := false
|
||||
for _, s := range allowed {
|
||||
if s == expireStyle {
|
||||
okStyle = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !okStyle {
|
||||
return nil, errBadRequest("过期时间类型错误")
|
||||
}
|
||||
if expireValue <= 0 {
|
||||
return nil, errBadRequest("过期时间值必须大于 0")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
res := &expireResult{ExpiredCount: -1, UsedCount: 0}
|
||||
var expiredAt time.Time
|
||||
switch expireStyle {
|
||||
case "day":
|
||||
expiredAt = now.AddDate(0, 0, expireValue)
|
||||
case "hour":
|
||||
expiredAt = now.Add(time.Duration(expireValue) * time.Hour)
|
||||
case "minute":
|
||||
expiredAt = now.Add(time.Duration(expireValue) * time.Minute)
|
||||
case "count":
|
||||
// 保存次数策略(需求 ④):max_save_count>0 时为可取次数上限,超限 403
|
||||
if maxCount := cfg.MaxSaveCount(); maxCount > 0 && expireValue > maxCount {
|
||||
return nil, errForbidden(fmt.Sprintf("限制次数最多为 %d 次", maxCount))
|
||||
}
|
||||
// 按次数过期:固定保留 1 天时间兜底(对齐参考)
|
||||
expiredAt = now.AddDate(0, 0, 1)
|
||||
res.ExpiredCount = expireValue
|
||||
case "forever":
|
||||
res.ExpiredAt = nil
|
||||
res.ExpiredCount = -1
|
||||
return res, nil
|
||||
default:
|
||||
expiredAt = now.AddDate(0, 0, 1)
|
||||
}
|
||||
// 最长保存时间限制
|
||||
maxSeconds := cfg.MaxSaveSeconds()
|
||||
maxDelta := 7 * 24 * time.Hour
|
||||
if maxSeconds > 0 {
|
||||
maxDelta = time.Duration(maxSeconds) * time.Second
|
||||
}
|
||||
if expiredAt.Sub(now) > maxDelta {
|
||||
return nil, errForbidden(fmt.Sprintf("限制最长时间为 %s,可换用其他方式", formatDurationCN(maxDelta)))
|
||||
}
|
||||
res.ExpiredAt = &expiredAt
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// formatDurationCN 把时长格式化为中文描述(对齐参考 max_save_times_desc)。
|
||||
func formatDurationCN(d time.Duration) string {
|
||||
sec := int64(d.Seconds())
|
||||
days := sec / 86400
|
||||
hours := sec % 86400 / 3600
|
||||
minutes := sec % 3600 / 60
|
||||
seconds := sec % 60
|
||||
var parts []string
|
||||
if days > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d天", days))
|
||||
}
|
||||
if hours > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d小时", hours))
|
||||
}
|
||||
if minutes > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d分钟", minutes))
|
||||
}
|
||||
if seconds > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d秒", seconds))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "0秒"
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
// ============ 存储路径 / 容量预留 ============
|
||||
|
||||
// storeFor 26.9:按归属引擎取存储实例(空戳/未知名回落当前引擎,兼容历史数据)。
|
||||
func (d *Deps) storeFor(engine string) (storage.Storage, error) {
|
||||
if engine == "" || !storage.ValidEngine(engine) {
|
||||
return d.Store, nil
|
||||
}
|
||||
if engine == d.Store.CurrentName() {
|
||||
return d.Store, nil
|
||||
}
|
||||
return d.Store.EngineOf(engine)
|
||||
}
|
||||
|
||||
// buildSavePath 生成上传文件的存储相对路径(对齐参考 get_file_path_name):
|
||||
// [storage_path/]share/data/YYYY/MM/DD/<uuid>/<清理后文件名>。
|
||||
func buildSavePath(cfg *config.Config, rawName string, fileUUID string) (dirPath, prefix, suffix, cleanName, savePath string) {
|
||||
today := time.Now().Format("2006/01/02")
|
||||
cleanName = storage.SanitizeFileName(rawName)
|
||||
ext := path.Ext(cleanName)
|
||||
prefix = strings.TrimSuffix(cleanName, ext)
|
||||
suffix = ext
|
||||
base := "share/data/" + today + "/" + fileUUID
|
||||
if sp := strings.Trim(cfg.GetString("storage_path"), "/"); sp != "" {
|
||||
base = sp + "/" + base
|
||||
}
|
||||
dirPath = base
|
||||
savePath = base + "/" + cleanName
|
||||
return
|
||||
}
|
||||
|
||||
// reserveStorage 原子预留上传容量(对齐参考 quota.reserve_storage):
|
||||
// storageLimit<=0 或 size=0 时不限制直接返回;
|
||||
// 通过单条 INSERT...SELECT 条件写入保证 (已用+已预留+本次) <= limit,超限返回 507。
|
||||
func reserveStorage(ctx context.Context, db *gorm.DB, cfg *config.Config, token string, size int64, ttl time.Duration) error {
|
||||
limit := cfg.GetInt64("storageLimit")
|
||||
if limit <= 0 || size <= 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(ttl)
|
||||
// 清理同 token 的过期预留
|
||||
if err := db.WithContext(ctx).
|
||||
Where("token = ? AND expires_at <= ?", token, now).
|
||||
Delete(&model.StorageReservation{}).Error; err != nil {
|
||||
return errInternal("容量预留失败: " + err.Error())
|
||||
}
|
||||
// 同 token 已有生效预留:大小一致则幂等返回,不一致报冲突(对齐参考 409)
|
||||
var existing model.StorageReservation
|
||||
err := db.WithContext(ctx).
|
||||
Where("token = ? AND expires_at > ?", token, now).First(&existing).Error
|
||||
if err == nil {
|
||||
if existing.Size == size {
|
||||
return nil
|
||||
}
|
||||
return errConflict("上传容量预留信息不一致")
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errInternal("容量预留失败: " + err.Error())
|
||||
}
|
||||
// 原子条件插入:已用 + 生效预留 + 本次 <= 上限。
|
||||
// Info5:Postgres READ COMMITTED 下并发 INSERT..SELECT 可能同时读到相同快照
|
||||
// 而轻微超额记账,故包事务并用事务级 advisory lock 串行化配额判定
|
||||
// (SQLite 写本身串行,无需加锁)。
|
||||
lockFn := func(tx *gorm.DB) error {
|
||||
if tx.Dialector.Name() == config.DBDriverPostgres {
|
||||
return tx.Exec(`SELECT pg_advisory_xact_lock(?)`, quotaLockKey).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var insertErr error
|
||||
txErr := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := lockFn(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
res := tx.Exec(`
|
||||
INSERT INTO storage_reservations (token, size, expires_at)
|
||||
SELECT ?, ?, ?
|
||||
WHERE (
|
||||
COALESCE((SELECT COALESCE(SUM(size),0) FROM file_codes), 0)
|
||||
+ COALESCE((SELECT COALESCE(SUM(size),0) FROM storage_reservations WHERE expires_at > ?), 0)
|
||||
+ ?
|
||||
) <= ?`,
|
||||
token, size, expiresAt, now, size, limit)
|
||||
insertErr = res.Error
|
||||
if res.Error != nil {
|
||||
return res.Error // 触发回滚(同 token 冲突分支在外层处理)
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errInsufficient("存储空间已达到管理员设置的容量上限")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if txErr != nil {
|
||||
// 并发冲突回退:检查是否已有同 token 同大小的生效预留(对齐参考并发分支)
|
||||
var cnt int64
|
||||
_ = db.WithContext(ctx).Model(&model.StorageReservation{}).
|
||||
Where("token = ? AND size = ? AND expires_at > ?", token, size, now).
|
||||
Count(&cnt).Error
|
||||
if cnt > 0 {
|
||||
return nil
|
||||
}
|
||||
var ins *apiError
|
||||
if errors.As(txErr, &ins) && ins.Status == http.StatusInsufficientStorage {
|
||||
return txErr // 507:真实容量不足
|
||||
}
|
||||
if insertErr != nil && errors.Is(insertErr, txErr) {
|
||||
return errInternal("容量预留失败: " + insertErr.Error())
|
||||
}
|
||||
return errInternal("容量预留失败: " + txErr.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// quotaLockKey Postgres advisory lock 键(配额判定的事务级串行化)。
|
||||
const quotaLockKey int64 = 0x46434251 // "FCBQ"
|
||||
|
||||
// releaseStorage 释放容量预留(幂等)。
|
||||
func releaseStorage(ctx context.Context, db *gorm.DB, token string) {
|
||||
_ = db.WithContext(ctx).Where("token = ?", token).Delete(&model.StorageReservation{}).Error
|
||||
}
|
||||
|
||||
// ============ 文件类型校验(对齐 apps/base/file_validation.py)============
|
||||
|
||||
// fileKind 已知文件类型:扩展名 / MIME / magic bytes。
|
||||
type fileKind struct {
|
||||
name string
|
||||
extensions []string
|
||||
mimes []string
|
||||
signatures [][]byte
|
||||
}
|
||||
|
||||
var fileKinds = []fileKind{
|
||||
{"png", []string{".png"}, []string{"image/png"}, [][]byte{{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}}},
|
||||
{"jpg", []string{".jpg", ".jpeg"}, []string{"image/jpeg"}, [][]byte{{0xff, 0xd8, 0xff}}},
|
||||
{"gif", []string{".gif"}, []string{"image/gif"}, [][]byte{[]byte("GIF87a"), []byte("GIF89a")}},
|
||||
{"webp", []string{".webp"}, []string{"image/webp"}, nil},
|
||||
{"bmp", []string{".bmp"}, []string{"image/bmp", "image/x-ms-bmp"}, [][]byte{[]byte("BM")}},
|
||||
{"pdf", []string{".pdf"}, []string{"application/pdf"}, [][]byte{[]byte("%PDF")}},
|
||||
{"zip", []string{".zip", ".docx", ".xlsx", ".pptx", ".apk", ".jar"},
|
||||
[]string{"application/zip", "application/x-zip-compressed",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/java-archive", "application/vnd.android.package-archive"},
|
||||
[][]byte{[]byte("PK\x03\x04"), []byte("PK\x05\x06"), []byte("PK\x07\x08")}},
|
||||
{"rar", []string{".rar"}, []string{"application/x-rar-compressed", "application/vnd.rar"},
|
||||
[][]byte{[]byte("Rar!\x1a\x07\x00"), []byte("Rar!\x1a\x07\x01\x00")}},
|
||||
{"7z", []string{".7z"}, []string{"application/x-7z-compressed"}, [][]byte{{0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c}}},
|
||||
{"gz", []string{".gz", ".tgz"}, []string{"application/gzip", "application/x-gzip"}, [][]byte{{0x1f, 0x8b}}},
|
||||
{"mp3", []string{".mp3"}, []string{"audio/mpeg"}, [][]byte{[]byte("ID3"), {0xff, 0xfb}, {0xff, 0xf3}, {0xff, 0xf2}}},
|
||||
{"mp4", []string{".mp4", ".m4a", ".mov"}, []string{"video/mp4", "audio/mp4", "video/quicktime"}, nil},
|
||||
{"exe", []string{".exe", ".dll", ".sys"}, []string{"application/x-msdownload", "application/x-dosexec"}, [][]byte{[]byte("MZ")}},
|
||||
{"elf", []string{".elf", ".so", ".o"}, []string{"application/x-executable"}, [][]byte{{0x7f, 'E', 'L', 'F'}}},
|
||||
}
|
||||
|
||||
// knownExtensions 全部已知扩展名集合。
|
||||
var knownExtensions = func() map[string]bool {
|
||||
m := map[string]bool{}
|
||||
for _, k := range fileKinds {
|
||||
for _, ext := range k.extensions {
|
||||
m[ext] = true
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// isTypeAllowed 判断文件是否在 allowed_file_types 白名单内("*"/*/* 放行全部)。
|
||||
func isTypeAllowed(cfg *config.Config, fileName, contentType string) bool {
|
||||
allowed := cfg.AllowedFileTypes()
|
||||
if len(allowed) == 0 {
|
||||
return true
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(fileName))
|
||||
ct := strings.ToLower(strings.TrimSpace(contentType))
|
||||
for _, rule := range allowed {
|
||||
rule = strings.ToLower(strings.TrimSpace(rule))
|
||||
switch {
|
||||
case rule == "*" || rule == "*/*":
|
||||
return true
|
||||
case strings.Contains(rule, "/"):
|
||||
if ok, _ := path.Match(rule, ct); ok {
|
||||
return true
|
||||
}
|
||||
default:
|
||||
if !strings.HasPrefix(rule, ".") {
|
||||
rule = "." + rule
|
||||
}
|
||||
if strings.HasSuffix(name, rule) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// detectFileKind 按文件头识别类型(对齐参考:RIFF/WEBP、ftyp/mp4 与前缀签名表)。
|
||||
func detectFileKind(header []byte) *fileKind {
|
||||
if len(header) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(header) >= 12 && string(header[:4]) == "RIFF" && string(header[8:12]) == "WEBP" {
|
||||
for i := range fileKinds {
|
||||
if fileKinds[i].name == "webp" {
|
||||
return &fileKinds[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(header) >= 12 && string(header[4:8]) == "ftyp" {
|
||||
for i := range fileKinds {
|
||||
if fileKinds[i].name == "mp4" {
|
||||
return &fileKinds[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
var best *fileKind
|
||||
bestLen := 0
|
||||
for i := range fileKinds {
|
||||
for _, sig := range fileKinds[i].signatures {
|
||||
if len(sig) > 0 && len(header) >= len(sig) && string(header[:len(sig)]) == string(sig) {
|
||||
if len(sig) > bestLen {
|
||||
bestLen = len(sig)
|
||||
best = &fileKinds[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// validateFileMagic 白名单 + magic bytes 防伪造(对齐参考 validate_file_magic)。
|
||||
// header 为文件前 64 字节,可为空(空则只校验白名单)。
|
||||
func validateFileMagic(cfg *config.Config, fileName, contentType string, header []byte) error {
|
||||
if !isTypeAllowed(cfg, fileName, contentType) {
|
||||
return errForbidden("不允许上传该类型文件")
|
||||
}
|
||||
if len(header) == 0 {
|
||||
return nil
|
||||
}
|
||||
ext := strings.ToLower(path.Ext(fileName))
|
||||
ct := strings.ToLower(strings.TrimSpace(contentType))
|
||||
detected := detectFileKind(header)
|
||||
|
||||
if knownExtensions[ext] {
|
||||
if detected == nil {
|
||||
return errForbidden("文件内容与扩展名不匹配,疑似伪造类型")
|
||||
}
|
||||
matched := false
|
||||
for _, e := range detected.extensions {
|
||||
if e == ext {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return errForbidden("文件内容与扩展名不匹配,疑似伪造类型")
|
||||
}
|
||||
}
|
||||
if ct != "" {
|
||||
for _, k := range fileKinds {
|
||||
for _, m := range k.mimes {
|
||||
if m == ct {
|
||||
// 声明了已知 MIME:内容必须匹配
|
||||
if detected == nil {
|
||||
return errForbidden("文件内容与 Content-Type 不匹配,疑似伪造类型")
|
||||
}
|
||||
matched := false
|
||||
for _, m2 := range detected.mimes {
|
||||
if m2 == ct {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return errForbidden("文件内容与 Content-Type 不匹配,疑似伪造类型")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readMultipartHeader 读取上传文件前 n 字节并 seek 回起点(用于 magic 校验)。
|
||||
func readMultipartHeader(f multipart.File, n int64) []byte {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
nread, _ := f.Read(buf)
|
||||
_, _ = f.Seek(0, 0)
|
||||
if nread <= 0 {
|
||||
return nil
|
||||
}
|
||||
return buf[:nread]
|
||||
}
|
||||
|
||||
// ============ 杂项 ============
|
||||
|
||||
// humanSize 把字节数转成人类可读描述(B/KB/MB/GB 自适应;
|
||||
// 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 {
|
||||
case n >= gb:
|
||||
return fmt.Sprintf("%.2f GB", float64(n)/float64(gb))
|
||||
case n >= mb:
|
||||
return fmt.Sprintf("%.2f MB", float64(n)/float64(mb))
|
||||
case n >= kb:
|
||||
return fmt.Sprintf("%.2f KB", float64(n)/float64(kb))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
}
|
||||
|
||||
// contentDisposition 生成 RFC 5987 附件头(对齐参考 filename*=UTF-8” 格式)。
|
||||
func contentDisposition(name string) string {
|
||||
quoted := urlPathEscape(name)
|
||||
return "attachment; filename*=UTF-8''" + quoted
|
||||
}
|
||||
|
||||
// urlPathEscape RFC 5987 风格百分号编码(等价 urllib.parse.quote(safe=”))。
|
||||
func urlPathEscape(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range []byte(s) {
|
||||
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') ||
|
||||
r == '-' || r == '_' || r == '.' || r == '~' {
|
||||
b.WriteByte(r)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%%%02X", r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseISOTime 解析 ISO 8601 / RFC3339 / 日期时间字符串,失败返回错误。
|
||||
func parseISOTime(s string) (time.Time, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}, errors.New("空时间")
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano, time.RFC3339,
|
||||
"2006-01-02T15:04:05", "2006-01-02 15:04:05", "2006-01-02",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("时间格式错误: %s", s)
|
||||
}
|
||||
|
||||
// requireUploadLimit 上传限流入口检查(进入即校验,超限 423;成功后由 handler 显式 Add)。
|
||||
// 对齐参考:FastAPI Depends(ip_limit["upload"]) 在进入时 check。
|
||||
func requireUploadLimit(c *gin.Context, limiter *middleware.RateLimiter) bool {
|
||||
if allowed, _ := limiter.Check(c, middleware.LimitUpload); !allowed {
|
||||
response.Fail(c, http.StatusLocked, "请求次数过多,请稍后再试")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// bindJSONOrForm 兼容 JSON 与表单请求体绑定到结构体。
|
||||
func bindJSONOrForm(c *gin.Context, obj any) error {
|
||||
ct := c.GetHeader("Content-Type")
|
||||
if strings.Contains(ct, "application/json") {
|
||||
if err := c.ShouldBindJSON(obj); err != nil {
|
||||
return errBadRequest("请求体格式错误: " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 26.9 兼容归一化:旧前端 bundle(fetch 字符串 body 默认 text/plain)发的
|
||||
// 是 text/plain + urlencoded 格式。此类请求改写 Content-Type 后走表单绑定,
|
||||
// 否则 ShouldBind 对 text/plain 不解析,非空字段全部丢失。
|
||||
base := ct
|
||||
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
||||
base = ct[:i]
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(base), "text/plain") &&
|
||||
c.Request != nil && c.Request.Body != nil {
|
||||
if raw, err := io.ReadAll(c.Request.Body); err == nil {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
// JSON 形态(无头/误标 text/plain):改写后按 JSON 绑定(须先于 ParseQuery 判断,
|
||||
// 否则形如 {"a":1} 的 JSON 会被 ParseQuery 误判为单键 urlencoded)
|
||||
if len(trimmed) > 0 && trimmed[0] == '{' {
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(raw))
|
||||
return c.ShouldBindJSON(obj)
|
||||
}
|
||||
if vals, perr := url.ParseQuery(string(raw)); perr == nil && len(vals) > 0 {
|
||||
// urlencoded 形态:改写 Content-Type 走表单绑定
|
||||
c.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(raw))
|
||||
return c.ShouldBind(obj)
|
||||
}
|
||||
// 其他形态:还原 body 让 ShouldBind 按原样处理
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(raw))
|
||||
}
|
||||
}
|
||||
if err := c.ShouldBind(obj); err != nil {
|
||||
return errBadRequest("请求体格式错误: " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// auditUploadEntry 填充上传类审计业务字段的便捷函数。
|
||||
func auditUploadEntry(c *gin.Context, code, name string, size, transferred int64) {
|
||||
middleware.AuditSet(c, func(e *audit.Entry) {
|
||||
e.FileCode = code
|
||||
e.FileName = name
|
||||
e.SizeBytes = size
|
||||
e.TransferredBytes = transferred
|
||||
})
|
||||
}
|
||||
|
||||
// auditRecordSuccess / auditRecordFailed 显式落库便捷函数。
|
||||
func auditRecordSuccess(c *gin.Context, svc *audit.Service) {
|
||||
middleware.AuditRecordRequest(c, svc, model.AuditResultSuccess, "")
|
||||
}
|
||||
|
||||
func auditRecordFailed(c *gin.Context, svc *audit.Service, msg string) {
|
||||
middleware.AuditRecordRequest(c, svc, model.AuditResultFailed, msg)
|
||||
}
|
||||
|
||||
// uuidHex 生成 32 位十六进制随机串(对齐参考 uuid4().hex)。
|
||||
func uuidHex() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
// 设置版本号与变体位以保持 uuid4 兼容格式
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// uuidCanonical 生成带连字符的 UUID 字符串(upload_id 用)。
|
||||
func uuidCanonical() string {
|
||||
h := uuidHex()
|
||||
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"fileshare/internal/config"
|
||||
"fileshare/internal/storage"
|
||||
)
|
||||
|
||||
// newTestConfig 构造测试配置(defaults 基线,无 KV 覆盖;需求 ⑧ 默认 sqlite,无需真实数据库)。
|
||||
func newTestConfig(t *testing.T) *config.Config {
|
||||
t.Helper()
|
||||
t.Setenv("FCB_DB_DRIVER", "sqlite")
|
||||
t.Setenv("FCB_DB_DSN", "")
|
||||
cfg, err := config.New()
|
||||
if err != nil {
|
||||
t.Fatalf("config.New 失败: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// TestGetSelectTokenWindow 验证下载令牌的窗口号逻辑(对齐参考 get_select_token)。
|
||||
func TestGetSelectTokenWindow(t *testing.T) {
|
||||
code := "AB12C"
|
||||
secret := "test-secret"
|
||||
|
||||
tok0 := GetSelectToken(code, secret, 0)
|
||||
tok1 := GetSelectToken(code, secret, 1)
|
||||
|
||||
if tok0 == "" || tok1 == "" {
|
||||
t.Fatal("令牌不应为空")
|
||||
}
|
||||
// 同一窗口内 offset=0 的两次生成必须一致(确定性问题)
|
||||
if tok0 != GetSelectToken(code, secret, 0) {
|
||||
t.Fatal("同窗口令牌应确定一致")
|
||||
}
|
||||
// 不同 offset 的令牌必然不同(窗口号不同)
|
||||
if tok0 == tok1 {
|
||||
t.Fatal("offset=0 与 offset=1 的令牌应不同")
|
||||
}
|
||||
// 不同 code / secret 的令牌不同
|
||||
if tok0 == GetSelectToken("ZZ999", secret, 0) {
|
||||
t.Fatal("不同取件码的令牌应不同")
|
||||
}
|
||||
if tok0 == GetSelectToken(code, "other-secret", 0) {
|
||||
t.Fatal("不同密钥的令牌应不同")
|
||||
}
|
||||
// 令牌为 64 位十六进制(sha256 hex)
|
||||
if len(tok0) != 64 {
|
||||
t.Fatalf("令牌长度应为 64,实际 %d", len(tok0))
|
||||
}
|
||||
// 窗口号公式:unix/1000 - offset(秒级窗口约 16.7 分钟)
|
||||
now := time.Now().Unix()
|
||||
if now/1000 == (now+1100)/1000 {
|
||||
// 仅当测试跨越窗口边界才跳过该断言(罕见,容忍)
|
||||
t.Log("测试跨越窗口边界,跳过窗口公式断言")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMapStorageError 验证存储哨兵错误→HTTP 状态映射表。
|
||||
func TestMapStorageError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
expect int
|
||||
}{
|
||||
{"NotFound 直返", storage.ErrNotFound, 404},
|
||||
{"NotFound 包装", fmt.Errorf("引擎内层: %w", storage.ErrNotFound), 404},
|
||||
{"InvalidPath", storage.ErrInvalidPath, 400},
|
||||
{"InvalidPath 包装", fmt.Errorf("webdav: %w", storage.ErrInvalidPath), 400},
|
||||
{"Unavailable", storage.ErrUnavailable, 503},
|
||||
{"Unavailable 包装", fmt.Errorf("s3: %w", storage.ErrUnavailable), 503},
|
||||
{"NotSupported", storage.ErrNotSupported, 501},
|
||||
{"NotSupported 包装", fmt.Errorf("local: %w", storage.ErrNotSupported), 501},
|
||||
{"RangeNotSatisfiable", storage.ErrRangeNotSatisfiable, 416},
|
||||
{"HashMismatch", storage.ErrHashMismatch, 400},
|
||||
{"未知错误", errors.New("其他错误"), 500},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mapped := mapStorageError(tc.err)
|
||||
var ae *apiError
|
||||
if !errors.As(mapped, &ae) {
|
||||
t.Fatalf("应映射为 apiError,得到 %T", mapped)
|
||||
}
|
||||
if ae.Status != tc.expect {
|
||||
t.Fatalf("状态码应为 %d,实际 %d", tc.expect, ae.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveExpire 验证过期策略解析(白名单/上限/各 style)。
|
||||
func TestResolveExpire(t *testing.T) {
|
||||
cfg := newTestConfig(t)
|
||||
// 非法 style
|
||||
if _, err := resolveExpire(cfg, 1, "year"); err == nil {
|
||||
t.Fatal("非白名单 style 应报错")
|
||||
}
|
||||
// 非法 value
|
||||
if _, err := resolveExpire(cfg, 0, "day"); err == nil {
|
||||
t.Fatal("expire_value<=0 应报错")
|
||||
}
|
||||
// day:7 天内合法
|
||||
res, err := resolveExpire(cfg, 3, "day")
|
||||
if err != nil {
|
||||
t.Fatalf("3 天应合法: %v", err)
|
||||
}
|
||||
if res.ExpiredAt == nil || res.ExpiredCount != -1 {
|
||||
t.Fatal("day 类型应有 expired_at 且 expired_count=-1")
|
||||
}
|
||||
// 超过 7 天上限
|
||||
if _, err := resolveExpire(cfg, 30, "day"); err == nil {
|
||||
t.Fatal("超过 7 天上限应报错")
|
||||
}
|
||||
// count:按次数
|
||||
res, err = resolveExpire(cfg, 5, "count")
|
||||
if err != nil {
|
||||
t.Fatalf("count 应合法: %v", err)
|
||||
}
|
||||
if res.ExpiredCount != 5 {
|
||||
t.Fatalf("count 类型 expired_count 应为 5,实际 %d", res.ExpiredCount)
|
||||
}
|
||||
// forever:永久
|
||||
res, err = resolveExpire(cfg, 1, "forever")
|
||||
if err != nil {
|
||||
t.Fatalf("forever 应合法: %v", err)
|
||||
}
|
||||
if res.ExpiredAt != nil || res.ExpiredCount != -1 {
|
||||
t.Fatal("forever 应为 expired_at=nil 且 expired_count=-1")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateCode 验证取件码格式。
|
||||
func TestGenerateCode(t *testing.T) {
|
||||
for i := 0; i < 50; i++ {
|
||||
num := generateCode("number")
|
||||
if len(num) != 5 {
|
||||
t.Fatalf("数字码应为 5 位,实际 %q", num)
|
||||
}
|
||||
for _, ch := range num {
|
||||
if ch < '0' || ch > '9' {
|
||||
t.Fatalf("数字码含非数字字符: %q", num)
|
||||
}
|
||||
}
|
||||
secret := generateCode("secret")
|
||||
if len(secret) != 5 {
|
||||
t.Fatalf("字符码应为 5 位,实际 %q", secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseRangeHeader 验证 Range 头解析(对齐 HTTP 语义)。
|
||||
func TestParseRangeHeader(t *testing.T) {
|
||||
// 全量(无 Range)
|
||||
if parseRangeHeader("", 1000) != nil {
|
||||
t.Fatal("无 Range 头应返回 nil")
|
||||
}
|
||||
// 标准区间
|
||||
r := parseRangeHeader("bytes=0-99", 1000)
|
||||
if r == nil || r.Start != 0 || r.End != 99 {
|
||||
t.Fatalf("bytes=0-99 解析错误: %+v", r)
|
||||
}
|
||||
// 开区间到末尾
|
||||
r = parseRangeHeader("bytes=500-", 1000)
|
||||
if r == nil || r.Start != 500 || r.End != -1 {
|
||||
t.Fatalf("bytes=500- 解析错误: %+v", r)
|
||||
}
|
||||
// 后缀区间(最后 100 字节)
|
||||
r = parseRangeHeader("bytes=-100", 1000)
|
||||
if r == nil || r.Start != 900 || r.End != -1 {
|
||||
t.Fatalf("bytes=-100 解析错误: %+v", r)
|
||||
}
|
||||
// 后缀超长:截断到全文件
|
||||
r = parseRangeHeader("bytes=-5000", 1000)
|
||||
if r == nil || r.Start != 0 {
|
||||
t.Fatalf("bytes=-5000 应从头开始: %+v", r)
|
||||
}
|
||||
// 多区间不支持→回退全量
|
||||
if parseRangeHeader("bytes=0-1,5-6", 1000) != nil {
|
||||
t.Fatal("多区间应返回 nil(回退全量)")
|
||||
}
|
||||
// 非法格式
|
||||
if parseRangeHeader("items=0-1", 1000) != nil {
|
||||
t.Fatal("非 bytes 单位应返回 nil")
|
||||
}
|
||||
if parseRangeHeader("bytes=abc-", 1000) != nil {
|
||||
t.Fatal("非法数字应返回 nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseISOTime 验证时间解析的多格式兼容。
|
||||
func TestParseISOTime(t *testing.T) {
|
||||
valid := []string{
|
||||
"2025-01-01T00:00:00Z",
|
||||
"2025-01-01T08:00:00+08:00",
|
||||
"2025-01-01 08:00:00",
|
||||
"2025-01-01",
|
||||
}
|
||||
for _, s := range valid {
|
||||
if _, err := parseISOTime(s); err != nil {
|
||||
t.Fatalf("%q 应解析成功: %v", s, err)
|
||||
}
|
||||
}
|
||||
if _, err := parseISOTime("not-a-time"); err == nil {
|
||||
t.Fatal("非法时间应报错")
|
||||
}
|
||||
if _, err := parseISOTime(""); err == nil {
|
||||
t.Fatal("空串应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatDurationCN 验证中文时长描述。
|
||||
func TestFormatDurationCN(t *testing.T) {
|
||||
cases := []struct {
|
||||
d time.Duration
|
||||
expect string
|
||||
}{
|
||||
{7 * 24 * time.Hour, "7天"},
|
||||
{90 * time.Minute, "1小时30分钟"},
|
||||
{45 * time.Second, "45秒"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := formatDurationCN(tc.d); got != tc.expect {
|
||||
t.Fatalf("formatDurationCN(%v)=%q,期望 %q", tc.d, got, tc.expect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileMagicValidation 验证 magic bytes 防伪造。
|
||||
func TestFileMagicValidation(t *testing.T) {
|
||||
cfg := newTestConfig(t)
|
||||
// 白名单 * 全放行
|
||||
if err := validateFileMagic(cfg, "a.txt", "", nil); err != nil {
|
||||
t.Fatalf("白名单 * 应放行: %v", err)
|
||||
}
|
||||
// PNG 内容 + .exe 扩展名 → 拒绝(伪造)
|
||||
if err := validateFileMagic(cfg, "evil.exe", "", []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}); err == nil {
|
||||
t.Fatal("PNG 内容伪装 exe 应拒绝")
|
||||
}
|
||||
// PNG 内容 + .png 扩展名 → 通过
|
||||
if err := validateFileMagic(cfg, "ok.png", "image/png", []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}); err != nil {
|
||||
t.Fatalf("真 PNG 应通过: %v", err)
|
||||
}
|
||||
// 文本内容 + .png 扩展名 → 拒绝
|
||||
if err := validateFileMagic(cfg, "fake.png", "", []byte("hello world, this is text")); err == nil {
|
||||
t.Fatal("文本伪装 png 应拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizePathBuild 验证存储路径构造不含穿越。
|
||||
func TestSanitizePathBuild(t *testing.T) {
|
||||
cfg := newTestConfig(t)
|
||||
_, _, _, clean, savePath := buildSavePath(cfg, "../../etc/passwd", "uuid-123")
|
||||
if clean != "etc_passwd" && clean != "passwd" {
|
||||
t.Logf("清理后的文件名: %q", clean)
|
||||
}
|
||||
if _, ok := storage.SanitizePath(savePath); !ok {
|
||||
t.Fatalf("构造的 savePath 应通过安全校验: %q", savePath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// policy.go — 26.9 上传策略统一读取与校验(需求 ④⑩)。
|
||||
//
|
||||
// 管理端在后台设置页修改策略(settings KV,t1 schema)后,上传链路
|
||||
// (share/file、chunk、presign)每次请求实时读取当前策略并动态校验:
|
||||
// - 大小上限:max_file_size(0=回落 uploadSize,语义见 config.MaxFileSize);
|
||||
// - 类型白名单:allowed_file_types("*" 不限制),由 validateFileMagic 统一执行;
|
||||
// - 保存策略:expire_style 白名单 + max_save_seconds 时间上限 + max_save_count
|
||||
// 次数上限,统一在 resolveExpire(helpers.go)执行。
|
||||
//
|
||||
// 超限返回 403(超出策略限制)/400(参数非法),错误信息为中文。
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// UploadPolicy 当前生效的上传策略快照(每次上传请求实时读取,管理端改动立即生效)。
|
||||
type UploadPolicy struct {
|
||||
MaxFileSize int64 // 单文件大小上限(字节),0=不限制
|
||||
AllowedTypes []string // 类型白名单,"*" 不限制
|
||||
ExpireStyles []string // 允许的过期方式白名单
|
||||
MaxSaveSeconds int64 // 最长保存秒数,0=不限制(默认 7 天兜底)
|
||||
MaxSaveCount int // 单次分享最大可取次数上限,0=不限制
|
||||
}
|
||||
|
||||
// CurrentUploadPolicy 读取当前上传策略快照。
|
||||
// 上传页亦通过 GET /api/v1/config 的 policy 字段读取同一组值做动态渲染。
|
||||
func (d *Deps) CurrentUploadPolicy() UploadPolicy {
|
||||
cfg := d.Cfg
|
||||
return UploadPolicy{
|
||||
MaxFileSize: cfg.MaxFileSize(),
|
||||
AllowedTypes: cfg.AllowedFileTypes(),
|
||||
ExpireStyles: cfg.ExpireStyle(),
|
||||
MaxSaveSeconds: cfg.MaxSaveSeconds(),
|
||||
MaxSaveCount: cfg.MaxSaveCount(),
|
||||
}
|
||||
}
|
||||
|
||||
// CheckSize 校验单文件大小是否超出策略上限(超出返回 403,文案对齐参考实现)。
|
||||
func (p UploadPolicy) CheckSize(size int64) error {
|
||||
if p.MaxFileSize > 0 && size > p.MaxFileSize {
|
||||
return errForbidden(fmt.Sprintf("大小超过限制,最大为%s", humanSize(p.MaxFileSize)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"fileshare/internal/audit"
|
||||
"fileshare/internal/cache"
|
||||
"fileshare/internal/config"
|
||||
"fileshare/internal/database"
|
||||
"fileshare/internal/middleware"
|
||||
"fileshare/internal/settings"
|
||||
"fileshare/internal/storage"
|
||||
)
|
||||
|
||||
// ============ 测试环境装配(真实 sqlite + 内存缓存 + 本地存储)============
|
||||
|
||||
// newPolicyTestDeps 构造带真实依赖的 Deps:sqlite 文件库(t.TempDir)、
|
||||
// 本地存储引擎、内存缓存限流器与审计服务(需求 ⑧ 默认形态)。
|
||||
func newPolicyTestDeps(t *testing.T) *Deps {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("FCB_DB_DRIVER", "sqlite")
|
||||
t.Setenv("FCB_DB_DSN", filepath.Join(dir, "test.db"))
|
||||
cfg, err := config.New()
|
||||
if err != nil {
|
||||
t.Fatalf("config.New: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := database.Open(ctx, database.Options{Driver: config.DBDriverSQLite, DSN: filepath.Join(dir, "test.db")})
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close(db) })
|
||||
if err := database.Migrate(ctx, db); err != nil {
|
||||
t.Fatalf("database.Migrate: %v", err)
|
||||
}
|
||||
mgr, err := settings.NewManager(ctx, db, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("settings.NewManager: %v", err)
|
||||
}
|
||||
store, err := storage.NewLocalStorage(filepath.Join(dir, "storage"))
|
||||
if err != nil {
|
||||
t.Fatalf("storage.NewLocalStorage: %v", err)
|
||||
}
|
||||
// 26.9:包装为 Manager(build 直接返回 local 实例,测试无需真实多引擎)
|
||||
storeMgr := storage.NewManager("local", store, func(string) (storage.Storage, error) {
|
||||
return storage.NewLocalStorage(filepath.Join(dir, "storage"))
|
||||
})
|
||||
return &Deps{
|
||||
DB: db,
|
||||
Cfg: cfg,
|
||||
Mgr: mgr,
|
||||
AuditSvc: audit.NewService(audit.NewDBSink(db)),
|
||||
Limiter: middleware.NewRateLimiter(cache.NewMemory(), nil),
|
||||
Store: storeMgr,
|
||||
Version: "test",
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 请求构造辅助 ============
|
||||
|
||||
// invoke 以给定请求调用 handler 并返回响应。
|
||||
func invoke(handler gin.HandlerFunc, req *http.Request) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
handler(c)
|
||||
return w
|
||||
}
|
||||
|
||||
// patchConfig 以 JSON 调用 PATCH /admin/config/update。
|
||||
func patchConfig(d *Deps, patch map[string]any) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(patch)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/admin/config/update", bytes.NewReader(raw))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return invoke(d.adminConfigUpdate, req)
|
||||
}
|
||||
|
||||
// getConfig 调用 GET /admin/config/get。
|
||||
func getConfig(d *Deps) *httptest.ResponseRecorder {
|
||||
return invoke(d.adminConfigGet, httptest.NewRequest(http.MethodGet, "/admin/config/get", nil))
|
||||
}
|
||||
|
||||
// getPublicConfig 调用 GET /api/v1/config。
|
||||
func getPublicConfig(d *Deps) *httptest.ResponseRecorder {
|
||||
return invoke(d.publicConfig, httptest.NewRequest(http.MethodGet, "/api/v1/config", nil))
|
||||
}
|
||||
|
||||
// uploadFile 以 multipart 表单调用 POST /share/file。
|
||||
func uploadFile(d *Deps, name string, content []byte, fields map[string]string) *httptest.ResponseRecorder {
|
||||
body := &bytes.Buffer{}
|
||||
mw := multipart.NewWriter(body)
|
||||
fw, err := mw.CreateFormFile("file", name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_, _ = fw.Write(content)
|
||||
for k, v := range fields {
|
||||
_ = mw.WriteField(k, v)
|
||||
}
|
||||
_ = mw.Close()
|
||||
req := httptest.NewRequest(http.MethodPost, "/share/file", body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
return invoke(d.shareFile, req)
|
||||
}
|
||||
|
||||
// chunkInitJSON 以 JSON 调用 POST /chunk/upload/init。
|
||||
func chunkInitJSON(d *Deps, payload string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/chunk/upload/init", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return invoke(d.chunkInit, req)
|
||||
}
|
||||
|
||||
// respBody 解析统一响应体。
|
||||
func respBody(t *testing.T, w *httptest.ResponseRecorder) (code int, data map[string]any) {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("响应解析失败: %v; body=%s", err, w.Body.String())
|
||||
}
|
||||
return body.Code, body.Data
|
||||
}
|
||||
|
||||
// pngMagic 最小合法 PNG 头(magic 校验可识别)。
|
||||
var pngMagic = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
|
||||
|
||||
// ============ ① 公开 config:26.9 展示与策略字段下发 ============
|
||||
|
||||
// TestPublicConfigV2Fields 验证 /api/v1/config 下发背景/页脚/备案/通知与策略范围,
|
||||
// 且响应不包含任何敏感键(admin_token/jwt_secret)。
|
||||
func TestPublicConfigV2Fields(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
// 管理端先设置 26.9 展示字段
|
||||
if w := patchConfig(d, map[string]any{
|
||||
"background_url": "https://cdn.example.com/bg.png",
|
||||
"footer_text": "自定义页脚内容",
|
||||
"footer_beian": "京ICP备2024xxxxxx号-1",
|
||||
"notify_enabled": 0,
|
||||
"max_save_count": 5,
|
||||
}); w.Code != 200 {
|
||||
t.Fatalf("patchConfig 失败: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
w := getPublicConfig(d)
|
||||
code, data := respBody(t, w)
|
||||
if code != 200 {
|
||||
t.Fatalf("publicConfig code=%d", code)
|
||||
}
|
||||
cfgMap, _ := data["config"].(map[string]any)
|
||||
if cfgMap == nil {
|
||||
t.Fatal("响应缺少 config 对象")
|
||||
}
|
||||
for key, want := range map[string]any{
|
||||
"background_url": "https://cdn.example.com/bg.png",
|
||||
"footer_text": "自定义页脚内容",
|
||||
"footer_beian": "京ICP备2024xxxxxx号-1",
|
||||
"notify_enabled": float64(0),
|
||||
"notify_title": "系统通知",
|
||||
} {
|
||||
if got := cfgMap[key]; got != want {
|
||||
t.Fatalf("config.%s = %v, 期望 %v", key, got, want)
|
||||
}
|
||||
}
|
||||
// 策略范围
|
||||
if _, ok := cfgMap["max_file_size"]; !ok {
|
||||
t.Fatal("config 缺少 max_file_size(存储策略)")
|
||||
}
|
||||
if _, ok := cfgMap["max_save_seconds"]; !ok {
|
||||
t.Fatal("config 缺少 max_save_seconds(保存时间策略)")
|
||||
}
|
||||
if got := cfgMap["max_save_count"]; got != float64(5) {
|
||||
t.Fatalf("config.max_save_count = %v, 期望 5", got)
|
||||
}
|
||||
if _, ok := cfgMap["allowedFileTypes"]; !ok {
|
||||
t.Fatal("config 缺少 allowedFileTypes")
|
||||
}
|
||||
if _, ok := cfgMap["expireStyle"]; !ok {
|
||||
t.Fatal("config 缺少 expireStyle")
|
||||
}
|
||||
if _, ok := cfgMap["uploadSize"]; !ok {
|
||||
t.Fatal("config 缺少 uploadSize")
|
||||
}
|
||||
// 敏感键绝不下发
|
||||
raw := w.Body.String()
|
||||
if strings.Contains(raw, "admin_token") || strings.Contains(raw, "jwt_secret") {
|
||||
t.Fatal("公开 config 响应包含敏感键")
|
||||
}
|
||||
}
|
||||
|
||||
// ============ ② 管理端 get/update:26.9 键全链路 + 类型范围校验 ============
|
||||
|
||||
// TestAdminConfigV2RoundTrip 验证 26.9 新键 update → get → public 的往返,
|
||||
// 且 admin_token 屏蔽、jwt_secret 不下发。
|
||||
func TestAdminConfigV2RoundTrip(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
patch := map[string]any{
|
||||
"background_url": "https://cdn.example.com/bg.png",
|
||||
"footer_text": "页脚 HTML 片段",
|
||||
"footer_beian": "京ICP备20240001号",
|
||||
"notify_enabled": 0,
|
||||
"max_save_count": 20,
|
||||
"max_file_size": 5242880,
|
||||
"max_save_seconds": 86400,
|
||||
}
|
||||
w := patchConfig(d, patch)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("update 失败: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// admin config get:新键可见 + 敏感键屏蔽
|
||||
w = getConfig(d)
|
||||
code, data := respBody(t, w)
|
||||
if code != 200 {
|
||||
t.Fatalf("get code=%d", code)
|
||||
}
|
||||
for key, want := range map[string]any{
|
||||
"background_url": "https://cdn.example.com/bg.png",
|
||||
"footer_text": "页脚 HTML 片段",
|
||||
"footer_beian": "京ICP备20240001号",
|
||||
"notify_enabled": float64(0),
|
||||
"max_save_count": float64(20),
|
||||
"max_file_size": float64(5242880),
|
||||
"max_save_seconds": float64(86400),
|
||||
} {
|
||||
if got := data[key]; got != want {
|
||||
t.Fatalf("admin get %s = %v, 期望 %v", key, got, want)
|
||||
}
|
||||
}
|
||||
// v1 既有设计:admin_token 不在 configKeys 白名单(响应中不存在即屏蔽);
|
||||
// 兼容两种形态:键缺失或空串均算通过
|
||||
if got, present := data["admin_token"]; present && got != "" {
|
||||
t.Fatalf("admin_token 应屏蔽(缺失或空串),实际 %v", got)
|
||||
}
|
||||
rawGet := w.Body.String()
|
||||
if strings.Contains(rawGet, `"jwt_secret"`) {
|
||||
t.Fatal("admin get 不应下发 jwt_secret")
|
||||
}
|
||||
|
||||
// public config 立即反映(改策略 → 公开 config 即时更新)
|
||||
w = getPublicConfig(d)
|
||||
_, data = respBody(t, w)
|
||||
cfgMap := data["config"].(map[string]any)
|
||||
if got := cfgMap["max_file_size"]; got != float64(5242880) {
|
||||
t.Fatalf("public max_file_size = %v, 期望 5242880", got)
|
||||
}
|
||||
if got := cfgMap["footer_beian"]; got != "京ICP备20240001号" {
|
||||
t.Fatalf("public footer_beian = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminConfigV2Validation 验证新键的类型与范围校验(400 + 中文错误)。
|
||||
func TestAdminConfigV2Validation(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
patch map[string]any
|
||||
}{
|
||||
{"max_file_size 负数", map[string]any{"max_file_size": -1}},
|
||||
{"max_file_size 超上限", map[string]any{"max_file_size": config.MaxFileSizeMax + 1}},
|
||||
{"max_save_count 超上限", map[string]any{"max_save_count": config.MaxSaveCountMax + 1}},
|
||||
{"notify_enabled 越界", map[string]any{"notify_enabled": 2}},
|
||||
{"footer_beian 超长", map[string]any{"footer_beian": strings.Repeat("备", config.FooterBeianMaxLen+1)}},
|
||||
{"footer_text 超长", map[string]any{"footer_text": strings.Repeat("页", config.FooterTextMaxLen+1)}},
|
||||
{"background_url 非法协议", map[string]any{"background_url": "javascript:alert(1)"}},
|
||||
{"max_save_seconds 超上限", map[string]any{"max_save_seconds": config.MaxSaveSecondsMax + 1}},
|
||||
{"allowed_file_types 类型错误", map[string]any{"allowed_file_types": 123}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := patchConfig(d, tc.patch)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("应 400,实际 %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
code, _ := respBody(t, w)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("响应 code 应为 400,实际 %d", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
// 合法值不受影响
|
||||
if w := patchConfig(d, map[string]any{
|
||||
"max_save_count": 0, // 0 = 不限制
|
||||
"notify_enabled": 1,
|
||||
"background_url": "data:image/png;base64,AAA",
|
||||
"max_file_size": 1024,
|
||||
"max_save_seconds": 0,
|
||||
}); w.Code != 200 {
|
||||
t.Fatalf("合法 patch 应 200: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ============ ③ 上传动态校验:admin 改策略 → 上传行为即时变化 ============
|
||||
|
||||
// TestUploadPolicyDynamicEnforcement 全链路:默认可传 → 改 max_file_size/白名单/
|
||||
// 保存策略后 → 公开 config 反映 → 上传被新策略拒绝(403/400)。
|
||||
func TestUploadPolicyDynamicEnforcement(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
// 默认策略:小 PNG 上传成功
|
||||
w := uploadFile(d, "ok.png", pngMagic, map[string]string{"expire_value": "1", "expire_style": "day"})
|
||||
if code, _ := respBody(t, w); code != 200 {
|
||||
t.Fatalf("默认策略上传应成功: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// —— 大小上限:max_file_size=100 → 200B 文件 403 ——
|
||||
if w = patchConfig(d, map[string]any{"max_file_size": 100}); w.Code != 200 {
|
||||
t.Fatalf("patch max_file_size: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
w = getPublicConfig(d)
|
||||
_, data := respBody(t, w)
|
||||
if got := data["config"].(map[string]any)["max_file_size"]; got != float64(100) {
|
||||
t.Fatalf("公开 config 未即时反映 max_file_size=100: %v", got)
|
||||
}
|
||||
w = uploadFile(d, "big.png", append(pngMagic, bytes.Repeat([]byte{0}, 200)...),
|
||||
map[string]string{"expire_value": "1", "expire_style": "day"})
|
||||
code, _ := respBody(t, w)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("超限上传应 403: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// —— 类型白名单:allowed_file_types=[.png] → .txt 403 ——
|
||||
if w = patchConfig(d, map[string]any{"allowed_file_types": []string{".png"}}); w.Code != 200 {
|
||||
t.Fatalf("patch allowed_file_types: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
w = uploadFile(d, "note.txt", []byte("hello"), map[string]string{"expire_value": "1", "expire_style": "day"})
|
||||
if code, _ := respBody(t, w); code != http.StatusForbidden {
|
||||
t.Fatalf("非白名单类型应 403: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// —— 保存时间:max_save_seconds=3600 → expire 1 天 403 ——
|
||||
if w = patchConfig(d, map[string]any{"max_save_seconds": 3600}); w.Code != 200 {
|
||||
t.Fatalf("patch max_save_seconds: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
w = uploadFile(d, "timed.png", pngMagic, map[string]string{"expire_value": "1", "expire_style": "day"})
|
||||
if code, _ := respBody(t, w); code != http.StatusForbidden {
|
||||
t.Fatalf("保存时间超范围应 403: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// —— 保存次数:max_save_count=5 → count=10 403(重置时间策略避免交叉影响)——
|
||||
if w = patchConfig(d, map[string]any{"max_save_count": 5, "max_save_seconds": 0}); w.Code != 200 {
|
||||
t.Fatalf("patch max_save_count: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
w = uploadFile(d, "counted.png", pngMagic, map[string]string{"expire_value": "10", "expire_style": "count"})
|
||||
if code, _ := respBody(t, w); code != http.StatusForbidden {
|
||||
t.Fatalf("保存次数超上限应 403: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// 次数在上限内合法
|
||||
w = uploadFile(d, "counted.png", pngMagic, map[string]string{"expire_value": "3", "expire_style": "count"})
|
||||
if code, _ := respBody(t, w); code != 200 {
|
||||
t.Fatalf("次数在上限内应成功: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// —— 过期方式白名单收窄:expireStyle=[day] → hour 400 ——
|
||||
if w = patchConfig(d, map[string]any{"expireStyle": []string{"day"}}); w.Code != 200 {
|
||||
t.Fatalf("patch expireStyle: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
w = uploadFile(d, "hour.png", pngMagic, map[string]string{"expire_value": "2", "expire_style": "hour"})
|
||||
if code, _ := respBody(t, w); code != http.StatusBadRequest {
|
||||
t.Fatalf("非白名单 expire_style 应 400: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// 恢复后可用(说明策略动态读取)
|
||||
if w = patchConfig(d, map[string]any{"expireStyle": []string{"day", "hour", "minute", "forever", "count"}}); w.Code != 200 {
|
||||
t.Fatal("恢复 expireStyle 失败")
|
||||
}
|
||||
w = uploadFile(d, "hour.png", pngMagic, map[string]string{"expire_value": "2", "expire_style": "hour"})
|
||||
if code, _ := respBody(t, w); code != 200 {
|
||||
t.Fatalf("白名单恢复后应成功: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestChunkUploadPolicyEnforcement 验证分片上传链路接入动态策略。
|
||||
func TestChunkUploadPolicyEnforcement(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
// L4:后端强制 enableChunk 开关,本测试前置开启
|
||||
if w := patchConfig(d, map[string]any{"enableChunk": 1}); w.Code != 200 {
|
||||
t.Fatalf("patch enableChunk: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// 大小:max_file_size=1000 → file_size 5000 拒绝
|
||||
if w := patchConfig(d, map[string]any{"max_file_size": 1000}); w.Code != 200 {
|
||||
t.Fatalf("patch max_file_size: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
w := chunkInitJSON(d, `{"file_name":"a.png","file_size":5000,"chunk_size":1024,"file_hash":"h"}`)
|
||||
if code, _ := respBody(t, w); code != http.StatusForbidden {
|
||||
t.Fatalf("分片总大小超限应 403: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// 类型:allowed_file_types=[.png] → b.txt 拒绝(max_file_size 重置为回落,隔离类型断言)
|
||||
if w := patchConfig(d, map[string]any{"allowed_file_types": []string{".png"}, "max_file_size": 0}); w.Code != 200 {
|
||||
t.Fatalf("patch allowed_file_types: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
w = chunkInitJSON(d, `{"file_name":"b.txt","file_size":500,"chunk_size":1024,"file_hash":"h"}`)
|
||||
if code, _ := respBody(t, w); code != http.StatusForbidden {
|
||||
t.Fatalf("分片文件类型非白名单应 403: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// 白名单内 + 大小内 → 会话创建成功
|
||||
w = chunkInitJSON(d, `{"file_name":"c.png","file_size":500,"chunk_size":1024,"file_hash":"h"}`)
|
||||
if code, _ := respBody(t, w); code != 200 {
|
||||
t.Fatalf("合法分片初始化应 200: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPresignPolicyEnforcement 验证预签名直传链路接入动态大小策略。
|
||||
func TestPresignPolicyEnforcement(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
if w := patchConfig(d, map[string]any{"max_file_size": 1000}); w.Code != 200 {
|
||||
t.Fatalf("patch max_file_size: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
payload := `{"file_name":"a.png","file_size":5000,"expire_value":1,"expire_style":"day"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/presign/upload/init", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := invoke(d.presignInit, req)
|
||||
if code, _ := respBody(t, w); code != http.StatusForbidden {
|
||||
t.Fatalf("预签名直传超限应 403: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSensitiveKeysNeverInPublicConfig 额外兜底:公开 config 任意策略下都无敏感键。
|
||||
func TestSensitiveKeysNeverInPublicConfig(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
// 写入敏感 KV(模拟已初始化实例),公开端点依旧不能带出
|
||||
ctx := context.Background()
|
||||
if err := d.Mgr.UpdateKV(ctx, map[string]any{
|
||||
"jwt_secret": "super-secret-value",
|
||||
"admin_token": settings.HashPassword("password-123456"),
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateKV: %v", err)
|
||||
}
|
||||
if err := d.Mgr.Reload(ctx); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
raw := getPublicConfig(d).Body.String()
|
||||
if strings.Contains(raw, "super-secret-value") || strings.Contains(raw, "jwt_secret") {
|
||||
t.Fatal("公开 config 泄露 jwt_secret")
|
||||
}
|
||||
if strings.Contains(raw, "admin_token") {
|
||||
t.Fatal("公开 config 泄露 admin_token")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolicySnapshotMatchesConfig 验证策略快照与 config 一致(单一读取口径)。
|
||||
func TestPolicySnapshotMatchesConfig(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
if w := patchConfig(d, map[string]any{"max_file_size": 2048, "max_save_count": 9, "max_save_seconds": 7200}); w.Code != 200 {
|
||||
t.Fatal("patch 失败")
|
||||
}
|
||||
pol := d.CurrentUploadPolicy()
|
||||
if pol.MaxFileSize != 2048 || pol.MaxSaveCount != 9 || pol.MaxSaveSeconds != 7200 {
|
||||
t.Fatalf("策略快照不一致: %+v", pol)
|
||||
}
|
||||
if err := pol.CheckSize(2048); err != nil {
|
||||
t.Fatalf("边界值应放行: %v", err)
|
||||
}
|
||||
if err := pol.CheckSize(2049); err == nil {
|
||||
t.Fatal("超限应拒绝")
|
||||
}
|
||||
// 0=回落 uploadSize
|
||||
if w := patchConfig(d, map[string]any{"max_file_size": 0}); w.Code != 200 {
|
||||
t.Fatal("patch 失败")
|
||||
}
|
||||
if got := d.CurrentUploadPolicy().MaxFileSize; got != d.Cfg.UploadSize() {
|
||||
t.Fatalf("max_file_size=0 应回落 uploadSize: %d vs %d", got, d.Cfg.UploadSize())
|
||||
}
|
||||
}
|
||||
|
||||
// 编译期保证 fmt 被使用(测试辅助函数中错误路径占位)。
|
||||
var _ = fmt.Sprintf
|
||||
|
||||
// ============ 26.9 存储引擎热切换 ============
|
||||
|
||||
// switchEngine 调用 POST /admin/storage/switch。
|
||||
func switchEngine(d *Deps, engine string) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(map[string]any{"engine": engine})
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/storage/switch", bytes.NewReader(raw))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return invoke(d.adminStorageSwitch, req)
|
||||
}
|
||||
|
||||
// TestAdminStorageSwitchLocal 本地引擎切换(测试 build 只产 local,切 local 恒成功)。
|
||||
func TestAdminStorageSwitchLocal(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
w := switchEngine(d, "local")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("switch local 失败: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// KV 持久化:admin get 可见
|
||||
if got := getConfig(d); got.Code != 200 {
|
||||
t.Fatal("get 失败")
|
||||
}
|
||||
// 非法引擎名 400
|
||||
if w := switchEngine(d, "ftp"); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("非法引擎应 400,实际 %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminConfigEngineSwitchFailure 测试环境下切换到不可用引擎保持原引擎(503)。
|
||||
// 测试 Manager 的 build 返回 local;这里通过直接操作 Manager 验证 503 路径的响应格式。
|
||||
func TestAdminConfigEngineSwitchFailure(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
// 用一个恒失败的 Manager 替换(模拟 s3/webdav 健康检查不过)
|
||||
d.Store = storage.NewManager("local", mustLocal(t), func(string) (storage.Storage, error) {
|
||||
return nil, errors.New("连接失败")
|
||||
})
|
||||
w := switchEngine(d, "s3")
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("不可用引擎应 503,实际 %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "已保持原引擎") {
|
||||
t.Fatal("错误信息应包含「已保持原引擎」")
|
||||
}
|
||||
// 失败后当前引擎不变
|
||||
if d.Store.CurrentName() != "local" {
|
||||
t.Fatalf("失败后应保持 local,实际 %s", d.Store.CurrentName())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminConfigMaskedSecrets 敏感引擎凭据:get 掩码、update 空/掩码不落库。
|
||||
func TestAdminConfigMaskedSecrets(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
// 先写入真实凭据
|
||||
if w := patchConfig(d, map[string]any{"webdav_password": "real-secret", "s3_secret_access_key": "sk-real"}); w.Code != 200 {
|
||||
t.Fatalf("写凭据失败: %s", w.Body.String())
|
||||
}
|
||||
// get 应为掩码
|
||||
_, data := respBody(t, getConfig(d))
|
||||
if got := data["webdav_password"]; got != settings.SensitiveMaskValue {
|
||||
t.Fatalf("webdav_password 应掩码,实际 %v", got)
|
||||
}
|
||||
if got := data["s3_secret_access_key"]; got != settings.SensitiveMaskValue {
|
||||
t.Fatalf("s3_secret_access_key 应掩码,实际 %v", got)
|
||||
}
|
||||
// 提交掩码(模拟前端回显原样提交)→ 不应覆盖为掩码串
|
||||
if w := patchConfig(d, map[string]any{"webdav_password": settings.SensitiveMaskValue}); w.Code != 200 {
|
||||
t.Fatalf("掩码提交应 200: %s", w.Body.String())
|
||||
}
|
||||
// 提交空串 → 不修改
|
||||
if w := patchConfig(d, map[string]any{"s3_secret_access_key": ""}); w.Code != 200 {
|
||||
t.Fatalf("空串提交应 200: %s", w.Body.String())
|
||||
}
|
||||
// 公开 config 绝不含引擎凭据
|
||||
_, pub := respBody(t, getPublicConfig(d))
|
||||
rawPub, _ := json.Marshal(pub)
|
||||
for _, sk := range []string{"webdav_password", "s3_secret_access_key", "aws_session_token", "jwt_secret"} {
|
||||
if strings.Contains(string(rawPub), sk) {
|
||||
t.Fatalf("公开 config 不应包含 %s", sk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustLocal(t *testing.T) storage.Storage {
|
||||
t.Helper()
|
||||
s, err := storage.NewLocalStorage(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"fileshare/internal/middleware"
|
||||
"fileshare/internal/model"
|
||||
"fileshare/internal/response"
|
||||
"fileshare/internal/storage"
|
||||
)
|
||||
|
||||
// presignSessionExpires 预签名会话有效期(对齐参考 PRESIGN_SESSION_EXPIRES=900 秒)。
|
||||
const presignSessionExpires = 900
|
||||
|
||||
// getValidSession 校验并返回预签名会话(对齐参考 _get_valid_session):
|
||||
// 不存在 404、已过期删除后 404、mode 不符 400。
|
||||
func (d *Deps) getValidSession(c *gin.Context, uploadID, expectedMode string) (*model.PresignUploadSession, error) {
|
||||
ctx := c.Request.Context()
|
||||
var session model.PresignUploadSession
|
||||
err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ?", uploadID).First(&session).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errNotFound("上传会话不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errInternal("查询上传会话失败: " + err.Error())
|
||||
}
|
||||
if session.IsExpired(time.Now()) {
|
||||
_ = d.DB.WithContext(ctx).Where("upload_id = ?", uploadID).
|
||||
Delete(&model.PresignUploadSession{}).Error
|
||||
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
||||
return nil, errNotFound("上传会话已过期")
|
||||
}
|
||||
if expectedMode != "" && session.Mode != expectedMode {
|
||||
return nil, errBadRequest("此会话不支持" + expectedMode + "模式")
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// ============ POST /presign/upload/init 初始化预签名上传 ============
|
||||
|
||||
// presignInitRequest init 请求体(对齐参考 PresignUploadInitRequest)。
|
||||
type presignInitRequest struct {
|
||||
FileName string `json:"file_name" form:"file_name"`
|
||||
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"` // 26.9:自定义提取码(init 时校验,完成时落库)
|
||||
}
|
||||
|
||||
// presignInit 初始化预签名上传(对齐参考 presign_upload_init):
|
||||
// 引擎支持直链(S3)返回 direct + 预签名 PUT URL;否则返回 proxy + 代理地址。
|
||||
func (d *Deps) presignInit(c *gin.Context) {
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
if !requireUploadLimit(c, d.Limiter) {
|
||||
return
|
||||
}
|
||||
var req presignInitRequest
|
||||
if err := bindJSONOrForm(c, &req); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
safeName := storage.SanitizeFileName(req.FileName)
|
||||
if safeName == "" {
|
||||
auditRecordFailed(c, d.AuditSvc, "文件名非法")
|
||||
response.Fail(c, http.StatusBadRequest, "文件名非法")
|
||||
return
|
||||
}
|
||||
// 26.9:自定义提取码提前校验(init 时快速失败;完成请求须再次携带)
|
||||
if err := validatePickupCode(req.Code); err != nil {
|
||||
auditRecordFailed(c, d.AuditSvc, "提取码非法")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
if err := validateFileMagic(d.Cfg, safeName, "", nil); err != nil {
|
||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件类型被拒绝")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// 26.9 需求 ④⑩:动态策略校验(max_file_size,0=回落 uploadSize)
|
||||
if err := d.CurrentUploadPolicy().CheckSize(req.FileSize); err != nil {
|
||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件大小超过限制")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// M2:直传 confirm 的实际大小校验依赖真实对象,0/负值声明直接拒绝
|
||||
if req.FileSize <= 0 {
|
||||
auditRecordFailed(c, d.AuditSvc, "file_size 非法")
|
||||
response.Fail(c, http.StatusBadRequest, "file_size 必须大于 0")
|
||||
return
|
||||
}
|
||||
if req.ExpireValue <= 0 {
|
||||
req.ExpireValue = 1
|
||||
}
|
||||
if req.ExpireStyle == "" {
|
||||
req.ExpireStyle = "day"
|
||||
}
|
||||
if _, err := resolveExpire(d.Cfg, req.ExpireValue, req.ExpireStyle); err != nil {
|
||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "过期策略非法")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
uploadID := uuidHex()
|
||||
resToken := "presign:" + uploadID
|
||||
if err := reserveStorage(ctx, d.DB, d.Cfg, resToken, req.FileSize, presignSessionExpires*time.Second); err != nil {
|
||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "容量预留失败")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
dirPath, _, _, _, savePath := buildSavePath(d.Cfg, safeName, uploadID)
|
||||
mode := "proxy"
|
||||
uploadURL := "/presign/upload/proxy/" + uploadID
|
||||
putURL, err := d.Store.PresignPutURL(ctx, savePath, presignSessionExpires)
|
||||
switch {
|
||||
case err == nil:
|
||||
mode = "direct"
|
||||
uploadURL = putURL
|
||||
case errors.Is(err, storage.ErrNotSupported):
|
||||
// 引擎不支持直传:代理模式
|
||||
default:
|
||||
releaseStorage(ctx, d.DB, resToken)
|
||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "生成预签名失败")
|
||||
respondError(c, mapStorageError(err))
|
||||
return
|
||||
}
|
||||
|
||||
session := model.PresignUploadSession{
|
||||
UploadID: uploadID,
|
||||
FileName: safeName,
|
||||
FileSize: req.FileSize,
|
||||
SavePath: savePath,
|
||||
Mode: mode,
|
||||
Engine: d.Store.CurrentName(), // 26.9:会话归属引擎
|
||||
ExpireValue: req.ExpireValue,
|
||||
ExpireStyle: req.ExpireStyle,
|
||||
ExpiresAt: time.Now().Add(presignSessionExpires * time.Second),
|
||||
}
|
||||
if err := d.DB.WithContext(ctx).Create(&session).Error; err != nil {
|
||||
releaseStorage(ctx, d.DB, resToken)
|
||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "会话创建失败")
|
||||
respondError(c, errInternal("创建上传会话失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
d.Limiter.Add(c, middleware.LimitUpload)
|
||||
auditUploadEntry(c, uploadID, safeName, req.FileSize, 0)
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
detail := gin.H{
|
||||
"upload_id": uploadID,
|
||||
"upload_url": uploadURL,
|
||||
"mode": mode,
|
||||
"expires_in": presignSessionExpires,
|
||||
"file_path": dirPath,
|
||||
}
|
||||
if mode == "proxy" {
|
||||
detail["proxy_upload_url"] = uploadURL
|
||||
detail["legacy_proxy_upload_url"] = "/api" + uploadURL
|
||||
}
|
||||
response.OK(c, detail)
|
||||
}
|
||||
|
||||
// ============ PUT /presign/upload/proxy/{uploadID} 代理上传 ============
|
||||
|
||||
// presignProxy 代理模式上传(对齐参考 presign_upload_proxy):
|
||||
// 服务器接收文件并转存到存储引擎,随后立即创建分享记录。
|
||||
func (d *Deps) presignProxy(c *gin.Context) {
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
if !requireUploadLimit(c, d.Limiter) {
|
||||
return
|
||||
}
|
||||
uploadID := c.Param("uploadID")
|
||||
session, err := d.getValidSession(c, uploadID, "proxy")
|
||||
if err != nil {
|
||||
auditUploadEntry(c, uploadID, "", 0, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, err.Error())
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// 26.9:自定义提取码随代理上传表单携带(init 时已预校验)
|
||||
if err := validatePickupCode(c.PostForm("code")); err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "提取码非法")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if err := reserveStorage(ctx, d.DB, d.Cfg, "presign:"+uploadID, session.FileSize, presignSessionExpires*time.Second); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "缺少 file 字段")
|
||||
response.Fail(c, http.StatusBadRequest, "缺少上传文件 file 字段")
|
||||
return
|
||||
}
|
||||
// 动态策略快照(与 share/chunk 上传路径一致,消除会话窗口内的策略滞后)
|
||||
maxSize := d.CurrentUploadPolicy().MaxFileSize
|
||||
if maxSize > 0 && fh.Size > maxSize {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "大小超过限制")
|
||||
response.Fail(c, http.StatusForbidden, fmt.Sprintf("大小超过限制,最大为%s", humanSize(maxSize)))
|
||||
return
|
||||
}
|
||||
// 文件大小与声明不符(±1KB 容差,对齐参考)
|
||||
if abs64(fh.Size-session.FileSize) > 1024 {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件大小与声明不符")
|
||||
response.Fail(c, http.StatusBadRequest, "文件大小与声明不符")
|
||||
return
|
||||
}
|
||||
f, err := fh.Open()
|
||||
if err == nil {
|
||||
defer func() { _ = f.Close() }()
|
||||
if err = validateFileMagic(d.Cfg, session.FileName, fh.Header.Get("Content-Type"), readMultipartHeader(f, 64)); err == nil {
|
||||
// 26.9:落盘走会话归属引擎
|
||||
var ps storage.Storage
|
||||
ps, sErr := d.storeFor(session.Engine)
|
||||
if sErr != nil {
|
||||
err = sErr
|
||||
} else if _, err = ps.SaveFile(ctx, f, session.SavePath); err != nil {
|
||||
// 落盘失败,err 交给统一错误处理
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件保存失败")
|
||||
if isStorageErr(err) {
|
||||
respondError(c, mapStorageError(err))
|
||||
} else {
|
||||
respondError(c, errInternal("文件保存失败: "+err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
code, err := d.createRecordFromSession(c, session, c.PostForm("code"))
|
||||
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
||||
if err != nil {
|
||||
if ps, sErr := d.storeFor(session.Engine); sErr == nil {
|
||||
_ = ps.DeleteFile(ctx, session.SavePath) // 26.9:清理走归属引擎
|
||||
}
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "创建分享失败")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
_ = d.DB.WithContext(ctx).Where("upload_id = ?", uploadID).
|
||||
Delete(&model.PresignUploadSession{}).Error
|
||||
d.Limiter.Add(c, middleware.LimitUpload)
|
||||
auditUploadEntry(c, code, session.FileName, session.FileSize, fh.Size)
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
response.OK(c, gin.H{"code": code, "name": session.FileName})
|
||||
}
|
||||
|
||||
// ============ POST /presign/upload/confirm/{uploadID} 直传确认 ============
|
||||
|
||||
// presignConfirm 直传确认(对齐参考 presign_upload_confirm):
|
||||
// 客户端完成 S3 直传后调用,校验文件已存在并创建分享记录。
|
||||
func (d *Deps) presignConfirm(c *gin.Context) {
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
if !requireUploadLimit(c, d.Limiter) {
|
||||
return
|
||||
}
|
||||
uploadID := c.Param("uploadID")
|
||||
session, err := d.getValidSession(c, uploadID, "direct")
|
||||
if err != nil {
|
||||
auditUploadEntry(c, uploadID, "", 0, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, err.Error())
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if err := reserveStorage(ctx, d.DB, d.Cfg, "presign:"+uploadID, session.FileSize, presignSessionExpires*time.Second); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 26.9:直传文件存在性按会话归属引擎检查(直传可能落在旧引擎)
|
||||
psCheck, sErr := d.storeFor(session.Engine)
|
||||
if sErr != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "存储引擎不可用: "+sErr.Error())
|
||||
respondError(c, mapStorageError(sErr))
|
||||
return
|
||||
}
|
||||
// 26.9:自定义提取码随确认请求携带(query 或 JSON/form body,均可选)
|
||||
customCode := c.Query("code")
|
||||
if customCode == "" && c.Request.Body != nil && c.Request.ContentLength != 0 {
|
||||
var fin struct {
|
||||
Code string `json:"code" form:"code"`
|
||||
}
|
||||
if err := bindJSONOrForm(c, &fin); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
customCode = fin.Code
|
||||
}
|
||||
|
||||
exists, err := psCheck.FileExists(ctx, session.SavePath)
|
||||
if err == nil && !exists {
|
||||
err = errNotFound("文件未上传或上传失败")
|
||||
}
|
||||
if err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件未上传或上传失败")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// M2 修复:直传内容不经过服务器,confirm 必须核实实际大小与内容类型。
|
||||
meta, head, hErr := psCheck.HeadMeta(ctx, session.SavePath, 64)
|
||||
if hErr != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件信息读取失败")
|
||||
respondError(c, mapStorageError(hErr))
|
||||
return
|
||||
}
|
||||
if meta == nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件未上传或上传失败")
|
||||
respondError(c, errNotFound("文件未上传或上传失败"))
|
||||
return
|
||||
}
|
||||
// 大小上限:实际大小超过策略上限 → 删除对象并 403(防绕过 max_file_size / storageLimit)
|
||||
maxSize := d.CurrentUploadPolicy().MaxFileSize
|
||||
if maxSize > 0 && meta.Size > maxSize {
|
||||
_ = psCheck.DeleteFile(ctx, session.SavePath)
|
||||
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "实际文件大小超过限制")
|
||||
respondError(c, errForbidden(fmt.Sprintf("大小超过限制,最大为%s", humanSize(maxSize))))
|
||||
return
|
||||
}
|
||||
// 大小与声明不符(±1KB 容差,对齐 proxy 模式):超差删除对象并 400
|
||||
if abs64(meta.Size-session.FileSize) > 1024 {
|
||||
_ = psCheck.DeleteFile(ctx, session.SavePath)
|
||||
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件大小与声明不符")
|
||||
respondError(c, errBadRequest("文件大小与声明不符"))
|
||||
return
|
||||
}
|
||||
// 内容类型防伪造(对齐 proxy 模式 magic bytes 校验)
|
||||
if err := validateFileMagic(d.Cfg, session.FileName, "", head); err != nil {
|
||||
_ = psCheck.DeleteFile(ctx, session.SavePath)
|
||||
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件内容校验失败")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
code, err := d.createRecordFromSession(c, session, customCode)
|
||||
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
||||
if err != nil {
|
||||
if ps, sErr := d.storeFor(session.Engine); sErr == nil {
|
||||
_ = ps.DeleteFile(ctx, session.SavePath) // 26.9:清理走归属引擎
|
||||
}
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "创建分享失败")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
_ = d.DB.WithContext(ctx).Where("upload_id = ?", uploadID).
|
||||
Delete(&model.PresignUploadSession{}).Error
|
||||
d.Limiter.Add(c, middleware.LimitUpload)
|
||||
auditUploadEntry(c, code, session.FileName, session.FileSize, session.FileSize)
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
response.OK(c, gin.H{"code": code, "name": session.FileName})
|
||||
}
|
||||
|
||||
// createRecordFromSession 依据预签名会话创建分享记录(对齐参考 create_file_record)。
|
||||
func (d *Deps) createRecordFromSession(c *gin.Context, session *model.PresignUploadSession, customCode string) (string, error) {
|
||||
exp, err := resolveExpire(d.Cfg, session.ExpireValue, session.ExpireStyle)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 26.9:完成请求的自定义提取码兜底校验(init 已验,防只发完成请求绕过)
|
||||
if err := validatePickupCode(customCode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
code, err := pickCustomCode(ctx, d.DB, d.Cfg, customCode)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir, name := splitDirBase(session.SavePath)
|
||||
ext := baseExt(name)
|
||||
fc := model.FileCodes{
|
||||
Code: code,
|
||||
Prefix: trimExt(name),
|
||||
Suffix: ext,
|
||||
UUIDFileName: &name,
|
||||
FilePath: &dir,
|
||||
Size: session.FileSize,
|
||||
ExpiredAt: exp.ExpiredAt,
|
||||
ExpiredCount: exp.ExpiredCount,
|
||||
UsedCount: exp.UsedCount,
|
||||
Engine: session.Engine, // 26.9:归属引擎戳
|
||||
}
|
||||
if err := d.DB.WithContext(ctx).Create(&fc).Error; err != nil {
|
||||
return "", mapCodeConflict(err) // 26.9:并发占用自定义码 → 友好 400
|
||||
}
|
||||
// 26.9:SHA512 内容去重(命中则复用旧对象并删除本次副本)
|
||||
if store, sErr := d.storeFor(session.Engine); sErr == nil {
|
||||
d.applyDedup(ctx, store, session.SavePath, &fc)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// ============ GET /presign/upload/status/{uploadID} ============
|
||||
|
||||
// presignStatus 查询预签名会话状态(对齐参考 presign_upload_status)。
|
||||
func (d *Deps) presignStatus(c *gin.Context) {
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
uploadID := c.Param("uploadID")
|
||||
ctx := c.Request.Context()
|
||||
var session model.PresignUploadSession
|
||||
if err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ?", uploadID).First(&session).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, "上传会话不存在")
|
||||
return
|
||||
}
|
||||
respondError(c, errInternal("查询上传会话失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"upload_id": session.UploadID,
|
||||
"file_name": session.FileName,
|
||||
"file_size": session.FileSize,
|
||||
"mode": session.Mode,
|
||||
"created_at": session.CreatedAt.Format(time.RFC3339),
|
||||
"expires_at": session.ExpiresAt.Format(time.RFC3339),
|
||||
"is_expired": session.IsExpired(time.Now()),
|
||||
})
|
||||
}
|
||||
|
||||
// ============ DELETE /presign/upload/{uploadID} 取消会话 ============
|
||||
|
||||
// presignCancel 取消预签名上传会话(对齐参考 presign_upload_cancel):
|
||||
// 直传模式尽力清理已直传的文件。
|
||||
func (d *Deps) presignCancel(c *gin.Context) {
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
uploadID := c.Param("uploadID")
|
||||
ctx := c.Request.Context()
|
||||
session, err := d.getValidSession(c, uploadID, "")
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
if session.Mode == "direct" {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := d.DB.WithContext(ctx).Where("upload_id = ?", uploadID).
|
||||
Delete(&model.PresignUploadSession{}).Error; err != nil {
|
||||
respondError(c, errInternal("取消上传会话失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
||||
response.OK(c, gin.H{"message": "上传会话已取消"})
|
||||
}
|
||||
|
||||
// ============ 杂项 ============
|
||||
|
||||
// abs64 绝对值。
|
||||
func abs64(n int64) int64 {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// isStorageErr 判断是否为存储层哨兵错误(含 %w 包装)。
|
||||
func isStorageErr(err error) bool {
|
||||
return err != nil && (errors.Is(err, storage.ErrNotFound) ||
|
||||
errors.Is(err, storage.ErrInvalidPath) ||
|
||||
errors.Is(err, storage.ErrUnavailable) ||
|
||||
errors.Is(err, storage.ErrNotSupported) ||
|
||||
errors.Is(err, storage.ErrRangeNotSatisfiable) ||
|
||||
errors.Is(err, storage.ErrHashMismatch))
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package api
|
||||
|
||||
// recycle_dedup_test.go — 26.9 回收与下载安全测试:
|
||||
// SHA512 去重(同内容单存储 + 引用计数删除)、过期回收(时间/次数/留存期)、
|
||||
// 防盗链中间件、S3 直链 302 重定向。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"fileshare/internal/janitor"
|
||||
"fileshare/internal/middleware"
|
||||
"fileshare/internal/model"
|
||||
"fileshare/internal/storage"
|
||||
)
|
||||
|
||||
// ============ 辅助 ============
|
||||
|
||||
// uploadOK 上传文件并断言 200,返回取件码(复用 policy_test 的 uploadFile/respBody)。
|
||||
func uploadOK(t *testing.T, d *Deps, name string, content []byte) string {
|
||||
t.Helper()
|
||||
w := uploadFile(d, name, content, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("上传失败: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
_, data := respBody(t, w)
|
||||
code, _ := data["code"].(string)
|
||||
if code == "" {
|
||||
t.Fatalf("响应缺少 code: %s", w.Body.String())
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// fileByID 按 code 查记录。
|
||||
func fileByCode(t *testing.T, d *Deps, code string) model.FileCodes {
|
||||
t.Helper()
|
||||
var fc model.FileCodes
|
||||
if err := d.DB.Where("code = ?", code).First(&fc).Error; err != nil {
|
||||
t.Fatalf("查询分享 %s: %v", code, err)
|
||||
}
|
||||
return fc
|
||||
}
|
||||
|
||||
// objectExists 检查本地引擎对象是否存在。
|
||||
func objectExists(t *testing.T, d *Deps, fc model.FileCodes) bool {
|
||||
t.Helper()
|
||||
store, err := d.storeFor(fc.Engine)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ok, err := store.FileExists(context.Background(), fc.SavePath())
|
||||
if err != nil {
|
||||
t.Fatalf("FileExists: %v", err)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// ============ SHA512 去重 ============
|
||||
|
||||
// TestDedupSameContentSingleObject 同内容上传两次 → 单存储对象 + 记录互引 +
|
||||
// 删除其一对象保留,删除最后一条对象才删除。
|
||||
func TestDedupSameContentSingleObject(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
content := []byte("dedup-me-26.9-同一个内容")
|
||||
|
||||
code1 := uploadOK(t, d, "a.txt", content)
|
||||
code2 := uploadOK(t, d, "b.txt", content)
|
||||
if code1 == code2 {
|
||||
t.Fatal("两次上传应生成不同取件码")
|
||||
}
|
||||
fc1, fc2 := fileByCode(t, d, code1), fileByCode(t, d, code2)
|
||||
|
||||
if fc1.ContentHash == nil || *fc1.ContentHash == "" {
|
||||
t.Fatal("第一条记录未回填 content_hash")
|
||||
}
|
||||
if fc1.ContentHash == nil || fc2.ContentHash == nil || *fc1.ContentHash != *fc2.ContentHash {
|
||||
t.Fatalf("两条记录哈希应一致: %v vs %v", fc1.ContentHash, fc2.ContentHash)
|
||||
}
|
||||
if fc1.UUIDFileName == nil || fc2.UUIDFileName == nil || *fc1.UUIDFileName != *fc2.UUIDFileName {
|
||||
t.Fatalf("去重应复用同一 UUID 文件名: %v vs %v", fc1.UUIDFileName, fc2.UUIDFileName)
|
||||
}
|
||||
if fc1.SavePath() != fc2.SavePath() {
|
||||
t.Fatal("去重应指向同一存储路径")
|
||||
}
|
||||
// 去重后对象应存在
|
||||
if !objectExists(t, d, fc1) {
|
||||
t.Fatal("去重后对象应存在")
|
||||
}
|
||||
// 删除其一:对象保留(另一条仍引用);删除第二条:对象随之删除
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodDelete, "/admin/file/delete", nil)
|
||||
if err := d.deleteFileCode(c, &fc1); err != nil {
|
||||
t.Fatalf("删除第一条: %v", err)
|
||||
}
|
||||
if !objectExists(t, d, fc2) {
|
||||
t.Fatal("仍有引用时对象不应被删除")
|
||||
}
|
||||
if err := d.deleteFileCode(c, &fc2); err != nil {
|
||||
t.Fatalf("删除第二条: %v", err)
|
||||
}
|
||||
store, _ := d.storeFor("local")
|
||||
if ok, _ := store.FileExists(context.Background(), fc2.SavePath()); ok {
|
||||
t.Fatal("最后一个引用删除后对象应被删除")
|
||||
}
|
||||
var cnt int64
|
||||
d.DB.Model(&model.FileCodes{}).Count(&cnt)
|
||||
if cnt != 0 {
|
||||
t.Fatalf("记录应全部删除,剩余 %d", cnt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupDisabled 不去重:两记录各自独立对象。
|
||||
func TestDedupDisabled(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
setKV(t, d, "dedup_enabled", "0")
|
||||
content := []byte("no-dedup-content")
|
||||
c1 := uploadOK(t, d, "x.txt", content)
|
||||
c2 := uploadOK(t, d, "y.txt", content)
|
||||
fc1, fc2 := fileByCode(t, d, c1), fileByCode(t, d, c2)
|
||||
if fc1.ContentHash != nil && *fc1.ContentHash != "" {
|
||||
t.Fatal("去重关闭时不应回填 content_hash")
|
||||
}
|
||||
if fc1.SavePath() == fc2.SavePath() {
|
||||
t.Fatal("去重关闭时不应共享路径")
|
||||
}
|
||||
}
|
||||
|
||||
// setKV 直写 KV 并应用到内存 Config(对齐生产链路:UpdateKV 落库 + ApplyKV 生效)。
|
||||
func setKV(t *testing.T, d *Deps, key, value string) {
|
||||
t.Helper()
|
||||
if err := d.Mgr.UpdateKV(context.Background(), map[string]any{key: value}); err != nil {
|
||||
t.Fatalf("setKV %s: %v", key, err)
|
||||
}
|
||||
d.Cfg.ApplyKV(map[string]any{key: value})
|
||||
}
|
||||
|
||||
// ============ 过期回收 ============
|
||||
|
||||
// makeFileRecord 直插一条文件分享记录(可指定过期形态)。
|
||||
func makeFileRecord(t *testing.T, d *Deps, code string, expiredAt *time.Time, expiredCount int, createdAt time.Time) model.FileCodes {
|
||||
t.Helper()
|
||||
name := "obj-" + code + ".bin"
|
||||
dir := "share/data/test"
|
||||
store, _ := d.storeFor("local")
|
||||
if _, err := store.SaveFile(context.Background(), bytes.NewReader([]byte("recycle-body")), dir+"/"+name); err != nil {
|
||||
t.Fatalf("写入测试对象: %v", err)
|
||||
}
|
||||
fc := model.FileCodes{
|
||||
Code: code, Prefix: "obj-" + code, Suffix: ".bin",
|
||||
UUIDFileName: &name, FilePath: &dir, Size: 12,
|
||||
ExpiredAt: expiredAt, ExpiredCount: expiredCount,
|
||||
Engine: "local",
|
||||
}
|
||||
if err := d.DB.Create(&fc).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 校正 created_at(GORM 自动填 now)
|
||||
if err := d.DB.Model(&model.FileCodes{}).Where("id = ?", fc.ID).Update("created_at", createdAt).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fc.CreatedAt = createdAt
|
||||
return fc
|
||||
}
|
||||
|
||||
// TestRecycleExpiredTimeAndCount 时间过期与次数耗尽都被回收。
|
||||
func TestRecycleExpiredTimeAndCount(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
past := time.Now().Add(-time.Hour)
|
||||
r1 := makeFileRecord(t, d, "RECYA", &past, -1, time.Now().Add(-2*time.Hour)) // 时间过期
|
||||
r2 := makeFileRecord(t, d, "RECYB", &past, 0, time.Now().Add(-2*time.Hour)) // 次数耗尽
|
||||
r3 := makeFileRecord(t, d, "RECYC", nil, 5, time.Now().Add(-2*time.Hour)) // 存活(无过期时间且有余量)
|
||||
|
||||
removed := janitor.RecycleExpired(context.Background(), d.DB, d.Store, &janitor.Recycler{
|
||||
Enabled: func() bool { return true },
|
||||
RetentionDays: func() int64 { return 0 },
|
||||
})
|
||||
if removed != 2 {
|
||||
t.Fatalf("应回收 2 条,实际 %d", removed)
|
||||
}
|
||||
for _, fc := range []model.FileCodes{r1, r2} {
|
||||
var cnt int64
|
||||
d.DB.Model(&model.FileCodes{}).Where("code = ?", fc.Code).Count(&cnt)
|
||||
if cnt != 0 {
|
||||
t.Fatalf("%s 记录应被回收", fc.Code)
|
||||
}
|
||||
if objectExists(t, d, fc) {
|
||||
t.Fatalf("%s 存储对象应被删除", fc.Code)
|
||||
}
|
||||
}
|
||||
var cnt int64
|
||||
d.DB.Model(&model.FileCodes{}).Where("code = ?", r3.Code).Count(&cnt)
|
||||
if cnt != 1 {
|
||||
t.Fatal("存活分享不应被回收")
|
||||
}
|
||||
if !objectExists(t, d, r3) {
|
||||
t.Fatal("存活分享对象应保留")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecycleRetentionDays 留存期:创建超 retention_days 的分享被回收。
|
||||
func TestRecycleRetentionDays(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
fresh := makeFileRecord(t, d, "RETEN1", nil, 5, time.Now()) // 新
|
||||
stale := makeFileRecord(t, d, "RETEN2", nil, 5, time.Now().Add(-48*time.Hour)) // 超 1 天留存
|
||||
removed := janitor.RecycleExpired(context.Background(), d.DB, d.Store, &janitor.Recycler{
|
||||
Enabled: func() bool { return true },
|
||||
RetentionDays: func() int64 { return 1 },
|
||||
})
|
||||
if removed != 1 {
|
||||
t.Fatalf("应回收 1 条,实际 %d", removed)
|
||||
}
|
||||
var cnt int64
|
||||
d.DB.Model(&model.FileCodes{}).Where("code = ?", stale.Code).Count(&cnt)
|
||||
if cnt != 0 {
|
||||
t.Fatal("超留存期分享应被回收")
|
||||
}
|
||||
if !objectExists(t, d, fresh) {
|
||||
t.Fatal("未超留存期的分享对象应保留")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLazyRecycleOnPickup 取件次数耗尽后再取 → 惰性回收(记录与对象删除)。
|
||||
func TestLazyRecycleOnPickup(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
past := time.Now().Add(time.Hour)
|
||||
fc := makeFileRecord(t, d, "LAZYa", &past, 1, time.Now())
|
||||
if !d.consumeUsage(invokeContext(t), &fc) {
|
||||
// 第一次:count 1→0 成功
|
||||
t.Fatal("首次取件应成功")
|
||||
}
|
||||
if d.consumeUsage(invokeContext(t), &fc) {
|
||||
t.Fatal("次数耗尽后取件应失败")
|
||||
}
|
||||
// 惰性回收是异步的:同步触发一次等价清理验证语义
|
||||
janitor.RecycleRecord(context.Background(), d.DB, d.Store, &fc, &janitor.Recycler{
|
||||
Enabled: func() bool { return true },
|
||||
RetentionDays: func() int64 { return 0 },
|
||||
})
|
||||
var cnt int64
|
||||
d.DB.Model(&model.FileCodes{}).Where("code = ?", fc.Code).Count(&cnt)
|
||||
if cnt != 0 {
|
||||
t.Fatal("惰性回收应删除记录")
|
||||
}
|
||||
if objectExists(t, d, fc) {
|
||||
t.Fatal("惰性回收应删除对象")
|
||||
}
|
||||
}
|
||||
|
||||
// invokeContext 构造带请求的测试 context。
|
||||
func invokeContext(t *testing.T) *gin.Context {
|
||||
t.Helper()
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
return c
|
||||
}
|
||||
|
||||
// ============ 防盗链 ============
|
||||
|
||||
// TestHotlinkMiddleware 防盗链中间件矩阵。
|
||||
func TestHotlinkMiddleware(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
mw := hotlinkProbe(d)
|
||||
req := func(referer, host string) int {
|
||||
r := httptest.NewRequest(http.MethodGet, "/share/download", nil)
|
||||
if referer != "" {
|
||||
r.Header.Set("Referer", referer)
|
||||
}
|
||||
r.Host = host
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = r
|
||||
mw(c)
|
||||
if !c.IsAborted() {
|
||||
return http.StatusOK
|
||||
}
|
||||
return w.Code
|
||||
}
|
||||
setKV(t, d, "hotlink_enabled", "0")
|
||||
if got := req("https://evil.com/leech", "mysite.com"); got != http.StatusOK {
|
||||
t.Fatalf("开关关闭时应全放行,got %d", got)
|
||||
}
|
||||
setKV(t, d, "hotlink_enabled", "1")
|
||||
setKV(t, d, "hotlink_whitelist", "")
|
||||
if got := req("https://evil.com/leech", "mysite.com"); got != http.StatusForbidden {
|
||||
t.Fatalf("外站 Referer 应 403,got %d", got)
|
||||
}
|
||||
if got := req("https://mysite.com/page", "mysite.com"); got != http.StatusOK {
|
||||
t.Fatalf("同源 Referer 应放行,got %d", got)
|
||||
}
|
||||
if got := req("", "mysite.com"); got != http.StatusOK {
|
||||
t.Fatalf("空 Referer 应放行,got %d", got)
|
||||
}
|
||||
setKV(t, d, "hotlink_whitelist", "friend.org, *.cdn.net")
|
||||
if got := req("https://friend.org/x", "mysite.com"); got != http.StatusOK {
|
||||
t.Fatalf("白名单精确命中应放行,got %d", got)
|
||||
}
|
||||
if got := req("https://sub.cdn.net/x", "mysite.com"); got != http.StatusOK {
|
||||
t.Fatalf("白名单通配命中应放行,got %d", got)
|
||||
}
|
||||
if got := req("https://other.net/x", "mysite.com"); got != http.StatusForbidden {
|
||||
t.Fatalf("非白名单应 403,got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// hotlinkProbe 直接调用中间件构造器。
|
||||
func hotlinkProbe(d *Deps) gin.HandlerFunc {
|
||||
return middleware.HotlinkMiddleware(d.Cfg)
|
||||
}
|
||||
|
||||
// ============ 直链下载 ============
|
||||
|
||||
// presignFake 包装本地引擎,仅覆盖 PresignGetURL 返回固定签名 URL。
|
||||
type presignFake struct {
|
||||
storage.Storage
|
||||
gotExpires int64
|
||||
url string
|
||||
}
|
||||
|
||||
func (p *presignFake) PresignGetURL(_ context.Context, _ string, expires int64) (string, error) {
|
||||
p.gotExpires = expires
|
||||
return p.url, nil
|
||||
}
|
||||
|
||||
// TestDirectDownloadRedirect 直链开启 + 引擎支持 → 302 到签名 URL,且
|
||||
// 有效期不超过分享剩余时效;直链关闭 → 走代理 200。
|
||||
func TestDirectDownloadRedirect(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
// 桩包装原 local 引擎:302 不落盘,代理回落时仍能读到真实对象
|
||||
origLocal, err := d.storeFor("local")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fake := &presignFake{Storage: origLocal, url: "https://s3.example.com/signed?X-Amz-Signature=abc"}
|
||||
swapLocal(t, d, fake)
|
||||
setKV(t, d, "direct_download", "1")
|
||||
setKV(t, d, "direct_link_expire", "900")
|
||||
|
||||
content := []byte("direct-link-body")
|
||||
code := uploadOK(t, d, "d.txt", content)
|
||||
|
||||
// 时间型分享剩余 5 分钟 → 直链有效期应被钳到 300s
|
||||
exp := time.Now().Add(5 * time.Minute)
|
||||
if err := d.DB.Model(&model.FileCodes{}).Where("code = ?", code).
|
||||
Updates(map[string]any{"expired_at": exp, "expired_count": -1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fc := fileByCode(t, d, code) // 重新取(带上过期时间)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/share/download", nil)
|
||||
d.serveFile(c, &fc)
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("应 302 直链,实际 %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if loc := w.Header().Get("Location"); loc != fake.url {
|
||||
t.Fatalf("Location 应为签名 URL,实际 %q", loc)
|
||||
}
|
||||
if fake.gotExpires > 300 {
|
||||
t.Fatalf("直链有效期应被分享剩余时效钳位(≤300),实际 %d", fake.gotExpires)
|
||||
}
|
||||
|
||||
// 关闭直链 → 回落代理 200
|
||||
setKV(t, d, "direct_download", "0")
|
||||
fc2 := fileByCode(t, d, code)
|
||||
w2 := httptest.NewRecorder()
|
||||
c2, _ := gin.CreateTestContext(w2)
|
||||
c2.Request = httptest.NewRequest(http.MethodGet, "/share/download", nil)
|
||||
d.serveFile(c2, &fc2)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("直链关闭应走代理 200,实际 %d", w2.Code)
|
||||
}
|
||||
if !strings.Contains(w2.Body.String(), "direct-link-body") {
|
||||
t.Fatal("代理响应应包含文件内容")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectDownloadLocalFallback 本地引擎不支持直链 → 自动回落代理 200。
|
||||
func TestDirectDownloadLocalFallback(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
setKV(t, d, "direct_download", "1")
|
||||
code := uploadOK(t, d, "f.txt", []byte("local-fallback"))
|
||||
fc := fileByCode(t, d, code)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/share/download", nil)
|
||||
d.serveFile(c, &fc)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("本地引擎应回落代理 200,实际 %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 存储桩 ============
|
||||
|
||||
// swapLocal 用桩替换 local 引擎(重建 Manager,工厂恒返回桩)。
|
||||
func swapLocal(t *testing.T, d *Deps, fake storage.Storage) {
|
||||
t.Helper()
|
||||
factory := func(string) (storage.Storage, error) { return fake, nil }
|
||||
d.Store = storage.NewManager("local", fake, factory)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"fileshare/internal/audit"
|
||||
"fileshare/internal/config"
|
||||
"fileshare/internal/middleware"
|
||||
"fileshare/internal/response"
|
||||
"fileshare/internal/settings"
|
||||
"fileshare/internal/storage"
|
||||
)
|
||||
|
||||
// Deps API 层共享依赖(main.go 装配后注入)。
|
||||
type Deps struct {
|
||||
DB *gorm.DB
|
||||
Cfg *config.Config
|
||||
Mgr *settings.Manager
|
||||
AuditSvc *audit.Service
|
||||
Limiter *middleware.RateLimiter
|
||||
Store *storage.Manager // 26.9:可热切换引擎管理器(实现 Storage 接口)
|
||||
Version string
|
||||
}
|
||||
|
||||
// jwtSecret 当前 JWT 签名密钥(settings KV 运行时可变)。
|
||||
func (d *Deps) jwtSecret() string { return d.Mgr.SecretProvider()() }
|
||||
|
||||
// Register 注册全部 API 路由与前端静态资源回退。
|
||||
// 业务路由挂根路径(/share /chunk /presign /admin),与审计中间件
|
||||
// DefaultClassifier 的路由模式一致(t1 冻结契约);公共接口保留
|
||||
// /api/v1/health 与 /api/v1/config(对齐 t1 骨架)。
|
||||
func Register(r *gin.Engine, d *Deps) {
|
||||
// —— 公共接口 ——
|
||||
r.GET("/api/v1/health", d.health)
|
||||
r.GET("/api/v1/config", d.publicConfig)
|
||||
// Info3:robotsText 配置键此前无路由承接,补上(内容可在管理端自定义)
|
||||
r.GET("/robots.txt", d.robotsText)
|
||||
|
||||
// —— 初始化向导(未初始化时唯一可用入口,GuardNotInitialized 白名单)——
|
||||
registerSetup(r, d)
|
||||
|
||||
// —— 分享 ——
|
||||
share := r.Group("/share")
|
||||
{
|
||||
share.POST("/text", d.shareText)
|
||||
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)
|
||||
// 26.9:下载防盗链(动态开关,空 Referer 放行)
|
||||
share.GET("/download", middleware.HotlinkMiddleware(d.Cfg), d.shareDownload)
|
||||
}
|
||||
|
||||
// —— 分片上传 ——
|
||||
chunk := r.Group("/chunk")
|
||||
{
|
||||
chunk.POST("/upload/init", d.chunkInit)
|
||||
// 主路径(参考语义):/chunk/upload/{uploadID}/{index};
|
||||
// 扁平兼容:/chunk/upload + 表单/query 传 upload_id/chunk_index
|
||||
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)
|
||||
}
|
||||
|
||||
// —— 预签名直传 ——
|
||||
presign := r.Group("/presign")
|
||||
{
|
||||
presign.POST("/upload/init", d.presignInit)
|
||||
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)
|
||||
}
|
||||
|
||||
// —— 管理端(login 公开,其余需管理员 JWT)——
|
||||
registerAdmin(r, d)
|
||||
|
||||
// —— 前端静态资源 + SPA 回退(须最后注册)——
|
||||
registerWeb(r, d)
|
||||
}
|
||||
|
||||
// health 健康检查(对齐 t1 骨架,保持 /api/v1/health 语义不变)。
|
||||
func (d *Deps) health(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"msg": "ok",
|
||||
"data": gin.H{
|
||||
"status": "ok",
|
||||
"version": d.Version,
|
||||
"storage": d.Cfg.Engine(),
|
||||
"time": nowRFC3339(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// robotsText 输出管理端可配置的 robots.txt 内容。
|
||||
func (d *Deps) robotsText(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(d.Cfg.GetString("robotsText")))
|
||||
}
|
||||
|
||||
// publicConfig 公共配置(前端首页/上传页所需;26.9 需求 ①②③④⑩ 扩展):
|
||||
// - 展示字段:站点信息、Logo/favicon、背景图、页脚文案/备案号、通知;
|
||||
// - 策略范围(上传页动态渲染):大小上限、类型白名单、过期方式、保存
|
||||
// 时间/次数上限、上传频率(仅范围,不含内部实现键)。
|
||||
//
|
||||
// 敏感键(admin_token/jwt_secret,settings.SensitiveKeys)与本端点无关:
|
||||
// 下发字段为白名单显式构造,任何敏感键均不会出现在响应中。
|
||||
func (d *Deps) publicConfig(c *gin.Context) {
|
||||
cfg := d.Cfg
|
||||
policy := d.CurrentUploadPolicy()
|
||||
uploadCount := cfg.GetInt("uploadCount")
|
||||
uploadMinute := cfg.GetInt("uploadMinute")
|
||||
// uploadSize 为参考语义的回落上限,单独下发供管理端联动展示
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"msg": "ok",
|
||||
"data": gin.H{
|
||||
"config": gin.H{
|
||||
"name": cfg.SiteName(),
|
||||
"description": cfg.GetString("description"),
|
||||
"explain": cfg.GetString("page_explain"),
|
||||
// 需求 ①:Logo/favicon/背景图
|
||||
"logo_url": cfg.LogoURL(),
|
||||
"favicon_url": cfg.FaviconURL(),
|
||||
"background_url": cfg.BackgroundURL(),
|
||||
// 需求 ②:页脚自定义内容与备案号
|
||||
"footer_text": cfg.FooterText(),
|
||||
"footer_beian": cfg.FooterBeian(),
|
||||
// 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()),
|
||||
"notify_title": cfg.GetString("notify_title"),
|
||||
"notify_content": settings.SanitizeInlineHTML(cfg.GetString("notify_content")),
|
||||
// 策略范围(需求 ④⑩):上传页动态读取并在范围内选择
|
||||
"uploadSize": cfg.UploadSize(),
|
||||
"max_file_size": policy.MaxFileSize,
|
||||
"maxFileSize": policy.MaxFileSize,
|
||||
"allowedFileTypes": policy.AllowedTypes,
|
||||
"expireStyle": policy.ExpireStyles,
|
||||
"max_save_seconds": policy.MaxSaveSeconds,
|
||||
"maxSaveSeconds": policy.MaxSaveSeconds,
|
||||
"max_save_count": policy.MaxSaveCount,
|
||||
"maxSaveCount": policy.MaxSaveCount,
|
||||
"uploadCount": uploadCount,
|
||||
"uploadMinute": uploadMinute,
|
||||
"enableChunk": cfg.EnableChunk(),
|
||||
"openUpload": cfg.OpenUpload(),
|
||||
},
|
||||
"meta": gin.H{
|
||||
"version": d.Version,
|
||||
"features": gin.H{
|
||||
"chunkUpload": cfg.EnableChunk(),
|
||||
"guestUpload": cfg.OpenUpload(),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// requireShareLogin 分享上传权限(对齐参考 share_required_login):
|
||||
// openUpload 开启时游客可传;关闭时要求管理员 Bearer token(403)。
|
||||
func (d *Deps) requireShareLogin(c *gin.Context) bool {
|
||||
if d.Cfg.OpenUpload() {
|
||||
return true
|
||||
}
|
||||
header := c.GetHeader("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if len(header) <= len(prefix) || header[:len(prefix)] != prefix {
|
||||
response.Fail(c, http.StatusForbidden, "本站未开启游客上传,如需上传请先登录后台")
|
||||
return false
|
||||
}
|
||||
token := header[len(prefix):]
|
||||
if _, err := middleware.VerifyAdminToken(d.jwtSecret(), token); err != nil {
|
||||
response.Fail(c, http.StatusForbidden, "本站未开启游客上传,如需上传请先登录后台")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// security_fixes_test.go — 安全审计修复项行为测试:
|
||||
// L4 enableChunk 强制、M2 presign 大小/类型校验、L3 提码长度、M3 chunk_size 上限。
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"fileshare/internal/model"
|
||||
"fileshare/internal/settings"
|
||||
)
|
||||
|
||||
// postJSON 以 JSON body 调用 POST 端点。
|
||||
func postJSON(d *Deps, path string, body any) *httptest.ResponseRecorder {
|
||||
var reader *bytes.Reader
|
||||
if body == nil {
|
||||
reader = bytes.NewReader(nil)
|
||||
} else {
|
||||
raw, _ := json.Marshal(body)
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, path, reader)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
c.Params = append(c.Params, gin.Param{Key: "uploadID", Value: req.URL.Path[len("/presign/upload/confirm/"):]})
|
||||
d.presignConfirm(c)
|
||||
return w
|
||||
}
|
||||
|
||||
// sha256LegacyHash 构造旧版 sha256$salt$hash 格式(M1 迁移测试用)。
|
||||
func sha256LegacyHash(password string) string {
|
||||
salt := make([]byte, 16)
|
||||
for i := range salt {
|
||||
salt[i] = byte(i)
|
||||
}
|
||||
saltHex := hex.EncodeToString(salt)
|
||||
sum := sha256.Sum256([]byte(saltHex + password))
|
||||
return "sha256$" + saltHex + "$" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// TestChunkToggleEnforced L4:enableChunk=0 时 /chunk 相关端点一律 403。
|
||||
func TestChunkToggleEnforced(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
// 默认 enableChunk=0
|
||||
w := chunkInitJSON(d, `{"file_name":"a.png","file_size":500,"chunk_size":1024,"file_hash":"h"}`)
|
||||
if code, _ := respBody(t, w); code != http.StatusForbidden {
|
||||
t.Fatalf("enableChunk=0 时 init 应 403: %d %s", code, w.Body.String())
|
||||
}
|
||||
// 开启后放行
|
||||
if w := patchConfig(d, map[string]any{"enableChunk": 1}); w.Code != 200 {
|
||||
t.Fatalf("patch enableChunk: %d", w.Code)
|
||||
}
|
||||
w = chunkInitJSON(d, `{"file_name":"a.png","file_size":500,"chunk_size":1024,"file_hash":"h"}`)
|
||||
if code, _ := respBody(t, w); code != 200 {
|
||||
t.Fatalf("enableChunk=1 时 init 应 200: %d %s", code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestChunkSizeCap M3:chunk_size 超过 32MB 上限时 400。
|
||||
func TestChunkSizeCap(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
if w := patchConfig(d, map[string]any{"enableChunk": 1}); w.Code != 200 {
|
||||
t.Fatalf("patch enableChunk: %d", w.Code)
|
||||
}
|
||||
w := chunkInitJSON(d, `{"file_name":"a.bin","file_size":70000000000,"chunk_size":34000000,"file_hash":"h"}`)
|
||||
if code, _ := respBody(t, w); code != http.StatusBadRequest {
|
||||
t.Fatalf("chunk_size 超上限应 400: %d %s", code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickupCodeMinLen L3:4 位自定义码拒绝、5 位通过。
|
||||
func TestPickupCodeMinLen(t *testing.T) {
|
||||
if err := validatePickupCode("abcd"); err == nil {
|
||||
t.Fatal("4 位码应被拒绝")
|
||||
}
|
||||
if err := validatePickupCode("abcde"); err != nil {
|
||||
t.Fatalf("5 位码应通过: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPresignConfirmRejectsOversizeObject M2:
|
||||
// 直传会话 confirm 时,若对象实际大小超过策略上限,应删除对象并 403。
|
||||
func TestPresignConfirmRejectsOversizeObject(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 声明 10 字节、策略上限 100 → 实际 PUT 500 字节对象
|
||||
if err := d.Mgr.UpdateKV(ctx, map[string]any{"max_file_size": 100}); err != nil {
|
||||
t.Fatalf("UpdateKV: %v", err)
|
||||
}
|
||||
if err := d.Mgr.Reload(ctx); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
|
||||
uploadID := "test-oversize-confirm"
|
||||
savePath := "share/data/presign_test.bin"
|
||||
if _, err := d.Store.SaveFile(ctx, bytes.NewReader(make([]byte, 500)), savePath); err != nil {
|
||||
t.Fatalf("SaveFile: %v", err)
|
||||
}
|
||||
sess := model.PresignUploadSession{
|
||||
UploadID: uploadID, FileName: "presign_test.bin", FileSize: 10,
|
||||
SavePath: savePath, Mode: "direct",
|
||||
ExpireValue: 1, ExpireStyle: "day",
|
||||
CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour), Engine: "local",
|
||||
}
|
||||
if err := d.DB.WithContext(ctx).Create(&sess).Error; err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
res := model.StorageReservation{Token: "presign:" + uploadID, Size: 10, ExpiresAt: time.Now().Add(time.Hour)}
|
||||
if err := d.DB.WithContext(ctx).Create(&res).Error; err != nil {
|
||||
t.Fatalf("create reservation: %v", err)
|
||||
}
|
||||
|
||||
w := postJSON(d, "/presign/upload/confirm/"+uploadID, nil)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("超限对象 confirm 应 403: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// 对象应被删除、预留应释放
|
||||
if ok, _ := d.Store.FileExists(ctx, savePath); ok {
|
||||
t.Fatal("超限对象应被服务端删除")
|
||||
}
|
||||
var cnt int64
|
||||
_ = d.DB.WithContext(ctx).Model(&model.StorageReservation{}).Where("token = ?", res.Token).Count(&cnt).Error
|
||||
if cnt != 0 {
|
||||
t.Fatal("预留应被释放")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPresignConfirmRejectsSizeMismatch M2:实际大小与声明差超过 ±1KB 时 400。
|
||||
func TestPresignConfirmRejectsSizeMismatch(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
ctx := context.Background()
|
||||
|
||||
uploadID := "test-mismatch-confirm"
|
||||
savePath := "share/data/presign_mismatch.bin"
|
||||
if _, err := d.Store.SaveFile(ctx, bytes.NewReader(make([]byte, 2048)), savePath); err != nil {
|
||||
t.Fatalf("SaveFile: %v", err)
|
||||
}
|
||||
sess := model.PresignUploadSession{
|
||||
UploadID: uploadID, FileName: "presign_mismatch.bin", FileSize: 10,
|
||||
SavePath: savePath, Mode: "proxy", // proxy 模式同样走大小核对(多引擎一致)
|
||||
ExpireValue: 1, ExpireStyle: "day",
|
||||
CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour), Engine: "local",
|
||||
}
|
||||
if err := d.DB.WithContext(ctx).Create(&sess).Error; err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
res := model.StorageReservation{Token: "presign:" + uploadID, Size: 10, ExpiresAt: time.Now().Add(time.Hour)}
|
||||
if err := d.DB.WithContext(ctx).Create(&res).Error; err != nil {
|
||||
t.Fatalf("create reservation: %v", err)
|
||||
}
|
||||
|
||||
w := postJSON(d, "/presign/upload/confirm/"+uploadID, nil)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("大小不符 confirm 应 400: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminPasswordAutoUpgrade M1:明文/旧哈希经 VerifyPassword 后 NeedsRehash 为真,
|
||||
// bcrypt 哈希不再需要升级。
|
||||
func TestAdminPasswordAutoUpgrade(t *testing.T) {
|
||||
if !settings.NeedsRehash("FileCodeBox2023") {
|
||||
t.Fatal("明文哈希需要升级")
|
||||
}
|
||||
legacy := sha256LegacyHash("pwd12345")
|
||||
if !settings.NeedsRehash(legacy) {
|
||||
t.Fatal("sha256 哈希需要升级")
|
||||
}
|
||||
if !settings.VerifyPassword("pwd12345", legacy) {
|
||||
t.Fatal("旧 sha256 哈希兼容校验失败")
|
||||
}
|
||||
b := settings.HashPassword("pwd12345")
|
||||
if settings.NeedsRehash(b) {
|
||||
t.Fatal("bcrypt 哈希不需要升级")
|
||||
}
|
||||
if !settings.VerifyPassword("pwd12345", b) {
|
||||
t.Fatal("bcrypt 校验失败")
|
||||
}
|
||||
if settings.VerifyPassword("wrong", b) {
|
||||
t.Fatal("错误密码不应通过")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"fileshare/internal/response"
|
||||
"fileshare/internal/settings"
|
||||
)
|
||||
|
||||
// fileSizeUnits 文件大小单位(对齐参考 FILE_SIZE_UNITS)。
|
||||
var fileSizeUnits = map[string]int64{"KB": 1024, "MB": 1024 * 1024, "GB": 1024 * 1024 * 1024}
|
||||
|
||||
// saveTimeUnits 保存时间单位(秒)。
|
||||
var saveTimeUnits = map[string]int64{"second": 1, "minute": 60, "hour": 3600, "day": 86400}
|
||||
|
||||
// expireStyleOptions 可用过期方式(用于 setup 表单校验)。
|
||||
var expireStyleOptions = []string{"day", "hour", "minute", "forever", "count"}
|
||||
|
||||
// setupFormValue 取表单/JSON 字符串值。
|
||||
func setupFormValue(data map[string]any, key, def string) string {
|
||||
v, ok := data[key]
|
||||
if !ok || v == nil {
|
||||
return def
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return strings.TrimSpace(strconv.FormatFloat(toAnyFloat(v), 'f', -1, 64))
|
||||
}
|
||||
|
||||
func toAnyFloat(v any) float64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case int:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// registerSetup 注册初始化向导(未初始化时唯一可用入口,白名单 /setup)。
|
||||
func registerSetup(r *gin.Engine, d *Deps) {
|
||||
r.GET("/setup", func(c *gin.Context) {
|
||||
if d.Mgr.IsInitialized() {
|
||||
c.Redirect(http.StatusSeeOther, "/")
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(buildSetupPage("")))
|
||||
})
|
||||
r.POST("/setup", func(c *gin.Context) {
|
||||
if d.Mgr.IsInitialized() {
|
||||
c.Redirect(http.StatusSeeOther, "/")
|
||||
return
|
||||
}
|
||||
d.setupSubmit(c)
|
||||
})
|
||||
}
|
||||
|
||||
// setupSubmit 处理初始化提交(对齐参考 setup_submit + parse_setup_options)。
|
||||
func (d *Deps) setupSubmit(c *gin.Context) {
|
||||
// 兼容 JSON 与表单
|
||||
data := map[string]any{}
|
||||
if strings.Contains(c.GetHeader("Content-Type"), "application/json") {
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
c.Data(http.StatusBadRequest, "text/html; charset=utf-8", []byte(buildSetupPage("请求体格式错误")))
|
||||
return
|
||||
}
|
||||
} else if err := c.Request.ParseForm(); err != nil {
|
||||
c.Data(http.StatusBadRequest, "text/html; charset=utf-8", []byte(buildSetupPage("请求体格式错误")))
|
||||
return
|
||||
} else {
|
||||
// 多值字段(如多个 expireStyle 复选框)保留完整列表,单值取首项
|
||||
for k, v := range c.Request.PostForm {
|
||||
switch {
|
||||
case len(v) == 1:
|
||||
data[k] = v[0]
|
||||
case len(v) > 1:
|
||||
data[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
adminPassword := setupFormValue(data, "admin_password", "")
|
||||
confirmPassword := setupFormValue(data, "confirm_password", "")
|
||||
siteName := setupFormValue(data, "site_name", "")
|
||||
|
||||
if adminPassword == "" || len(adminPassword) < 8 {
|
||||
c.Data(http.StatusBadRequest, "text/html; charset=utf-8", []byte(buildSetupPage("管理员密码至少 8 位")))
|
||||
return
|
||||
}
|
||||
if adminPassword != confirmPassword {
|
||||
c.Data(http.StatusBadRequest, "text/html; charset=utf-8", []byte(buildSetupPage("两次输入的管理员密码不一致")))
|
||||
return
|
||||
}
|
||||
patch, errMsg := parseSetupOptions(data)
|
||||
if errMsg != "" {
|
||||
c.Data(http.StatusBadRequest, "text/html; charset=utf-8", []byte(buildSetupPage(errMsg)))
|
||||
return
|
||||
}
|
||||
patch["site_name"] = firstNonEmpty(siteName, "文件快传")
|
||||
patch["admin_token"] = settings.HashPassword(adminPassword)
|
||||
patch["jwt_secret"] = settings.GenerateJWTSecret()
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if err := d.Mgr.UpdateKV(ctx, patch); err != nil {
|
||||
c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte(buildSetupPage("初始化失败: "+err.Error())))
|
||||
return
|
||||
}
|
||||
if err := d.Mgr.Reload(ctx); err != nil {
|
||||
c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte(buildSetupPage("配置重载失败: "+err.Error())))
|
||||
return
|
||||
}
|
||||
d.syncRateRules()
|
||||
// JSON 请求返回 JSON;表单返回成功页
|
||||
if strings.Contains(c.GetHeader("Accept"), "application/json") ||
|
||||
strings.Contains(c.GetHeader("Content-Type"), "application/json") {
|
||||
response.OK(c, gin.H{"ok": true, "admin": "/#/admin"})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(buildSetupSuccessPage()))
|
||||
}
|
||||
|
||||
// parseSetupOptions 解析并校验初始化选项(对齐参考 parse_setup_options)。
|
||||
func parseSetupOptions(data map[string]any) (map[string]any, string) {
|
||||
out := map[string]any{}
|
||||
|
||||
// 文件大小限制
|
||||
unit := strings.ToUpper(setupFormValue(data, "upload_size_unit", "MB"))
|
||||
if _, ok := fileSizeUnits[unit]; !ok {
|
||||
return nil, "文件大小单位不正确"
|
||||
}
|
||||
sizeVal, err := strconv.Atoi(setupFormValue(data, "upload_size_value", "10"))
|
||||
if err != nil || sizeVal < 1 {
|
||||
return nil, "文件大小限制必须是正整数"
|
||||
}
|
||||
out["uploadSize"] = int64(sizeVal) * fileSizeUnits[unit]
|
||||
|
||||
// 最长保存时间
|
||||
saveUnit := strings.ToLower(setupFormValue(data, "save_time_unit", "day"))
|
||||
if _, ok := saveTimeUnits[saveUnit]; !ok {
|
||||
return nil, "最长保存时间单位不正确"
|
||||
}
|
||||
saveVal, err := strconv.Atoi(setupFormValue(data, "save_time_value", "0"))
|
||||
if err != nil || saveVal < 0 {
|
||||
return nil, "最长保存时间必须是非负整数"
|
||||
}
|
||||
out["max_save_seconds"] = int64(saveVal) * saveTimeUnits[saveUnit]
|
||||
|
||||
// 过期方式白名单
|
||||
var styles []string
|
||||
if raw, ok := data["expireStyle"]; ok {
|
||||
switch v := raw.(type) {
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
styles = append(styles, s)
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
styles = v
|
||||
case string:
|
||||
for _, s := range strings.Split(v, ",") {
|
||||
styles = append(styles, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
valid := map[string]bool{}
|
||||
var finalStyles []string
|
||||
for _, s := range styles {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || valid[s] {
|
||||
continue
|
||||
}
|
||||
for _, opt := range expireStyleOptions {
|
||||
if opt == s {
|
||||
valid[s] = true
|
||||
finalStyles = append(finalStyles, s)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(finalStyles) == 0 {
|
||||
return nil, "至少需要选择一种过期方式"
|
||||
}
|
||||
out["expireStyle"] = finalStyles
|
||||
|
||||
// 取件码类型
|
||||
codeType := setupFormValue(data, "code_generate_type", "secret")
|
||||
if codeType != "number" && codeType != "secret" {
|
||||
return nil, "提取码类型不正确"
|
||||
}
|
||||
out["code_generate_type"] = codeType
|
||||
|
||||
// 频率限制
|
||||
for _, item := range []struct{ key, def string }{
|
||||
{"errorCount", "10"}, {"errorMinute", "1"},
|
||||
{"loginCount", "5"}, {"loginMinute", "15"},
|
||||
{"uploadCount", "10"}, {"uploadMinute", "1"},
|
||||
} {
|
||||
n, err := strconv.Atoi(setupFormValue(data, item.key, item.def))
|
||||
if err != nil || n < 1 {
|
||||
return nil, item.key + " 必须是正整数"
|
||||
}
|
||||
out[item.key] = n
|
||||
}
|
||||
|
||||
// 布尔开关
|
||||
out["openUpload"] = boolToInt(parseSetupBool(data, "openUpload", true))
|
||||
out["enableChunk"] = boolToInt(parseSetupBool(data, "enableChunk", false))
|
||||
|
||||
// 允许文件类型
|
||||
allowed := setupFormValue(data, "allowed_file_types", "*")
|
||||
var types []string
|
||||
for _, item := range strings.Split(allowed, ",") {
|
||||
if item = strings.TrimSpace(item); item != "" {
|
||||
types = append(types, item)
|
||||
}
|
||||
}
|
||||
if len(types) == 0 {
|
||||
types = []string{"*"}
|
||||
}
|
||||
out["allowed_file_types"] = types
|
||||
return out, ""
|
||||
}
|
||||
|
||||
// parseSetupBool 解析表单布尔(缺省 default;"1"/"true"/"on"/"yes" 为真)。
|
||||
func parseSetupBool(data map[string]any, key string, def bool) bool {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
switch s := v.(type) {
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "1", "true", "on", "yes":
|
||||
return true
|
||||
case "0", "false", "off", "no", "":
|
||||
return false
|
||||
}
|
||||
case float64:
|
||||
return s != 0
|
||||
case bool:
|
||||
return s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// buildSetupPage 初始化向导页面(简洁中文表单)。
|
||||
func buildSetupPage(errMsg string) string {
|
||||
errBlock := ""
|
||||
if errMsg != "" {
|
||||
errBlock = `<div style="margin-bottom:12px;padding:10px 12px;border-radius:10px;background:#fef2f2;color:#b91c1c;font-size:13px">` + htmlEscape(errMsg) + `</div>`
|
||||
}
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>初始化 文件快传</title>
|
||||
<style>
|
||||
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:16px;font-family:-apple-system,"Segoe UI",sans-serif;background:#f5f5f7;color:#18181b}
|
||||
main{width:min(100%,640px);padding:24px;border-radius:16px;background:#fff;box-shadow:0 18px 50px rgba(23,32,51,.08)}
|
||||
h1{margin:0 0 6px;font-size:20px} p{margin:0 0 16px;color:#71717a;font-size:13px}
|
||||
label{display:block;margin:10px 0 4px;font-size:12px;color:#3f3f46;font-weight:600}
|
||||
input{width:100%;height:36px;border:1px solid #e4e4e7;border-radius:8px;padding:0 10px;box-sizing:border-box;font:inherit}
|
||||
.grid{display:grid;grid-template-columns:1fr 1fr;gap:0 12px}
|
||||
button{width:100%;height:40px;margin-top:16px;border:0;border-radius:10px;background:#18181b;color:#fff;font:inherit;font-weight:700;cursor:pointer}
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<h1>初始化 文件快传</h1>
|
||||
<p>首次配置管理员密码、上传限制和取件策略,后续可在后台调整。</p>
|
||||
` + errBlock + `
|
||||
<form method="post" action="/setup" autocomplete="off">
|
||||
<label>站点名称</label>
|
||||
<input name="site_name" maxlength="80" placeholder="文件快传">
|
||||
<div class="grid">
|
||||
<div><label>管理员密码</label><input name="admin_password" type="password" minlength="8" required></div>
|
||||
<div><label>确认管理员密码</label><input name="confirm_password" type="password" minlength="8" required></div>
|
||||
<div><label>单文件大小限制</label><input name="upload_size_value" type="number" min="1" value="10" required></div>
|
||||
<div><label>大小单位</label><input name="upload_size_unit" value="MB" required></div>
|
||||
<div><label>上传频率(次/分钟)</label><input name="uploadCount" type="number" min="1" value="10" required></div>
|
||||
<div><label>上传检测窗口(分钟)</label><input name="uploadMinute" type="number" min="1" value="1" required></div>
|
||||
<div><label>取件错误频率(次/分钟)</label><input name="errorCount" type="number" min="1" value="10" required></div>
|
||||
<div><label>取件错误窗口(分钟)</label><input name="errorMinute" type="number" min="1" value="1" required></div>
|
||||
<div><label>登录失败频率(次/分钟)</label><input name="loginCount" type="number" min="1" value="5" required></div>
|
||||
<div><label>登录失败窗口(分钟)</label><input name="loginMinute" type="number" min="1" value="15" required></div>
|
||||
<div><label>最长保存时间</label><input name="save_time_value" type="number" min="0" value="0" required></div>
|
||||
<div><label>保存时间单位</label><input name="save_time_unit" value="day" required></div>
|
||||
</div>
|
||||
<label>允许文件类型(逗号分隔,* 不限制)</label>
|
||||
<input name="allowed_file_types" value="*">
|
||||
<label>提取码类型(number=数字 / secret=随机字符)</label>
|
||||
<input name="code_generate_type" value="secret">
|
||||
<label><input type="checkbox" name="openUpload" value="1" checked style="width:auto"> 允许游客上传</label>
|
||||
<label><input type="checkbox" name="enableChunk" value="1" style="width:auto"> 启用切片上传</label>
|
||||
<input type="hidden" name="expireStyle" value="day">
|
||||
<input type="hidden" name="expireStyle" value="hour">
|
||||
<input type="hidden" name="expireStyle" value="minute">
|
||||
<input type="hidden" name="expireStyle" value="forever">
|
||||
<input type="hidden" name="expireStyle" value="count">
|
||||
<button type="submit">完成初始化</button>
|
||||
</form>
|
||||
</main></body></html>`
|
||||
}
|
||||
|
||||
// buildSetupSuccessPage 初始化完成页。
|
||||
func buildSetupSuccessPage() string {
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta http-equiv="refresh" content="2;url=/#/admin"><title>初始化完成</title></head>
|
||||
<body style="display:grid;place-items:center;min-height:100vh;font-family:-apple-system,sans-serif;background:#f6f8fb;color:#172033">
|
||||
<main style="text-align:center;padding:32px;background:#fff;border-radius:12px;box-shadow:0 18px 50px rgba(23,32,51,.08)">
|
||||
<h1>初始化完成</h1><p>管理员密码已设置,请使用刚才的密码登录后台。</p><a href="/#/admin">进入后台</a>
|
||||
</main></body></html>`
|
||||
}
|
||||
|
||||
// htmlEscape HTML 转义(错误信息拼接用)。
|
||||
func htmlEscape(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, "'")
|
||||
return r.Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"fileshare/internal/audit"
|
||||
"fileshare/internal/janitor"
|
||||
"fileshare/internal/middleware"
|
||||
"fileshare/internal/model"
|
||||
"fileshare/internal/response"
|
||||
"fileshare/internal/storage"
|
||||
)
|
||||
|
||||
// nowRFC3339 当前时间的 RFC3339 表示。
|
||||
func nowRFC3339() string { return time.Now().Format(time.RFC3339) }
|
||||
|
||||
// lookupByCode 按取件码查询分享记录(对齐参考 get_code_file_by_code):
|
||||
// 不存在返回 "文件不存在";expired=true 时过期返回 "文件已过期"。
|
||||
// 服务端兜底:历史「链接+提取码」复制格式会把「CODE CODE」整串当码传入
|
||||
// (空格经 URL 编码进 query/path),取第一段有效码避免误报不存在。
|
||||
func (d *Deps) lookupByCode(c *gin.Context, code string, checkExpired bool) (*model.FileCodes, error) {
|
||||
code = strings.TrimSpace(code)
|
||||
if fields := strings.Fields(code); len(fields) > 1 {
|
||||
code = fields[0]
|
||||
}
|
||||
if code == "" {
|
||||
return nil, errNotFound("文件不存在")
|
||||
}
|
||||
var fc model.FileCodes
|
||||
err := d.DB.WithContext(c.Request.Context()).
|
||||
Where("code = ?", code).First(&fc).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errNotFound("文件不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errInternal("查询失败: " + err.Error())
|
||||
}
|
||||
if checkExpired && fc.Expired(time.Now()) {
|
||||
return nil, errNotFound("文件已过期")
|
||||
}
|
||||
return &fc, nil
|
||||
}
|
||||
|
||||
// consumeUsage 原子校验分享状态并记录一次实际领取(对齐参考 consume_file_usage):
|
||||
// 仅当 expired_count>0(次数剩余)或 expired_count<0 且未到过期时间时扣减成功。
|
||||
func (d *Deps) consumeUsage(c *gin.Context, fc *model.FileCodes) bool {
|
||||
now := time.Now()
|
||||
res := d.DB.WithContext(c.Request.Context()).
|
||||
Model(&model.FileCodes{}).
|
||||
Where("id = ?", fc.ID).
|
||||
Where("expired_count > 0 OR (expired_count < 0 AND (expired_at IS NULL OR expired_at > ?))", now).
|
||||
Updates(map[string]any{
|
||||
"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 := ""
|
||||
if fc.FilePath != nil {
|
||||
dir = strings.Trim(*fc.FilePath, "/")
|
||||
}
|
||||
name := ""
|
||||
if fc.UUIDFileName != nil {
|
||||
name = *fc.UUIDFileName
|
||||
}
|
||||
if dir == "" {
|
||||
return name
|
||||
}
|
||||
return dir + "/" + name
|
||||
}
|
||||
|
||||
// ============ POST /share/text 文本分享 ============
|
||||
|
||||
// shareText 创建文本分享(对齐参考 share_text)。
|
||||
func (d *Deps) shareText(c *gin.Context) {
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
if !requireUploadLimit(c, d.Limiter) {
|
||||
return
|
||||
}
|
||||
// 26.9 修复:JSON/表单/ultipart 统一绑定(form+json 双标签——此前仅 PostForm 时,
|
||||
// JSON 提交会静默存成空文本并 200,取件页空白)。
|
||||
var body struct {
|
||||
Text string `json:"text" form:"text"`
|
||||
ExpireValue int `json:"expire_value" form:"expire_value"`
|
||||
ExpireStyle string `json:"expire_style" form:"expire_style"`
|
||||
Code string `json:"code" form:"code"`
|
||||
}
|
||||
if err := bindJSONOrForm(c, &body); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
text := body.Text
|
||||
if strings.TrimSpace(text) == "" {
|
||||
response.Fail(c, http.StatusBadRequest, "分享内容不能为空")
|
||||
return
|
||||
}
|
||||
// M3:前置拒绝超大 body(配合全局 BodyLimit;441KB 为 222KB 内容 + 表单/JSON 编码余量)
|
||||
if c.Request.ContentLength > 441*1024 {
|
||||
response.Fail(c, http.StatusForbidden, "内容过多,建议采用文件形式")
|
||||
return
|
||||
}
|
||||
expireValue := body.ExpireValue
|
||||
if expireValue == 0 {
|
||||
expireValue = 1
|
||||
}
|
||||
expireStyle := body.ExpireStyle
|
||||
if expireStyle == "" {
|
||||
expireStyle = "day"
|
||||
}
|
||||
// 26.9:自定义提取码格式校验(4-8 位字母数字,空=随机)
|
||||
if err := validatePickupCode(body.Code); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
exp, err := resolveExpire(d.Cfg, expireValue, expireStyle)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
textSize := int64(len([]byte(text)))
|
||||
if textSize > 222*1024 {
|
||||
response.Fail(c, http.StatusForbidden, "内容过多,建议采用文件形式")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
token := "text:" + uuidHex()
|
||||
if err := reserveStorage(ctx, d.DB, d.Cfg, token, textSize, 300*time.Second); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
code, err := pickCustomCode(ctx, d.DB, d.Cfg, body.Code)
|
||||
if err == nil {
|
||||
fc := model.FileCodes{
|
||||
Code: code,
|
||||
Text: &text,
|
||||
Size: textSize,
|
||||
Prefix: "Text",
|
||||
ExpiredAt: exp.ExpiredAt,
|
||||
ExpiredCount: exp.ExpiredCount,
|
||||
UsedCount: exp.UsedCount,
|
||||
Engine: d.Store.CurrentName(), // 26.9:归属引擎戳(文本也记录,保持一致性)
|
||||
}
|
||||
err = d.DB.WithContext(ctx).Create(&fc).Error
|
||||
}
|
||||
err = mapCodeConflict(err) // 26.9:自定义码唯一索引冲突 → 友好 400
|
||||
releaseStorage(ctx, d.DB, token)
|
||||
if err != nil {
|
||||
auditRecordFailed(c, d.AuditSvc, "文本分享创建失败")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
d.Limiter.Add(c, middleware.LimitUpload) // 上传成功才计数
|
||||
auditUploadEntry(c, code, "Text", textSize, textSize)
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
response.OK(c, gin.H{"code": code})
|
||||
}
|
||||
|
||||
// ============ POST /share/file 文件分享 ============
|
||||
|
||||
// shareFile 上传文件并创建分享(对齐参考 share_file)。
|
||||
func (d *Deps) shareFile(c *gin.Context) {
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
if !requireUploadLimit(c, d.Limiter) {
|
||||
return
|
||||
}
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
auditRecordFailed(c, d.AuditSvc, "缺少 file 字段")
|
||||
response.Fail(c, http.StatusBadRequest, "缺少上传文件 file 字段")
|
||||
return
|
||||
}
|
||||
origName := fh.Filename
|
||||
// 26.9 需求 ④⑩:动态策略校验(max_file_size,0=回落 uploadSize)
|
||||
if err := d.CurrentUploadPolicy().CheckSize(fh.Size); err != nil {
|
||||
auditUploadEntry(c, "", origName, fh.Size, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "大小超过限制")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
expireValue := formInt(c, "expire_value", 1)
|
||||
expireStyle := c.DefaultPostForm("expire_style", "day")
|
||||
exp, err := resolveExpire(d.Cfg, expireValue, expireStyle)
|
||||
if err != nil {
|
||||
auditUploadEntry(c, "", origName, fh.Size, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "过期策略非法")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 26.9:自定义提取码(落盘前校验,失败快速返回不占容量预留)
|
||||
if err := validatePickupCode(c.PostForm("code")); err != nil {
|
||||
auditUploadEntry(c, "", origName, fh.Size, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "提取码非法")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// magic bytes 防伪造(读前 64 字节)
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
auditUploadEntry(c, "", origName, fh.Size, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件读取失败")
|
||||
response.Fail(c, http.StatusBadRequest, "文件读取失败")
|
||||
return
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
if err := validateFileMagic(d.Cfg, origName, fh.Header.Get("Content-Type"), readMultipartHeader(f, 64)); err != nil {
|
||||
auditUploadEntry(c, "", origName, fh.Size, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件类型被拒绝")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
dirPath, prefix, suffix, cleanName, savePath := buildSavePath(d.Cfg, origName, uuidHex())
|
||||
ctx := c.Request.Context()
|
||||
resToken := "file:" + uuidHex()
|
||||
if err := reserveStorage(ctx, d.DB, d.Cfg, resToken, fh.Size, time.Hour); err != nil {
|
||||
auditUploadEntry(c, "", origName, fh.Size, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "容量预留失败")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
code, err := pickCustomCode(ctx, d.DB, d.Cfg, c.PostForm("code"))
|
||||
if err == nil {
|
||||
if _, err = d.Store.SaveFile(ctx, f, savePath); err != nil {
|
||||
// 保存失败:清理半写文件
|
||||
_ = d.Store.DeleteFile(ctx, savePath)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
fc := model.FileCodes{
|
||||
Code: code,
|
||||
Prefix: prefix,
|
||||
Suffix: suffix,
|
||||
UUIDFileName: &cleanName,
|
||||
FilePath: &dirPath,
|
||||
Size: fh.Size,
|
||||
ExpiredAt: exp.ExpiredAt,
|
||||
ExpiredCount: exp.ExpiredCount,
|
||||
UsedCount: exp.UsedCount,
|
||||
Engine: d.Store.CurrentName(), // 26.9:归属引擎戳(下载按此取回)
|
||||
}
|
||||
if err = d.DB.WithContext(ctx).Create(&fc).Error; err != nil {
|
||||
err = mapCodeConflict(err) // 26.9
|
||||
// 记录创建失败:清理已落盘文件
|
||||
_ = d.Store.DeleteFile(ctx, savePath)
|
||||
} else {
|
||||
// 26.9:SHA512 内容去重(命中则复用旧对象并删除本次副本)
|
||||
d.applyDedup(ctx, d.Store, savePath, &fc)
|
||||
}
|
||||
} else {
|
||||
// 保存失败:尽力清理半写文件
|
||||
_ = d.Store.DeleteFile(ctx, savePath)
|
||||
}
|
||||
releaseStorage(ctx, d.DB, resToken)
|
||||
if err != nil {
|
||||
auditUploadEntry(c, "", origName, fh.Size, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件保存失败")
|
||||
if be, ok := err.(*apiError); ok && be.Status == http.StatusBadRequest {
|
||||
respondError(c, err) // 26.9:提取码冲突等业务 400 原样透出
|
||||
} else {
|
||||
respondError(c, mapStorageError(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
d.Limiter.Add(c, middleware.LimitUpload)
|
||||
auditUploadEntry(c, code, origName, fh.Size, fh.Size)
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
response.OK(c, gin.H{"code": code, "name": origName})
|
||||
}
|
||||
|
||||
// ============ GET/POST /share/metadata 分享元信息(不消耗次数)============
|
||||
|
||||
// shareMetadataGET 查询分享元信息(对齐参考 get_file_metadata / build_file_metadata)。
|
||||
func (d *Deps) shareMetadata(c *gin.Context) {
|
||||
d.metadataCommon(c, c.Query("code"))
|
||||
}
|
||||
|
||||
// shareMetadataPost JSON 体查询分享元信息(对齐参考 post_file_metadata)。
|
||||
func (d *Deps) shareMetadataPost(c *gin.Context) {
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := bindJSONOrForm(c, &body); err != nil {
|
||||
auditRecordFailed(c, d.AuditSvc, "请求体格式错误")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
d.metadataCommon(c, body.Code)
|
||||
}
|
||||
|
||||
// metadataCommon 元信息查询公共实现。
|
||||
func (d *Deps) metadataCommon(c *gin.Context, code string) {
|
||||
fc, err := d.lookupByCode(c, code, true)
|
||||
if err != nil {
|
||||
auditUploadEntry(c, strings.TrimSpace(code), "", 0, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, err.Error())
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
auditUploadEntry(c, fc.Code, fc.Prefix+fc.Suffix, fc.Size, 0)
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
response.OK(c, buildFileMetadata(fc))
|
||||
}
|
||||
|
||||
// buildFileMetadata 构造分享元信息(对齐参考 build_file_metadata,不暴露存储路径)。
|
||||
func buildFileMetadata(fc *model.FileCodes) gin.H {
|
||||
isText := fc.Text != nil
|
||||
var remaining any
|
||||
if fc.ExpiredCount > 0 {
|
||||
remaining = fc.ExpiredCount
|
||||
}
|
||||
var expiredAt any
|
||||
if fc.ExpiredAt != nil {
|
||||
expiredAt = fc.ExpiredAt.Format(time.RFC3339)
|
||||
}
|
||||
return gin.H{
|
||||
"code": fc.Code,
|
||||
"name": fc.Prefix + fc.Suffix,
|
||||
"size": fc.Size,
|
||||
"type": map[bool]string{true: "text", false: "file"}[isText],
|
||||
"is_text": isText,
|
||||
"created_at": fc.CreatedAt.Format(time.RFC3339),
|
||||
"expired_at": expiredAt,
|
||||
"expires_at": expiredAt,
|
||||
"expired_count": fc.ExpiredCount,
|
||||
"used_count": fc.UsedCount,
|
||||
"remaining_downloads": remaining,
|
||||
}
|
||||
}
|
||||
|
||||
// ============ GET /share/select 取件(消耗次数,流式下载)============
|
||||
|
||||
// shareSelect 取件:文本直接返回纯文本;文件流式返回(支持 Range,对齐参考 get_code_file)。
|
||||
func (d *Deps) shareSelect(c *gin.Context) {
|
||||
// error 类限流:进入即检查(对齐参考 Depends(ip_limit["error"]))
|
||||
if allowed, _ := d.Limiter.Check(c, middleware.LimitError); !allowed {
|
||||
response.Fail(c, http.StatusLocked, "请求次数过多,请稍后再试")
|
||||
return
|
||||
}
|
||||
code := strings.TrimSpace(c.Query("code"))
|
||||
fc, err := d.lookupByCode(c, code, true)
|
||||
if err != nil {
|
||||
d.Limiter.Add(c, middleware.LimitError) // 取件失败计数
|
||||
auditUploadEntry(c, code, "", 0, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, err.Error())
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
if !d.consumeUsage(c, fc) {
|
||||
auditUploadEntry(c, fc.Code, fc.Prefix+fc.Suffix, fc.Size, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件已过期")
|
||||
response.Fail(c, http.StatusNotFound, "文件已过期")
|
||||
return
|
||||
}
|
||||
if fc.Text != nil {
|
||||
// 文本分享:text/plain 响应(对齐参考 Response(content=text, media_type=text/plain))
|
||||
name := fc.Prefix + suffixOrTxt(fc)
|
||||
auditUploadEntry(c, fc.Code, name, fc.Size, int64(len(*fc.Text)))
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
c.Header("Content-Disposition", contentDisposition(name))
|
||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(*fc.Text))
|
||||
return
|
||||
}
|
||||
d.serveFile(c, fc)
|
||||
}
|
||||
|
||||
func suffixOrTxt(fc *model.FileCodes) string {
|
||||
if fc.Suffix != "" {
|
||||
return fc.Suffix
|
||||
}
|
||||
return ".txt"
|
||||
}
|
||||
|
||||
// ============ POST /share/select 取件详情(JSON,对齐参考 select_file)============
|
||||
|
||||
// shareSelectPost 返回分享详情 JSON(元信息+内容/下载地址,对齐参考 select_file)。
|
||||
// 有次数限制的文件返回代理下载地址(消耗发生在 download 时),其余在本次消耗。
|
||||
func (d *Deps) shareSelectPost(c *gin.Context) {
|
||||
if allowed, _ := d.Limiter.Check(c, middleware.LimitError); !allowed {
|
||||
response.Fail(c, http.StatusLocked, "请求次数过多,请稍后再试")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := bindJSONOrForm(c, &body); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
fc, err := d.lookupByCode(c, body.Code, true)
|
||||
if err != nil {
|
||||
d.Limiter.Add(c, middleware.LimitError)
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
detail := buildFileMetadata(fc)
|
||||
var downloadURL string
|
||||
if fc.Text != nil {
|
||||
detail["text"] = *fc.Text
|
||||
detail["content"] = *fc.Text
|
||||
} else if fc.ExpiredCount >= 0 {
|
||||
// 有次数限制:必须经代理下载接口扣次数
|
||||
downloadURL = d.proxyDownloadURL(fc.Code)
|
||||
detail["text"] = downloadURL
|
||||
} else {
|
||||
// 时间型/永久:优先引擎直链(如 S3 预签名),不支持则代理
|
||||
if url, err := d.Store.PresignGetURL(c.Request.Context(), fileSavePath(fc), 3600); err == nil {
|
||||
downloadURL = url
|
||||
} else {
|
||||
downloadURL = d.proxyDownloadURL(fc.Code)
|
||||
}
|
||||
detail["text"] = downloadURL
|
||||
}
|
||||
detail["download_url"] = nil
|
||||
if downloadURL != "" {
|
||||
detail["download_url"] = downloadURL
|
||||
}
|
||||
|
||||
// 仅当下载地址不是代理地址时在本次消耗次数(对齐参考 consumes_on_download 判定)
|
||||
consumesOnDownload := strings.HasPrefix(downloadURL, "/share/download?")
|
||||
if !consumesOnDownload {
|
||||
if !d.consumeUsage(c, fc) {
|
||||
response.Fail(c, http.StatusNotFound, "文件已过期")
|
||||
return
|
||||
}
|
||||
for k, v := range buildFileMetadata(fc) {
|
||||
detail[k] = v
|
||||
}
|
||||
}
|
||||
response.OK(c, detail)
|
||||
}
|
||||
|
||||
// proxyDownloadURL 生成代理下载地址(对齐参考 get_file_url)。
|
||||
func (d *Deps) proxyDownloadURL(code string) string {
|
||||
secret := d.jwtSecret()
|
||||
return "/share/download?key=" + GetSelectToken(code, secret, 0) + "&code=" + code
|
||||
}
|
||||
|
||||
// ============ GET /share/download 代理下载(token 鉴权,消耗次数)============
|
||||
|
||||
// shareDownload 代理下载(对齐参考 download_file):
|
||||
// key 为 GetSelectToken 生成的窗口令牌,同时接受当前与上一窗口。
|
||||
func (d *Deps) shareDownload(c *gin.Context) {
|
||||
key := c.Query("key")
|
||||
code := strings.TrimSpace(c.Query("code"))
|
||||
|
||||
secret := d.jwtSecret()
|
||||
// L2:HMAC 令牌 + 常量时间比较
|
||||
if key == "" || secret == "" || !VerifySelectToken(code, secret, key) {
|
||||
d.Limiter.Add(c, middleware.LimitError)
|
||||
auditUploadEntry(c, code, "", 0, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "下载鉴权失败")
|
||||
response.Fail(c, http.StatusForbidden, "下载鉴权失败")
|
||||
return
|
||||
}
|
||||
fc, err := d.lookupByCode(c, code, true)
|
||||
if err != nil {
|
||||
auditUploadEntry(c, code, "", 0, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, err.Error())
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
if !d.consumeUsage(c, fc) {
|
||||
auditUploadEntry(c, fc.Code, fc.Prefix+fc.Suffix, fc.Size, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件已过期")
|
||||
response.Fail(c, http.StatusNotFound, "文件已过期")
|
||||
return
|
||||
}
|
||||
if fc.Text != nil {
|
||||
auditUploadEntry(c, fc.Code, fc.Prefix+fc.Suffix, fc.Size, int64(len(*fc.Text)))
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
response.OK(c, *fc.Text)
|
||||
return
|
||||
}
|
||||
d.serveFile(c, fc)
|
||||
}
|
||||
|
||||
// ============ 文件流式下载(含 Range)============
|
||||
|
||||
// parseRangeHeader 解析 Range 头(仅 bytes 单区间,多区间/单位错误返回 nil 交由全量处理;
|
||||
// 合法但越界由引擎返回 ErrRangeNotSatisfiable→416)。
|
||||
func parseRangeHeader(header string, totalSize int64) *storage.Range {
|
||||
header = strings.TrimSpace(header)
|
||||
if header == "" || !strings.HasPrefix(header, "bytes=") {
|
||||
return nil
|
||||
}
|
||||
spec := strings.TrimPrefix(header, "bytes=")
|
||||
if strings.Contains(spec, ",") { // 多区间不支持,回退全量
|
||||
return nil
|
||||
}
|
||||
dash := strings.Index(spec, "-")
|
||||
if dash < 0 {
|
||||
return nil
|
||||
}
|
||||
startStr, endStr := strings.TrimSpace(spec[:dash]), strings.TrimSpace(spec[dash+1:])
|
||||
if startStr == "" {
|
||||
// bytes=-N:后缀区间
|
||||
n, err := strconv.ParseInt(endStr, 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return nil
|
||||
}
|
||||
if totalSize > 0 && n >= totalSize {
|
||||
n = totalSize
|
||||
}
|
||||
return &storage.Range{Start: totalSize - n, End: -1}
|
||||
}
|
||||
start, err := strconv.ParseInt(startStr, 10, 64)
|
||||
if err != nil || start < 0 {
|
||||
return nil
|
||||
}
|
||||
if endStr == "" {
|
||||
return &storage.Range{Start: start, End: -1}
|
||||
}
|
||||
end, err := strconv.ParseInt(endStr, 10, 64)
|
||||
if err != nil || end < start {
|
||||
return nil
|
||||
}
|
||||
return &storage.Range{Start: start, End: end}
|
||||
}
|
||||
|
||||
// serveFile 打开存储流并写响应(200 全量 / 206 区间,自动 Accept-Ranges/Content-Range)。
|
||||
// 响应字节数由审计中间件包装的 Writer 自动统计。
|
||||
func (d *Deps) serveFile(c *gin.Context, fc *model.FileCodes) {
|
||||
ctx := c.Request.Context()
|
||||
savePath := fileSavePath(fc)
|
||||
name := fc.Prefix + fc.Suffix
|
||||
|
||||
// 26.9:按文件归属引擎取回(切换引擎后旧文件仍可下载);空戳=历史数据回落当前引擎
|
||||
store, err := d.storeFor(fc.Engine)
|
||||
if err != nil {
|
||||
auditUploadEntry(c, fc.Code, name, fc.Size, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "存储引擎不可用: "+err.Error())
|
||||
respondError(c, mapStorageError(err))
|
||||
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 {
|
||||
total = meta.Size
|
||||
}
|
||||
|
||||
rng := parseRangeHeader(c.GetHeader("Range"), total)
|
||||
dl, err := store.Open(ctx, savePath, rng)
|
||||
if err != nil {
|
||||
auditUploadEntry(c, fc.Code, name, fc.Size, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件读取失败")
|
||||
respondError(c, mapStorageError(err))
|
||||
return
|
||||
}
|
||||
defer func() { _ = dl.Close() }()
|
||||
|
||||
if dl.Total >= 0 {
|
||||
total = dl.Total
|
||||
}
|
||||
c.Header("Accept-Ranges", "bytes")
|
||||
c.Header("Content-Disposition", contentDisposition(name))
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
|
||||
status := http.StatusOK
|
||||
if rng != nil {
|
||||
end := dl.End
|
||||
if end < 0 && total >= 0 {
|
||||
end = total - 1
|
||||
}
|
||||
if end < dl.Start {
|
||||
auditUploadEntry(c, fc.Code, name, total, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "请求范围超出文件大小")
|
||||
c.Header("Content-Range", fmt.Sprintf("bytes */%d", total))
|
||||
response.Fail(c, http.StatusRequestedRangeNotSatisfiable, "请求范围超出文件大小")
|
||||
return
|
||||
}
|
||||
status = http.StatusPartialContent
|
||||
c.Header("Content-Range", fmt.Sprintf("bytes %d-%d/%d", dl.Start, end, total))
|
||||
c.Header("Content-Length", strconv.FormatInt(end-dl.Start+1, 10))
|
||||
} else if total >= 0 {
|
||||
c.Header("Content-Length", strconv.FormatInt(total, 10))
|
||||
}
|
||||
auditUploadEntry(c, fc.Code, name, total, 0)
|
||||
c.Status(status)
|
||||
// 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
|
||||
}
|
||||
if e.SizeBytes == 0 {
|
||||
e.SizeBytes = total
|
||||
}
|
||||
})
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
}
|
||||
|
||||
// formInt 读取表单整数(缺省 default 值,非法值亦回退 default)。
|
||||
func formInt(c *gin.Context, key string, def int) int {
|
||||
raw := c.PostForm(key)
|
||||
if raw == "" {
|
||||
raw = c.Query(key)
|
||||
}
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"fileshare/internal/response"
|
||||
web "fileshare/web"
|
||||
)
|
||||
|
||||
// registerWeb 注册前端静态资源与 SPA 回退(必须最后注册):
|
||||
// - 静态资源命中 web/dist 内文件则直接服务(带 Immutable 缓存,html 不缓存);
|
||||
// - 未命中且为 GET/HEAD 且非 /api 前缀:回退 index.html(前端 history 路由
|
||||
// /s/:code、/admin/*、/docs、/openapi 由 SPA 接管);
|
||||
// - /api/* 未命中路由:JSON 404(避免调试时拿到 HTML 掩盖真实错误)。
|
||||
func registerWeb(r *gin.Engine, d *Deps) {
|
||||
dist, err := web.Dist()
|
||||
if err != nil {
|
||||
return // 嵌入异常时跳过(API 仍可用)
|
||||
}
|
||||
fileServer := http.StripPrefix("/", http.FileServer(http.FS(dist)))
|
||||
indexHTML := readIndexHTML(dist)
|
||||
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
p := c.Request.URL.Path
|
||||
// 1. API 未命中:JSON 404
|
||||
if strings.HasPrefix(p, "/api/") || p == "/api" {
|
||||
response.Fail(c, http.StatusNotFound, "接口不存在")
|
||||
return
|
||||
}
|
||||
// 2. 静态资源命中:直接服务
|
||||
if p != "/" {
|
||||
clean := strings.TrimPrefix(path.Clean(p), "/")
|
||||
if clean != "" {
|
||||
if f, err := dist.Open(clean); err == nil {
|
||||
_ = f.Close()
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. SPA 回退:仅 GET/HEAD 且接受 HTML
|
||||
if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead {
|
||||
accept := c.GetHeader("Accept")
|
||||
if accept == "" || strings.Contains(accept, "text/html") || strings.Contains(accept, "*/*") {
|
||||
if indexHTML != nil {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", indexHTML)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 非 HTML 请求未命中:普通 404
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
// readIndexHTML 读取嵌入的 index.html(SPA 回退用)。
|
||||
func readIndexHTML(dist fs.FS) []byte {
|
||||
f, err := dist.Open("index.html")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
data, err := fs.ReadFile(dist, "index.html")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// Package audit 提供上传/下载审计日志服务(需求 ③):
|
||||
// 记录操作时间/IP/UA/设备解析/动作/结果/字节数/耗时,落库 Postgres。
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"fileshare/internal/model"
|
||||
)
|
||||
|
||||
// Service 审计日志服务。
|
||||
type Service struct {
|
||||
sink Sink
|
||||
}
|
||||
|
||||
// Sink 审计落库抽象(生产为 Postgres,测试为内存实现)。
|
||||
type Sink interface {
|
||||
// Save 批量落库。
|
||||
Save(ctx context.Context, logs []model.AuditLog) error
|
||||
}
|
||||
|
||||
// DBSink 基于 GORM 的落库实现。
|
||||
type DBSink struct{ db *gorm.DB }
|
||||
|
||||
// NewDBSink 构造数据库落库实现。
|
||||
func NewDBSink(db *gorm.DB) *DBSink { return &DBSink{db: db} }
|
||||
|
||||
// Save 批量插入审计记录。
|
||||
func (s *DBSink) Save(ctx context.Context, logs []model.AuditLog) error {
|
||||
if len(logs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.WithContext(ctx).CreateInBatches(&logs, 200).Error
|
||||
}
|
||||
|
||||
// NewService 构造审计服务。
|
||||
func NewService(sink Sink) *Service {
|
||||
return &Service{sink: sink}
|
||||
}
|
||||
|
||||
// Entry 一次待落库的审计事件。
|
||||
type Entry struct {
|
||||
Action string // upload | download
|
||||
FileCode string // 取件码
|
||||
FileName string // 原始文件名
|
||||
SizeBytes int64 // 文件总字节数
|
||||
TransferredBytes int64 // 实际传输字节数
|
||||
IP string // 客户端 IP
|
||||
UserAgent string // User-Agent
|
||||
DeviceOS string // 操作系统
|
||||
DeviceBrowser string // 浏览器
|
||||
DeviceType string // desktop/mobile/tablet/bot/other
|
||||
Actor string // admin | guest
|
||||
Result string // success | denied | failed
|
||||
ErrorMsg string // 失败原因
|
||||
Duration time.Duration // 耗时
|
||||
}
|
||||
|
||||
// Record 异步写入一条审计日志:先尝试同步落库,失败时进入内存缓冲等待重试,
|
||||
// 避免审计失败影响主请求,也避免高峰期阻塞。
|
||||
func (s *Service) Record(entry Entry) {
|
||||
record := model.AuditLog{
|
||||
Action: entry.Action,
|
||||
FileCode: truncate(entry.FileCode, 64),
|
||||
FileName: truncate(entry.FileName, 255),
|
||||
SizeBytes: entry.SizeBytes,
|
||||
TransferredBytes: entry.TransferredBytes,
|
||||
IP: truncate(entry.IP, 64),
|
||||
UserAgent: truncate(entry.UserAgent, 512),
|
||||
DeviceOS: truncate(entry.DeviceOS, 64),
|
||||
DeviceBrowser: truncate(entry.DeviceBrowser, 64),
|
||||
DeviceType: truncate(entry.DeviceType, 32),
|
||||
Actor: truncate(entry.Actor, 64),
|
||||
Result: normalizeResult(entry.Result),
|
||||
ErrorMsg: truncate(entry.ErrorMsg, 512),
|
||||
DurationMs: entry.Duration.Milliseconds(),
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.sink.Save(ctx, []model.AuditLog{record}); err != nil {
|
||||
log.Printf("[audit] 审计日志落库失败,进入重试队列: %v", err)
|
||||
s.enqueue(record)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// retryBuf 落库失败时的内存重试缓冲。
|
||||
var retryBuf struct {
|
||||
sync.Mutex
|
||||
items []model.AuditLog
|
||||
}
|
||||
|
||||
const maxRetryBuffer = 10000
|
||||
|
||||
// enqueue 入队;超出上限时丢弃最旧的,防止内存无限增长。
|
||||
func (s *Service) enqueue(record model.AuditLog) {
|
||||
retryBuf.Lock()
|
||||
if len(retryBuf.items) >= maxRetryBuffer {
|
||||
retryBuf.items = retryBuf.items[1:]
|
||||
}
|
||||
retryBuf.items = append(retryBuf.items, record)
|
||||
retryBuf.Unlock()
|
||||
}
|
||||
|
||||
// FlushRetry 将缓冲中的审计日志重新落库;由后台定时任务调用。
|
||||
func (s *Service) FlushRetry() {
|
||||
retryBuf.Lock()
|
||||
if len(retryBuf.items) == 0 {
|
||||
retryBuf.Unlock()
|
||||
return
|
||||
}
|
||||
items := retryBuf.items
|
||||
retryBuf.items = nil
|
||||
retryBuf.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := s.sink.Save(ctx, items); err != nil {
|
||||
log.Printf("[audit] 重试队列落库失败: %v", err)
|
||||
// 失败则放回队首
|
||||
retryBuf.Lock()
|
||||
retryBuf.items = append(items, retryBuf.items...)
|
||||
if len(retryBuf.items) > maxRetryBuffer {
|
||||
retryBuf.items = retryBuf.items[:maxRetryBuffer]
|
||||
}
|
||||
retryBuf.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// StartRetryLoop 启动后台重试循环。
|
||||
func (s *Service) StartRetryLoop(stop <-chan struct{}) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
s.FlushRetry()
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.FlushRetry()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Query 按条件分页查询审计日志(管理端使用)。
|
||||
// action/ip/result 为可选过滤;begin/end 为创建时间范围(可选)。
|
||||
func (s *Service) Query(page, pageSize int, action, ip, result string, begin, end *time.Time) ([]model.AuditLog, int64, error) {
|
||||
dbSink, ok := s.sink.(*DBSink)
|
||||
if !ok {
|
||||
return nil, 0, errors.New("audit: 当前 sink 不支持查询")
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 200 {
|
||||
pageSize = 20
|
||||
}
|
||||
q := dbSink.db.Model(&model.AuditLog{})
|
||||
if action != "" {
|
||||
q = q.Where("action = ?", action)
|
||||
}
|
||||
if ip != "" {
|
||||
q = q.Where("ip = ?", ip)
|
||||
}
|
||||
if result != "" {
|
||||
q = q.Where("result = ?", result)
|
||||
}
|
||||
if begin != nil {
|
||||
q = q.Where("created_at >= ?", *begin)
|
||||
}
|
||||
if end != nil {
|
||||
q = q.Where("created_at <= ?", *end)
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var logs []model.AuditLog
|
||||
err := q.Order("id DESC").
|
||||
Offset((page - 1) * pageSize).
|
||||
Limit(pageSize).
|
||||
Find(&logs).Error
|
||||
return logs, total, err
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
func normalizeResult(r string) string {
|
||||
switch strings.TrimSpace(r) {
|
||||
case model.AuditResultSuccess, model.AuditResultDenied, model.AuditResultFailed:
|
||||
return strings.TrimSpace(r)
|
||||
case "":
|
||||
return model.AuditResultFailed
|
||||
default:
|
||||
return model.AuditResultFailed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DeviceInfo 从 User-Agent 解析出的设备信息。
|
||||
type DeviceInfo struct {
|
||||
OS string // Windows/macOS/Android/iOS/Linux/Unknown
|
||||
Browser string // Chrome/Firefox/Safari/Edge/Other
|
||||
Type string // desktop/mobile/tablet/bot/other
|
||||
}
|
||||
|
||||
// 动作常量。
|
||||
const (
|
||||
ActionUpload = "upload"
|
||||
ActionDownload = "download"
|
||||
// ActionAdmin 管理端敏感操作(登录/登出/配置/密码/引擎切换/文件删除等),
|
||||
// L5:纳入审计以便追溯登录失败与配置变更。
|
||||
ActionAdmin = "admin"
|
||||
)
|
||||
|
||||
// 角色常量。
|
||||
const (
|
||||
ActorAdmin = "admin"
|
||||
ActorGuest = "guest"
|
||||
)
|
||||
|
||||
// botKeywords 常见爬虫/机器人标识。
|
||||
var botKeywords = []string{"bot", "spider", "crawl", "slurp", "curl/", "wget", "python-requests", "go-http-client"}
|
||||
|
||||
// ParseUserAgent 解析 User-Agent 为设备信息(轻量规则,避免引入重依赖)。
|
||||
func ParseUserAgent(ua string) DeviceInfo {
|
||||
ua = strings.TrimSpace(ua)
|
||||
lower := strings.ToLower(ua)
|
||||
if ua == "" {
|
||||
return DeviceInfo{OS: "Unknown", Browser: "Other", Type: "other"}
|
||||
}
|
||||
for _, kw := range botKeywords {
|
||||
if strings.Contains(lower, kw) {
|
||||
return DeviceInfo{OS: "Unknown", Browser: "Other", Type: "bot"}
|
||||
}
|
||||
}
|
||||
|
||||
info := DeviceInfo{OS: "Unknown", Browser: "Other", Type: "desktop"}
|
||||
|
||||
// 操作系统
|
||||
switch {
|
||||
case strings.Contains(lower, "windows"):
|
||||
info.OS = "Windows"
|
||||
case strings.Contains(lower, "iphone"), strings.Contains(lower, "ipod"):
|
||||
info.OS = "iOS"
|
||||
info.Type = "mobile"
|
||||
case strings.Contains(lower, "ipad"):
|
||||
info.OS = "iOS"
|
||||
info.Type = "tablet"
|
||||
case strings.Contains(lower, "mac os x"), strings.Contains(lower, "macintosh"):
|
||||
info.OS = "macOS"
|
||||
case strings.Contains(lower, "android"):
|
||||
info.OS = "Android"
|
||||
info.Type = "mobile"
|
||||
if strings.Contains(lower, "tablet") || !strings.Contains(lower, "mobile") {
|
||||
info.Type = "tablet"
|
||||
}
|
||||
case strings.Contains(lower, "linux"), strings.Contains(lower, "ubuntu"), strings.Contains(lower, "fedora"):
|
||||
info.OS = "Linux"
|
||||
}
|
||||
|
||||
// 浏览器(顺序重要:Edge/OPR 必须在 Chrome 之前判断)
|
||||
switch {
|
||||
case strings.Contains(lower, "edg/"), strings.Contains(lower, "edge/"):
|
||||
info.Browser = "Edge"
|
||||
case strings.Contains(lower, "opr/"), strings.Contains(lower, "opera"):
|
||||
info.Browser = "Opera"
|
||||
case strings.Contains(lower, "chrome/"), strings.Contains(lower, "crios/"):
|
||||
info.Browser = "Chrome"
|
||||
case strings.Contains(lower, "firefox/"), strings.Contains(lower, "fxios/"):
|
||||
info.Browser = "Firefox"
|
||||
case strings.Contains(lower, "safari/"):
|
||||
info.Browser = "Safari"
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package audit
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseUserAgent(t *testing.T) {
|
||||
cases := []struct {
|
||||
ua string
|
||||
os string
|
||||
browser string
|
||||
typ string
|
||||
}{
|
||||
{
|
||||
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
os: "Windows", browser: "Chrome", typ: "desktop",
|
||||
},
|
||||
{
|
||||
ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
||||
os: "iOS", browser: "Safari", typ: "mobile",
|
||||
},
|
||||
{
|
||||
ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0",
|
||||
os: "macOS", browser: "Edge", typ: "desktop",
|
||||
},
|
||||
{
|
||||
ua: "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
|
||||
os: "Android", browser: "Chrome", typ: "mobile",
|
||||
},
|
||||
{
|
||||
ua: "curl/8.4.0",
|
||||
os: "Unknown", browser: "Other", typ: "bot",
|
||||
},
|
||||
{
|
||||
ua: "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0",
|
||||
os: "Linux", browser: "Firefox", typ: "desktop",
|
||||
},
|
||||
{ua: "", os: "Unknown", browser: "Other", typ: "other"},
|
||||
}
|
||||
for i, tc := range cases {
|
||||
got := ParseUserAgent(tc.ua)
|
||||
if got.OS != tc.os || got.Browser != tc.browser || got.Type != tc.typ {
|
||||
t.Errorf("case %d: ParseUserAgent(%q) = %+v, want os=%s browser=%s type=%s",
|
||||
i, tc.ua, got, tc.os, tc.browser, tc.typ)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
// Package cache 提供统一缓存接口:FCB_REDIS_ADDR 未配置时自动降级为进程内存实现,
|
||||
// 用于 IP 限流计数与热点配置缓存(需求 ② 的可选 Redis 增强)。
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNotFound 表示键不存在。
|
||||
var ErrNotFound = errors.New("cache: key 不存在")
|
||||
|
||||
// Cache 缓存统一接口。
|
||||
type Cache interface {
|
||||
// Get 读取字符串值;键不存在返回 ErrNotFound。
|
||||
Get(ctx context.Context, key string) (string, error)
|
||||
// Set 写入字符串值,ttl<=0 表示不过期。
|
||||
Set(ctx context.Context, key, value string, ttl time.Duration) error
|
||||
// Delete 删除键。
|
||||
Delete(ctx context.Context, keys ...string) error
|
||||
// Exists 判断键是否存在。
|
||||
Exists(ctx context.Context, key string) (bool, error)
|
||||
// Incr 原子自增;键不存在时从 0 开始并设置 ttl 窗口(限流固定窗口用)。
|
||||
Incr(ctx context.Context, key string, ttl time.Duration) (int64, error)
|
||||
// Close 释放底层资源(Redis 连接;内存实现为空操作)。
|
||||
Close() error
|
||||
}
|
||||
|
||||
// RedisOptions Redis 连接参数(addr 为空 → 内存实现;db 为 FCB_REDIS_DB 库号)。
|
||||
type RedisOptions struct {
|
||||
Addr string
|
||||
DB int // 逻辑库号 0-15(cluster 模式忽略)
|
||||
}
|
||||
|
||||
// New 按配置构造缓存实现:redisAddr 为空 → 内存实现。
|
||||
func New(ctx context.Context, opt RedisOptions) (Cache, error) {
|
||||
if opt.Addr == "" {
|
||||
return NewMemory(), nil
|
||||
}
|
||||
return NewRedis(ctx, opt.Addr, opt.DB)
|
||||
}
|
||||
Vendored
+81
@@ -0,0 +1,81 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMemoryCacheSetGet(t *testing.T) {
|
||||
c := NewMemory()
|
||||
defer c.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := c.Set(ctx, "k1", "v1", 0); err != nil {
|
||||
t.Fatalf("Set 失败: %v", err)
|
||||
}
|
||||
v, err := c.Get(ctx, "k1")
|
||||
if err != nil || v != "v1" {
|
||||
t.Fatalf("Get = (%q, %v)", v, err)
|
||||
}
|
||||
if _, err := c.Get(ctx, "missing"); err != ErrNotFound {
|
||||
t.Fatalf("缺失键应返回 ErrNotFound: %v", err)
|
||||
}
|
||||
_ = c.Delete(ctx, "k1")
|
||||
if _, err := c.Get(ctx, "k1"); err != ErrNotFound {
|
||||
t.Fatal("删除后应不存在")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryCacheTTL(t *testing.T) {
|
||||
c := NewMemory()
|
||||
defer c.Close()
|
||||
ctx := context.Background()
|
||||
_ = c.Set(ctx, "ttl", "x", 50*time.Millisecond)
|
||||
if ok, _ := c.Exists(ctx, "ttl"); !ok {
|
||||
t.Fatal("TTL 内应存在")
|
||||
}
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
if _, err := c.Get(ctx, "ttl"); err != ErrNotFound {
|
||||
t.Fatal("过期后应不存在")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryCacheIncrWindow(t *testing.T) {
|
||||
c := NewMemory()
|
||||
defer c.Close()
|
||||
ctx := context.Background()
|
||||
for i := int64(1); i <= 3; i++ {
|
||||
n, err := c.Incr(ctx, "rl", time.Minute)
|
||||
if err != nil || n != i {
|
||||
t.Fatalf("Incr = (%d, %v), want (%d, nil)", n, err, i)
|
||||
}
|
||||
}
|
||||
// 窗口过期后重新计数
|
||||
_ = c.Set(ctx, "short", "seed", time.Millisecond)
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
n, err := c.Incr(ctx, "short", time.Millisecond)
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("过期窗口重置失败: (%d, %v)", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryCacheConcurrentIncr(t *testing.T) {
|
||||
c := NewMemory()
|
||||
defer c.Close()
|
||||
ctx := context.Background()
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = c.Incr(ctx, "cnt", time.Minute)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
n, _ := c.Incr(ctx, "cnt", time.Minute)
|
||||
if n != 51 {
|
||||
t.Fatalf("并发计数丢失: %d != 51", n)
|
||||
}
|
||||
}
|
||||
Vendored
+159
@@ -0,0 +1,159 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// memoryItem 内存缓存条目。
|
||||
type memoryItem struct {
|
||||
value string
|
||||
expiresAt time.Time // 零值表示不过期
|
||||
}
|
||||
|
||||
// MemoryCache 进程内存缓存实现(单机、无持久化)。
|
||||
type MemoryCache struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]memoryItem
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewMemory 构造内存缓存,并启动后台过期清理。
|
||||
func NewMemory() *MemoryCache {
|
||||
m := &MemoryCache{
|
||||
items: make(map[string]memoryItem),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go m.gcLoop()
|
||||
return m
|
||||
}
|
||||
|
||||
// gcLoop 每分钟清理一次过期键,避免长期运行内存膨胀。
|
||||
func (m *MemoryCache) gcLoop() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-m.done:
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
m.mu.Lock()
|
||||
for k, item := range m.items {
|
||||
if !item.expiresAt.IsZero() && now.After(item.expiresAt) {
|
||||
delete(m.items, k)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get 读取键值。
|
||||
func (m *MemoryCache) Get(_ context.Context, key string) (string, error) {
|
||||
m.mu.RLock()
|
||||
item, ok := m.items[key]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if !item.expiresAt.IsZero() && time.Now().After(item.expiresAt) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return item.value, nil
|
||||
}
|
||||
|
||||
// Set 写入键值。
|
||||
func (m *MemoryCache) Set(_ context.Context, key, value string, ttl time.Duration) error {
|
||||
item := memoryItem{value: value}
|
||||
if ttl > 0 {
|
||||
item.expiresAt = time.Now().Add(ttl)
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.items[key] = item
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除键。
|
||||
func (m *MemoryCache) Delete(_ context.Context, keys ...string) error {
|
||||
m.mu.Lock()
|
||||
for _, k := range keys {
|
||||
delete(m.items, k)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exists 判断键是否存在。
|
||||
func (m *MemoryCache) Exists(_ context.Context, key string) (bool, error) {
|
||||
_, err := m.Get(context.Background(), key)
|
||||
return err == nil, nil
|
||||
}
|
||||
|
||||
// Incr 原子自增;首次创建时记录窗口起点(以过期时间体现)。
|
||||
func (m *MemoryCache) Incr(_ context.Context, key string, ttl time.Duration) (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
now := time.Now()
|
||||
item, ok := m.items[key]
|
||||
if ok && !item.expiresAt.IsZero() && now.After(item.expiresAt) {
|
||||
// 窗口已过期,重新计数
|
||||
ok = false
|
||||
}
|
||||
var n int64
|
||||
if !ok {
|
||||
n = 1
|
||||
newItem := memoryItem{value: "1"}
|
||||
if ttl > 0 {
|
||||
newItem.expiresAt = now.Add(ttl)
|
||||
}
|
||||
m.items[key] = newItem
|
||||
return n, nil
|
||||
}
|
||||
// 解析现有值
|
||||
for _, c := range item.value {
|
||||
if c < '0' || c > '9' {
|
||||
n = 0
|
||||
break
|
||||
}
|
||||
n = n*10 + int64(c-'0')
|
||||
}
|
||||
n++
|
||||
newItem := memoryItem{value: itoa(n), expiresAt: item.expiresAt}
|
||||
m.items[key] = newItem
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Close 停止清理协程。
|
||||
func (m *MemoryCache) Close() error {
|
||||
select {
|
||||
case <-m.done:
|
||||
default:
|
||||
close(m.done)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// itoa 简单整数转字符串,避免在锁内依赖 strconv 的额外开销(数值都很小)。
|
||||
func itoa(n int64) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
Vendored
+117
@@ -0,0 +1,117 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RedisCache 基于 Redis 的缓存实现(可选增强)。
|
||||
type RedisCache struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
// NewRedis 连接 Redis 并校验可用性。addr 支持两种形式:
|
||||
// - host:port(纯地址,库号由 db 参数指定)
|
||||
// - redis://[:password@]host:port[/db](URL 形式,URL 中的库号优先于 db 参数)
|
||||
func NewRedis(ctx context.Context, addr string, db int) (*RedisCache, error) {
|
||||
opts, err := buildRedisOptions(addr, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := redis.NewClient(opts)
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
_ = client.Close()
|
||||
return nil, fmt.Errorf("cache: Redis 连接失败 %s: %w", addr, err)
|
||||
}
|
||||
return &RedisCache{client: client}, nil
|
||||
}
|
||||
|
||||
// buildRedisOptions 构造 go-redis 连接选项(纯地址 / URL 形式统一入口)。
|
||||
func buildRedisOptions(addr string, db int) (*redis.Options, error) {
|
||||
opts := &redis.Options{
|
||||
Addr: addr,
|
||||
DB: db,
|
||||
DialTimeout: 5 * time.Second,
|
||||
ReadTimeout: 3 * time.Second,
|
||||
WriteTimeout: 3 * time.Second,
|
||||
PoolSize: 32,
|
||||
}
|
||||
if strings.HasPrefix(addr, "redis://") || strings.HasPrefix(addr, "rediss://") {
|
||||
u, err := redis.ParseURL(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cache: Redis 地址解析失败 %s: %w", addr, err)
|
||||
}
|
||||
// URL 未显式携带库号(路径为空或 /)时用 db 参数;显式 /N 优先
|
||||
if u.DB == 0 && !urlHasDBPath(addr) {
|
||||
u.DB = db
|
||||
}
|
||||
u.DialTimeout = opts.DialTimeout
|
||||
u.ReadTimeout = opts.ReadTimeout
|
||||
u.WriteTimeout = opts.WriteTimeout
|
||||
u.PoolSize = opts.PoolSize
|
||||
opts = u
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
// urlHasDBPath 判断 redis:// URL 是否显式携带了库号路径(如 /5)。
|
||||
func urlHasDBPath(raw string) bool {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Trim(u.Path, "/") != ""
|
||||
}
|
||||
|
||||
// Get 读取键值。
|
||||
func (r *RedisCache) Get(ctx context.Context, key string) (string, error) {
|
||||
val, err := r.client.Get(ctx, key).Result()
|
||||
if err == redis.Nil {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return val, err
|
||||
}
|
||||
|
||||
// Set 写入键值。
|
||||
func (r *RedisCache) Set(ctx context.Context, key, value string, ttl time.Duration) error {
|
||||
return r.client.Set(ctx, key, value, ttl).Err()
|
||||
}
|
||||
|
||||
// Delete 删除键。
|
||||
func (r *RedisCache) Delete(ctx context.Context, keys ...string) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.client.Del(ctx, keys...).Err()
|
||||
}
|
||||
|
||||
// Exists 判断键是否存在。
|
||||
func (r *RedisCache) Exists(ctx context.Context, key string) (bool, error) {
|
||||
n, err := r.client.Exists(ctx, key).Result()
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// Incr 原子自增;首次创建时设置窗口 TTL。
|
||||
// 使用 Lua 脚本保证 INCR+EXPIRE 原子性,避免多实例下窗口被反复重置。
|
||||
func (r *RedisCache) Incr(ctx context.Context, key string, ttl time.Duration) (int64, error) {
|
||||
var incrScript = redis.NewScript(`
|
||||
local n = redis.call('INCR', KEYS[1])
|
||||
if n == 1 and ARGV[1] ~= '0' then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
return n
|
||||
`)
|
||||
ttlMs := int64(0)
|
||||
if ttl > 0 {
|
||||
ttlMs = ttl.Milliseconds()
|
||||
}
|
||||
return incrScript.Run(ctx, r.client, []string{key}, ttlMs).Int64()
|
||||
}
|
||||
|
||||
// Close 关闭 Redis 连接。
|
||||
func (r *RedisCache) Close() error { return r.client.Close() }
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
// redis_options_test.go — FCB_REDIS_DB / URL 库号解析单测。
|
||||
package cache
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildRedisOptionsPlainAddr(t *testing.T) {
|
||||
opts, err := buildRedisOptions("127.0.0.1:6379", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("plain addr: %v", err)
|
||||
}
|
||||
if opts.DB != 0 {
|
||||
t.Fatalf("默认库号应为 0, got %d", opts.DB)
|
||||
}
|
||||
|
||||
opts, err = buildRedisOptions("127.0.0.1:6379", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("plain addr db=5: %v", err)
|
||||
}
|
||||
if opts.Addr != "127.0.0.1:6379" || opts.DB != 5 {
|
||||
t.Fatalf("host:port + db: got addr=%s db=%d", opts.Addr, opts.DB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRedisOptionsURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
dbParam int
|
||||
wantDB int
|
||||
wantPw string
|
||||
}{
|
||||
{"URL 无库号用参数", "redis://127.0.0.1:6379", 3, 3, ""},
|
||||
{"URL 显式库号优先", "redis://127.0.0.1:6379/7", 3, 7, ""},
|
||||
{"URL 带密码", "redis://:secretpw@127.0.0.1:6379/2", 0, 2, "secretpw"},
|
||||
{"rediss 无库号用参数", "rediss://127.0.0.1:6379", 9, 9, ""},
|
||||
{"URL 根路径视为无库号", "redis://127.0.0.1:6379/", 4, 4, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
opts, err := buildRedisOptions(tc.url, tc.dbParam)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRedisOptions(%q): %v", tc.url, err)
|
||||
}
|
||||
if opts.DB != tc.wantDB {
|
||||
t.Fatalf("db = %d, want %d", opts.DB, tc.wantDB)
|
||||
}
|
||||
if opts.Password != tc.wantPw {
|
||||
t.Fatalf("password = %q, want %q", opts.Password, tc.wantPw)
|
||||
}
|
||||
if opts.Addr != "127.0.0.1:6379" {
|
||||
t.Fatalf("addr = %q", opts.Addr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRedisOptionsInvalidURL(t *testing.T) {
|
||||
if _, err := buildRedisOptions("redis://[bad", 0); err == nil {
|
||||
t.Fatal("非法 URL 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLHasDBPath(t *testing.T) {
|
||||
if urlHasDBPath("redis://h:6379") || urlHasDBPath("redis://h:6379/") {
|
||||
t.Fatal("无路径或根路径应视为 false")
|
||||
}
|
||||
if !urlHasDBPath("redis://h:6379/5") {
|
||||
t.Fatal("/5 应视为 true")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
// Package config 提供全局配置:默认值对齐参考实现 core/settings.py,
|
||||
// 支持 FCB_* 环境变量覆盖默认值,再由数据库 settings KV 做运行时覆盖。
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 会话有效期边界(与参考实现保持一致:天级、可配 1~365 天)。
|
||||
const (
|
||||
// AdminSessionExpireDefault 默认 7 天(L8:由 30 天缩短,降低 localStorage
|
||||
// token 泄露后的暴露窗口;管理员可在 1~365 天内自行调整)
|
||||
AdminSessionExpireDefault = 7 * 24 * 60 * 60
|
||||
AdminSessionExpireMin = 24 * 60 * 60 // 最小 1 天
|
||||
AdminSessionExpireMax = 365 * 24 * 60 * 60 // 最大 365 天
|
||||
|
||||
// DefaultSQLitePath SQLite 模式默认数据库文件路径(相对运行目录,自动创建 data/)。
|
||||
DefaultSQLitePath = "./data/fileshare.db"
|
||||
)
|
||||
|
||||
// 数据库驱动常量(需求 ⑧:SQLite 默认、Postgres 可选)。
|
||||
const (
|
||||
DBDriverSQLite = "sqlite"
|
||||
DBDriverPostgres = "postgres"
|
||||
)
|
||||
|
||||
// DefaultLogoURL / DefaultFaviconURL 默认 Logo 与 favicon(需求 ⑤):
|
||||
// 26.9 起默认改用前端打包的本地资源(web/src/assets/brand/logo.svg + favicon.png,
|
||||
// 经 Vite 产出 /assets/logo-*.svg 与 /assets/favicon-*.png)。此处留空,
|
||||
// GET /api/v1/config 下发空值时前端 displayLogoUrl/displayFaviconUrl 回落到本地打包资源;
|
||||
// 管理端仍可设置任意 URL 全站替换。
|
||||
const DefaultLogoURL = ""
|
||||
|
||||
// DefaultFaviconURL favicon/备用 Logo 默认空串(语义见 DefaultLogoURL 注释)。
|
||||
const DefaultFaviconURL = ""
|
||||
|
||||
// Config 运行时配置。Env 为 FCB_* 环境变量解析结果(进程级),
|
||||
// KV 为数据库 settings 键值覆盖(可被管理端动态修改)。
|
||||
type Config struct {
|
||||
Env *EnvConfig
|
||||
KV map[string]any
|
||||
}
|
||||
|
||||
// EnvConfig 进程级环境变量配置,仅能通过环境变量修改。
|
||||
type EnvConfig struct {
|
||||
DBDriver string // FCB_DB_DRIVER,sqlite|postgres,默认 sqlite(需求 ⑧)
|
||||
DBDSN string // FCB_DB_DSN;postgres 必需;sqlite 为空时用 DefaultSQLitePath
|
||||
RedisAddr string // FCB_REDIS_ADDR,可选;为空时缓存降级为内存实现
|
||||
RedisDB int // FCB_REDIS_DB,Redis 逻辑库号 0-15,默认 0(URL 形式地址以 URL 内库号优先)
|
||||
Listen string // FCB_LISTEN,监听地址,默认 :8466
|
||||
StorageEngine string // FCB_STORAGE_ENGINE,local|s3|webdav,默认 local
|
||||
TrustedProxies []string // FCB_TRUSTED_PROXIES,逗号分隔的可信代理 CIDR
|
||||
}
|
||||
|
||||
// defaults 返回与参考实现 core/settings.py DEFAULT_CONFIG 对齐的默认配置。
|
||||
func defaults() map[string]any {
|
||||
return map[string]any{
|
||||
// 存储引擎与路径
|
||||
"file_storage": "local",
|
||||
"storage_path": "",
|
||||
"storageLimit": 0,
|
||||
// 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": "文件快传", // 新增:管理端可自定义
|
||||
"description": "开箱即用的文件快传系统",
|
||||
"notify_title": "系统通知",
|
||||
"notify_content": "欢迎使用文件快传,拖拽或粘贴即可分享文本与文件。",
|
||||
"page_explain": "请勿上传或分享违法内容。根据《中华人民共和国网络安全法》、《中华人民共和国刑法》、《中华人民共和国治安管理处罚法》等相关规定。 传播或存储违法、违规内容,会受到相关处罚,严重者将承担刑事责任。本站坚决配合相关部门,确保网络内容的安全,和谐,打造绿色网络环境。",
|
||||
"keywords": "文件快传, 文件分享, 匿名口令分享文本, 文件",
|
||||
// 需求 ⑤:默认 Logo 与 favicon(空 = 前端使用打包的本地资源)
|
||||
"logo_url": DefaultLogoURL,
|
||||
"favicon_url": DefaultFaviconURL,
|
||||
// 需求 ①:背景图(26.9 新增 background_url;background 为参考实现既有键,保留兼容)
|
||||
"background": "",
|
||||
"background_url": "",
|
||||
// 需求 ②:页脚自定义内容与备案号
|
||||
"footer_text": "",
|
||||
"footer_beian": "",
|
||||
// 需求 ③:系统通知(notify_enabled 新增开关,title/content 沿用参考语义)
|
||||
"notify_enabled": 1,
|
||||
// 需求 ④:保存策略(次数上限新增;时间上限沿用 max_save_seconds)
|
||||
"max_save_count": 0,
|
||||
// 需求 ⑩:存储策略-单文件上限(0=回落 uploadSize,避免与参考键冲突)
|
||||
"max_file_size": 0,
|
||||
// 本地存储
|
||||
"local_storage_path": "",
|
||||
// S3 引擎
|
||||
"s3_access_key_id": "",
|
||||
"s3_secret_access_key": "",
|
||||
"s3_bucket_name": "",
|
||||
"s3_endpoint_url": "",
|
||||
"s3_region_name": "auto",
|
||||
"s3_signature_version": "s3v4",
|
||||
"s3_hostname": "",
|
||||
"s3_addressing_style": "auto",
|
||||
"s3_proxy": 0,
|
||||
"aws_session_token": "",
|
||||
// WebDAV 引擎
|
||||
"webdav_url": "",
|
||||
"webdav_username": "",
|
||||
"webdav_password": "",
|
||||
"webdav_root_path": "filebox_storage",
|
||||
"webdav_proxy": 0,
|
||||
// 安全
|
||||
"admin_token": "", // 管理员密码哈希;为空表示未初始化
|
||||
"jwt_secret": "",
|
||||
"adminSessionExpire": AdminSessionExpireDefault,
|
||||
// 上传与分享策略
|
||||
"openUpload": 1,
|
||||
"uploadSize": 1024 * 1024 * 10,
|
||||
"allowed_file_types": []string{"*"},
|
||||
"expireStyle": []string{"day", "hour", "minute", "forever", "count"},
|
||||
"code_generate_type": "secret",
|
||||
"uploadMinute": 1,
|
||||
"uploadCount": 10,
|
||||
"errorMinute": 1,
|
||||
"errorCount": 10,
|
||||
"loginCount": 5,
|
||||
"loginMinute": 15,
|
||||
"max_save_seconds": 0,
|
||||
"enableChunk": 0,
|
||||
// 界面
|
||||
"opacity": 0.9,
|
||||
"showAdminAddr": 0,
|
||||
"robotsText": "User-agent: *\nDisallow: /",
|
||||
"serverWorkers": 1,
|
||||
"serverHost": "0.0.0.0",
|
||||
"serverPort": 8466,
|
||||
}
|
||||
}
|
||||
|
||||
// loadEnv 解析 FCB_* 环境变量;返回 nil 表示未设置任何必需项。
|
||||
func loadEnv() (*EnvConfig, error) {
|
||||
env := &EnvConfig{
|
||||
DBDriver: strings.ToLower(strings.TrimSpace(os.Getenv("FCB_DB_DRIVER"))),
|
||||
DBDSN: strings.TrimSpace(os.Getenv("FCB_DB_DSN")),
|
||||
RedisAddr: strings.TrimSpace(os.Getenv("FCB_REDIS_ADDR")),
|
||||
Listen: strings.TrimSpace(os.Getenv("FCB_LISTEN")),
|
||||
StorageEngine: strings.TrimSpace(os.Getenv("FCB_STORAGE_ENGINE")),
|
||||
}
|
||||
// Redis 库号(FCB_REDIS_DB,0-15;非法值忽略用默认 0)
|
||||
if v := strings.TrimSpace(os.Getenv("FCB_REDIS_DB")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 && n <= 15 {
|
||||
env.RedisDB = n
|
||||
}
|
||||
}
|
||||
if env.Listen == "" {
|
||||
env.Listen = ":8466"
|
||||
}
|
||||
if env.StorageEngine == "" {
|
||||
env.StorageEngine = "local"
|
||||
}
|
||||
switch env.StorageEngine {
|
||||
case "local", "s3", "webdav":
|
||||
default:
|
||||
return nil, fmt.Errorf("FCB_STORAGE_ENGINE 无效值 %q,仅支持 local|s3|webdav", env.StorageEngine)
|
||||
}
|
||||
if raw := strings.TrimSpace(os.Getenv("FCB_TRUSTED_PROXIES")); raw != "" {
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
if item = strings.TrimSpace(item); item != "" {
|
||||
env.TrustedProxies = append(env.TrustedProxies, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// New 从环境变量构造配置;KV 覆盖先为空。
|
||||
// 需求 ⑧:FCB_DB_DRIVER 默认 sqlite(零依赖);postgres 必须提供 FCB_DB_DSN。
|
||||
func New() (*Config, error) {
|
||||
env, err := loadEnv()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch env.DBDriver {
|
||||
case "", DBDriverSQLite:
|
||||
env.DBDriver = DBDriverSQLite
|
||||
// sqlite 模式 DSN 可为空:数据库层回退到 DefaultSQLitePath
|
||||
case DBDriverPostgres:
|
||||
if env.DBDSN == "" {
|
||||
return nil, fmt.Errorf("FCB_DB_DRIVER=postgres 时必须提供 FCB_DB_DSN(Postgres 连接串)")
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("FCB_DB_DRIVER 无效值 %q,仅支持 sqlite|postgres", env.DBDriver)
|
||||
}
|
||||
return &Config{Env: env, KV: map[string]any{}}, nil
|
||||
}
|
||||
|
||||
// ApplyKV 用数据库 settings KV 覆盖运行时配置(内部键以 _ 开头的不允许覆盖)。
|
||||
func (c *Config) ApplyKV(kv map[string]any) {
|
||||
for k, v := range kv {
|
||||
if strings.HasPrefix(k, "_") {
|
||||
continue
|
||||
}
|
||||
c.KV[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Get 按 键读取:KV 覆盖 > 默认值;找不到返回零值与 false。
|
||||
func (c *Config) Get(key string) (any, bool) {
|
||||
if v, ok := c.KV[key]; ok {
|
||||
return v, true
|
||||
}
|
||||
v, ok := defaults()[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// GetString 取字符串配置。
|
||||
func (c *Config) GetString(key string) string {
|
||||
v, ok := c.Get(key)
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
// GetInt 取整型配置,兼容 JSON 数字(float64)与字符串。
|
||||
func (c *Config) GetInt(key string) int {
|
||||
n, _ := c.getInt64(key)
|
||||
return int(n)
|
||||
}
|
||||
|
||||
// GetInt64 取长整型配置。
|
||||
func (c *Config) GetInt64(key string) int64 {
|
||||
n, _ := c.getInt64(key)
|
||||
return n
|
||||
}
|
||||
|
||||
func (c *Config) getInt64(key string) (int64, bool) {
|
||||
v, ok := c.Get(key)
|
||||
if !ok || v == nil {
|
||||
return 0, false
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return int64(n), true
|
||||
case int64:
|
||||
return n, true
|
||||
case float64:
|
||||
return int64(n), true
|
||||
case string:
|
||||
if n, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64); err == nil {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// GetBool 取布尔配置,兼容 1/0、"true"/"false"/"on"/"yes"。
|
||||
func (c *Config) GetBool(key string) bool {
|
||||
v, ok := c.Get(key)
|
||||
if !ok || v == nil {
|
||||
return false
|
||||
}
|
||||
switch b := v.(type) {
|
||||
case bool:
|
||||
return b
|
||||
case int:
|
||||
return b != 0
|
||||
case float64:
|
||||
return b != 0
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(b)) {
|
||||
case "1", "true", "on", "yes":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetStringSlice 取字符串切片配置。
|
||||
// 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")), "/")
|
||||
}
|
||||
|
||||
func (c *Config) GetStringSlice(key string) []string {
|
||||
v, ok := c.Get(key)
|
||||
if !ok || v == nil {
|
||||
return nil
|
||||
}
|
||||
switch s := v.(type) {
|
||||
case []string:
|
||||
return s
|
||||
case []any:
|
||||
out := make([]string, 0, len(s))
|
||||
for _, item := range s {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%v", item))
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
var out []string
|
||||
for _, item := range strings.Split(s, ",") {
|
||||
if item = strings.TrimSpace(item); item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// —— 常用字段的便捷访问(与参考 settings.xxx 对齐)——
|
||||
|
||||
// SiteName 站点名称。
|
||||
func (c *Config) SiteName() string {
|
||||
if v := c.GetString("site_name"); v != "" {
|
||||
return v
|
||||
}
|
||||
return c.GetString("name")
|
||||
}
|
||||
|
||||
// LogoURL 页面 Logo。
|
||||
func (c *Config) LogoURL() string { return c.GetString("logo_url") }
|
||||
|
||||
// FaviconURL favicon 地址。
|
||||
func (c *Config) FaviconURL() string { return c.GetString("favicon_url") }
|
||||
|
||||
// OpenUpload 是否允许游客上传。
|
||||
func (c *Config) OpenUpload() bool { return c.GetBool("openUpload") }
|
||||
|
||||
// UploadSize 单文件大小上限(字节)。
|
||||
func (c *Config) UploadSize() int64 { return c.GetInt64("uploadSize") }
|
||||
|
||||
// AllowedFileTypes 允许的文件类型列表("*" 表示不限制)。
|
||||
func (c *Config) AllowedFileTypes() []string { return c.GetStringSlice("allowed_file_types") }
|
||||
|
||||
// ExpireStyle 允许的过期方式。
|
||||
func (c *Config) ExpireStyle() []string { return c.GetStringSlice("expireStyle") }
|
||||
|
||||
// EnableChunk 是否启用分片上传。
|
||||
func (c *Config) EnableChunk() bool { return c.GetBool("enableChunk") }
|
||||
|
||||
// MaxSaveSeconds 最长保存秒数,0 表示不限制。
|
||||
func (c *Config) MaxSaveSeconds() int64 { return c.GetInt64("max_save_seconds") }
|
||||
|
||||
// MaxSaveCount 单次分享最大可取次数上限(需求 ④),0 表示不限制。
|
||||
func (c *Config) MaxSaveCount() int { return c.GetInt("max_save_count") }
|
||||
|
||||
// MaxFileSize 存储策略-单文件上限(需求 ⑩);0 表示回落 uploadSize。
|
||||
func (c *Config) MaxFileSize() int64 {
|
||||
if n := c.GetInt64("max_file_size"); n > 0 {
|
||||
return n
|
||||
}
|
||||
return c.UploadSize()
|
||||
}
|
||||
|
||||
// FooterText 页脚自定义内容(需求 ②)。
|
||||
func (c *Config) FooterText() string { return c.GetString("footer_text") }
|
||||
|
||||
// FooterBeian 备案号(需求 ②)。
|
||||
func (c *Config) FooterBeian() string { return c.GetString("footer_beian") }
|
||||
|
||||
// BackgroundURL 背景图地址(需求 ①);空表示使用主题默认。
|
||||
func (c *Config) BackgroundURL() string {
|
||||
if v := c.GetString("background_url"); v != "" {
|
||||
return v
|
||||
}
|
||||
return c.GetString("background")
|
||||
}
|
||||
|
||||
// NotifyEnabled 系统通知开关(需求 ③):默认开启。
|
||||
func (c *Config) NotifyEnabled() bool {
|
||||
if v, ok := c.Get("notify_enabled"); ok && v != nil {
|
||||
return c.GetBool("notify_enabled")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SQLitePath 数据库文件路径:sqlite 模式下 DSN 为空时回退默认路径(需求 ⑧)。
|
||||
func (c *Config) SQLitePath() string {
|
||||
if c.Env.DBDriver != DBDriverSQLite {
|
||||
return ""
|
||||
}
|
||||
if c.Env.DBDSN != "" {
|
||||
return c.Env.DBDSN
|
||||
}
|
||||
return DefaultSQLitePath
|
||||
}
|
||||
|
||||
// AdminSessionExpireSeconds 管理员会话有效期(秒),
|
||||
// 参考 apps/admin/dependencies.py 的 get_admin_session_expire_seconds。
|
||||
func (c *Config) AdminSessionExpireSeconds() int {
|
||||
n := c.GetInt("adminSessionExpire")
|
||||
if n < AdminSessionExpireMin || n > AdminSessionExpireMax || n%AdminSessionExpireMin != 0 {
|
||||
return AdminSessionExpireDefault
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Engine 当前存储引擎。
|
||||
// Engine 返回当前存储引擎名:KV storage_engine 优先(26.9 运行时可改),
|
||||
// 空(未设置/历史数据)回落启动值 Env.StorageEngine(env 校验过的 local|s3|webdav)。
|
||||
// 枚举校验内联(避免 config→storage 反向依赖)。
|
||||
func (c *Config) Engine() string {
|
||||
if v, ok := c.Get(KeyStorageEngine); ok {
|
||||
if s, isStr := v.(string); isStr {
|
||||
switch s {
|
||||
case "local", "s3", "webdav":
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.Env.StorageEngine
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewDefaultsToSQLite(t *testing.T) {
|
||||
t.Setenv("FCB_DB_DRIVER", "")
|
||||
t.Setenv("FCB_DB_DSN", "")
|
||||
t.Setenv("FCB_REDIS_ADDR", "")
|
||||
t.Setenv("FCB_LISTEN", "")
|
||||
t.Setenv("FCB_STORAGE_ENGINE", "")
|
||||
c, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("默认(无 DSN)应可构造: %v", err)
|
||||
}
|
||||
if c.Env.DBDriver != DBDriverSQLite {
|
||||
t.Errorf("默认驱动应为 sqlite,实际 %s", c.Env.DBDriver)
|
||||
}
|
||||
if c.SQLitePath() != DefaultSQLitePath {
|
||||
t.Errorf("SQLite 默认路径 = %s", c.SQLitePath())
|
||||
}
|
||||
if c.Env.Listen != ":8466" {
|
||||
t.Errorf("默认监听地址错误: %s", c.Env.Listen)
|
||||
}
|
||||
if c.Env.StorageEngine != "local" {
|
||||
t.Errorf("默认存储引擎错误: %s", c.Env.StorageEngine)
|
||||
}
|
||||
if c.Env.RedisAddr != "" {
|
||||
t.Errorf("RedisAddr 应为空: %s", c.Env.RedisAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPostgresRequiresDSN(t *testing.T) {
|
||||
t.Setenv("FCB_DB_DRIVER", "postgres")
|
||||
t.Setenv("FCB_DB_DSN", "")
|
||||
if _, err := New(); err == nil {
|
||||
t.Fatal("postgres 模式缺少 FCB_DB_DSN 应报错")
|
||||
}
|
||||
t.Setenv("FCB_DB_DSN", "postgres://user:pass@localhost:5432/fcb")
|
||||
c, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("postgres + DSN 应可构造: %v", err)
|
||||
}
|
||||
if c.Env.DBDriver != DBDriverPostgres {
|
||||
t.Errorf("驱动应为 postgres,实际 %s", c.Env.DBDriver)
|
||||
}
|
||||
if c.SQLitePath() != "" {
|
||||
t.Errorf("postgres 模式 SQLitePath 应为空: %s", c.SQLitePath())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewInvalidDriver(t *testing.T) {
|
||||
t.Setenv("FCB_DB_DRIVER", "mysql")
|
||||
t.Setenv("FCB_DB_DSN", "x")
|
||||
if _, err := New(); err == nil {
|
||||
t.Fatal("非法驱动应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewInvalidEngine(t *testing.T) {
|
||||
t.Setenv("FCB_DB_DSN", "postgres://x")
|
||||
t.Setenv("FCB_STORAGE_ENGINE", "onedrive")
|
||||
if _, err := New(); err == nil {
|
||||
t.Fatal("非法引擎应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvOverridesAndDefaults(t *testing.T) {
|
||||
t.Setenv("FCB_DB_DSN", "postgres://x")
|
||||
t.Setenv("FCB_LISTEN", ":9999")
|
||||
t.Setenv("FCB_STORAGE_ENGINE", "webdav")
|
||||
c, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New 失败: %v", err)
|
||||
}
|
||||
if c.Env.Listen != ":9999" || c.Env.StorageEngine != "webdav" {
|
||||
t.Fatalf("env 覆盖失败: %+v", c.Env)
|
||||
}
|
||||
// 默认值对齐参考 DEFAULT_CONFIG
|
||||
if got := c.GetInt("uploadSize"); got != 1024*1024*10 {
|
||||
t.Errorf("uploadSize 默认值 = %d", got)
|
||||
}
|
||||
if got := c.GetInt("errorCount"); got != 10 {
|
||||
t.Errorf("errorCount 默认值 = %d", got)
|
||||
}
|
||||
if got := c.GetInt("loginCount"); got != 5 {
|
||||
t.Errorf("loginCount 默认值 = %d", got)
|
||||
}
|
||||
if got := c.GetInt("loginMinute"); got != 15 {
|
||||
t.Errorf("loginMinute 默认值 = %d", got)
|
||||
}
|
||||
if got := c.GetBool("openUpload"); !got {
|
||||
t.Error("openUpload 默认应为开启")
|
||||
}
|
||||
if c.EnableChunk() {
|
||||
t.Error("enableChunk 默认应关闭")
|
||||
}
|
||||
// 新增字段(需求 ①)
|
||||
if c.LogoURL() != DefaultLogoURL {
|
||||
t.Errorf("logo_url 默认值 = %s", c.LogoURL())
|
||||
}
|
||||
if c.FaviconURL() != DefaultFaviconURL {
|
||||
t.Errorf("favicon_url 默认值 = %s", c.FaviconURL())
|
||||
}
|
||||
if c.SiteName() == "" {
|
||||
t.Error("site_name 默认值不应为空")
|
||||
}
|
||||
// 过期方式与文件类型
|
||||
if len(c.ExpireStyle()) != 5 {
|
||||
t.Errorf("expireStyle 默认值 = %v", c.ExpireStyle())
|
||||
}
|
||||
if len(c.AllowedFileTypes()) != 1 || c.AllowedFileTypes()[0] != "*" {
|
||||
t.Errorf("allowed_file_types 默认值 = %v", c.AllowedFileTypes())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKVOverridesEnvAndDefaults(t *testing.T) {
|
||||
t.Setenv("FCB_DB_DSN", "postgres://x")
|
||||
c, _ := New()
|
||||
c.ApplyKV(map[string]any{
|
||||
"uploadSize": 1024,
|
||||
"openUpload": 0,
|
||||
"site_name": "我的快递柜",
|
||||
"logo_url": "https://example.com/logo.svg",
|
||||
"internalKey": "x", // 非下划线开头允许;下划线开头被拒
|
||||
"_secret": "no",
|
||||
})
|
||||
if got := c.GetInt("uploadSize"); got != 1024 {
|
||||
t.Errorf("KV 覆盖 uploadSize 失败: %d", got)
|
||||
}
|
||||
if c.OpenUpload() {
|
||||
t.Error("KV 覆盖 openUpload 失败")
|
||||
}
|
||||
if c.SiteName() != "我的快递柜" {
|
||||
t.Errorf("site_name KV 覆盖失败: %s", c.SiteName())
|
||||
}
|
||||
if c.LogoURL() != "https://example.com/logo.svg" {
|
||||
t.Errorf("logo_url KV 覆盖失败: %s", c.LogoURL())
|
||||
}
|
||||
if _, ok := c.Get("_secret"); ok {
|
||||
t.Error("下划线内部键不应可通过 ApplyKV 覆盖")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSessionExpireClamp(t *testing.T) {
|
||||
t.Setenv("FCB_DB_DSN", "postgres://x")
|
||||
c, _ := New()
|
||||
if got := c.AdminSessionExpireSeconds(); got != AdminSessionExpireDefault {
|
||||
t.Errorf("默认会话有效期 = %d", got)
|
||||
}
|
||||
c.ApplyKV(map[string]any{"adminSessionExpire": 7 * 24 * 60 * 60})
|
||||
if got := c.AdminSessionExpireSeconds(); got != 7*24*60*60 {
|
||||
t.Errorf("7 天会话有效期 = %d", got)
|
||||
}
|
||||
c.ApplyKV(map[string]any{"adminSessionExpire": 3600}) // 非整天,回落默认
|
||||
if got := c.AdminSessionExpireSeconds(); got != AdminSessionExpireDefault {
|
||||
t.Errorf("非法值应回落默认 = %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Package config — schema.go 定义 26.9 新增配置键(KV)schema:
|
||||
// 键名常量、类型、默认值与取值边界。管理与 API 层(t2)按下表读写与校验,
|
||||
// 文档(t4)按本表生成说明。键名除参考实现既有 camelCase 键外,
|
||||
// 26.9 新增键统一 snake_case。
|
||||
package config
|
||||
|
||||
// —— 26.9 新增/沿用键名常量(单一事实来源;settings 包会 re-export)——
|
||||
// 命名规则:26.9 新增键 snake_case;与参考实现对齐的既有键保持原拼写。
|
||||
const (
|
||||
// 需求 ①:背景图
|
||||
KeyBackground = "background" // 参考实现既有键(v1 兼容保留)
|
||||
KeyBackgroundURL = "background_url" // 26.9 新增:背景图 URL 或上传后的访问地址(空=默认主题)
|
||||
// 需求 ②:页脚
|
||||
KeyFooterText = "footer_text" // 26.9 新增:页脚自定义内容(纯文本或受控 HTML 片段)
|
||||
KeyFooterBeian = "footer_beian" // 26.9 新增:备案号(如 京ICP备2024xxxxxx号-1)
|
||||
// 需求 ③:系统通知
|
||||
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" // 26.9 新增:单次分享最大可取(保存)次数上限,0=不限制
|
||||
KeyExpireStyle = "expireStyle" // 既有键:允许的过期方式白名单
|
||||
// 需求 ④:上传频率限制(既有键,对齐参考 ip_limit["upload"])
|
||||
KeyUploadCount = "uploadCount" // 窗口内允许上传次数
|
||||
KeyUploadMinute = "uploadMinute" // 频率窗口(分钟)
|
||||
// 需求 ④⑩:存储策略(最大文件大小/允许类型/总容量)
|
||||
KeyUploadSize = "uploadSize" // 既有键:单文件上限(字节),参考实现语义
|
||||
KeyMaxFileSize = "max_file_size" // 26.9 新增:存储策略-单文件上限(字节),0=回落 uploadSize
|
||||
KeyAllowedTypes = "allowed_file_types" // 既有键:允许类型白名单("*" 不限制)
|
||||
KeyStorageLimit = "storageLimit" // 既有键:站点总容量(字节),0=不限制
|
||||
KeyOpenUpload = "openUpload" // 既有键:游客上传开关
|
||||
// 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 校验用)——
|
||||
const (
|
||||
// 保存时间上限:最长 365 天,0 表示不限制。
|
||||
MaxSaveSecondsMax = 365 * 24 * 60 * 60
|
||||
// 保存次数上限:最长 100000 次,0 表示不限制。
|
||||
MaxSaveCountMax = 100000
|
||||
// 单文件大小上限:最长 10 GiB,0 表示回落 uploadSize。
|
||||
MaxFileSizeMax = 10 * 1024 * 1024 * 1024
|
||||
// 背景图 URL 最大长度(含 data: 之外的普通 http(s) URL)。
|
||||
BackgroundURLMaxLen = 2048
|
||||
// 页脚自定义内容最大长度。
|
||||
FooterTextMaxLen = 2000
|
||||
// 备案号最大长度。
|
||||
FooterBeianMaxLen = 128
|
||||
// 通知标题/内容最大长度。
|
||||
NotifyTitleMaxLen = 128
|
||||
NotifyContentMaxLen = 2000
|
||||
// 回收扫描间隔边界(秒):最快 1 分钟一轮,最慢 1 天一轮。
|
||||
RecycleIntervalMin = 60
|
||||
RecycleIntervalMax = 86400
|
||||
// 全局存储时长上限(天):0=不限制,最长 10 年。
|
||||
RetentionDaysMax = 3650
|
||||
// 防盗链白名单最大长度。
|
||||
HotlinkWhitelistMaxLen = 2048
|
||||
// 直链有效期边界(秒)。
|
||||
DirectLinkExpireMin = 60
|
||||
DirectLinkExpireMax = 3600
|
||||
)
|
||||
|
||||
// KVSchemaEntry 配置键元数据:类型 / 默认值 / 说明,供管理端 UI 与文档生成。
|
||||
type KVSchemaEntry struct {
|
||||
Key string // KV 键名
|
||||
Type string // string | int | int64 | bool | []string
|
||||
Default any // 默认值(与 defaults() 保持一致,测试保证同步)
|
||||
Min int64 // 数值键最小值(字符串键为长度下界)
|
||||
Max int64 // 数值键最大值(字符串键为长度上界;-1 不限制)
|
||||
Description string // 中文说明
|
||||
}
|
||||
|
||||
// KVSchema 26.9 全量配置键 schema 表(含既有策略键,供管理端/文档/AI 校验)。
|
||||
// 注意:Default 与 config defaults() 逐一对应(schema_test 保证)。
|
||||
func KVSchema() []KVSchemaEntry {
|
||||
return []KVSchemaEntry{
|
||||
// —— 需求 ① 背景图 ——
|
||||
{KeyBackgroundURL, "string", "", 0, BackgroundURLMaxLen, "背景图 URL 或上传后地址(空=主题默认)"},
|
||||
// —— 需求 ② 页脚 ——
|
||||
{KeyFooterText, "string", "", 0, FooterTextMaxLen, "页脚自定义内容(纯文本或受控 HTML 片段)"},
|
||||
{KeyFooterBeian, "string", "", 0, FooterBeianMaxLen, "备案号,展示于页脚"},
|
||||
// —— 需求 ③ 系统通知 ——
|
||||
{KeyNotifyEnabled, "int", 1, 0, 1, "系统通知开关:1 右上角悬浮窗展示 / 0 关闭"},
|
||||
{KeyNotifyTitle, "string", "系统通知", 0, NotifyTitleMaxLen, "通知标题"},
|
||||
{KeyNotifyContent, "string", "欢迎使用文件快传,拖拽或粘贴即可分享文本与文件。", 0, NotifyContentMaxLen, "通知内容(允许 <a> 等受控 HTML)"},
|
||||
// —— 需求 ④ 保存策略 ——
|
||||
{KeyMaxSaveSeconds, "int64", int64(0), 0, MaxSaveSecondsMax, "最长保存秒数上限,0=不限制(默认 7 天兜底)"},
|
||||
{KeyMaxSaveCount, "int", 0, 0, MaxSaveCountMax, "单次分享最大可取次数上限,0=不限制"},
|
||||
{KeyExpireStyle, "[]string", []string{"day", "hour", "minute", "forever", "count"}, -1, -1, "上传页可选过期方式白名单"},
|
||||
// —— 需求 ④ 上传频率限制(既有键对齐参考)——
|
||||
{KeyUploadCount, "int", 10, 1, 10000, "频率窗口内允许的上传次数"},
|
||||
{KeyUploadMinute, "int", 1, 1, 1440, "上传频率窗口(分钟)"},
|
||||
// —— 需求 ④⑩ 存储策略 ——
|
||||
{KeyMaxFileSize, "int64", int64(0), 0, MaxFileSizeMax, "存储策略-单文件上限(字节),0=回落 uploadSize"},
|
||||
{KeyUploadSize, "int64", int64(1024 * 1024 * 10), 1024, MaxFileSizeMax, "单文件上限(字节),参考实现语义"},
|
||||
{KeyAllowedTypes, "[]string", []string{"*"}, -1, -1, "允许上传类型白名单(\"*\" 不限制)"},
|
||||
{KeyStorageLimit, "int64", int64(0), 0, -1, "站点总容量(字节),0=不限制"},
|
||||
{KeyOpenUpload, "int", 1, 0, 1, "游客上传开关:1 开 / 0 需管理员登录"},
|
||||
// —— 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 分钟;不超过分享剩余时效)"},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// schema 同步测试:保证 config.KVSchema() 的默认值/键集合与 defaults() 完全一致,
|
||||
// 与 settings 包 re-export 的键名常量同源。新增键时任何一处漏改都会在此失败。
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestKVSchemaDefaultsMatchDefaults KVSchema 的 Default 必须 === defaults() 中同名键。
|
||||
func TestKVSchemaDefaultsMatchDefaults(t *testing.T) {
|
||||
def := defaults()
|
||||
for _, e := range KVSchema() {
|
||||
want, ok := def[e.Key]
|
||||
if !ok {
|
||||
t.Fatalf("schema 键 %q 缺少 defaults() 默认值", e.Key)
|
||||
}
|
||||
// 类型规范化比较(JSON 序列化可比较 []string / int / float)
|
||||
a, _ := json.Marshal(e.Default)
|
||||
b, _ := json.Marshal(want)
|
||||
if string(a) != string(b) {
|
||||
t.Fatalf("键 %q 默认值不一致: schema=%s defaults=%s", e.Key, a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestKVSchemaNoDuplicates 键名不得重复。
|
||||
func TestKVSchemaNoDuplicates(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, e := range KVSchema() {
|
||||
if seen[e.Key] {
|
||||
t.Fatalf("schema 键 %q 重复定义", e.Key)
|
||||
}
|
||||
seen[e.Key] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestV2NewKeysPresent 26.9 新增键必须在 schema 与 defaults 中同时存在。
|
||||
func TestV2NewKeysPresent(t *testing.T) {
|
||||
def := defaults()
|
||||
newKeys := []string{
|
||||
KeyBackgroundURL, KeyFooterText, KeyFooterBeian,
|
||||
KeyNotifyEnabled, KeyMaxSaveCount, KeyMaxFileSize,
|
||||
}
|
||||
for _, k := range newKeys {
|
||||
if _, ok := def[k]; !ok {
|
||||
t.Fatalf("26.9 新键 %q 缺少默认值", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestKVAccessorDefaults 26.9 便捷访问器默认语义。
|
||||
func TestKVAccessorDefaults(t *testing.T) {
|
||||
t.Setenv("FCB_DB_DRIVER", "sqlite")
|
||||
t.Setenv("FCB_DB_DSN", "")
|
||||
c, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
// 背景图:background_url 与 background 均空 → 空
|
||||
if c.BackgroundURL() != "" {
|
||||
t.Fatalf("背景图默认应为空: %q", c.BackgroundURL())
|
||||
}
|
||||
// legacy background 键兜底
|
||||
c.ApplyKV(map[string]any{KeyBackground: "/legacy/bg.jpg"})
|
||||
if c.BackgroundURL() != "/legacy/bg.jpg" {
|
||||
t.Fatalf("legacy background 应回落生效: %q", c.BackgroundURL())
|
||||
}
|
||||
// max_file_size > 0 时优先于 uploadSize
|
||||
c.ApplyKV(map[string]any{KeyMaxFileSize: int64(1024), KeyUploadSize: int64(2048)})
|
||||
if c.MaxFileSize() != 1024 {
|
||||
t.Fatalf("max_file_size 应优先: %d", c.MaxFileSize())
|
||||
}
|
||||
// max_file_size = 0 回落 uploadSize
|
||||
c.ApplyKV(map[string]any{KeyMaxFileSize: int64(0)})
|
||||
if c.MaxFileSize() != 2048 {
|
||||
t.Fatalf("max_file_size=0 应回落 uploadSize: %d", c.MaxFileSize())
|
||||
}
|
||||
// 通知默认开启
|
||||
if !c.NotifyEnabled() {
|
||||
t.Fatal("notify_enabled 默认应开启")
|
||||
}
|
||||
// 保存次数上限默认不限制
|
||||
if c.MaxSaveCount() != 0 {
|
||||
t.Fatalf("max_save_count 默认应 0: %d", c.MaxSaveCount())
|
||||
}
|
||||
// 页脚默认空
|
||||
if c.FooterText() != "" || c.FooterBeian() != "" {
|
||||
t.Fatal("页脚默认应为空")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Package database 负责数据库连接与迁移(需求 ⑧:双方言):
|
||||
// - sqlite(默认):modernc.org/sqlite 纯 Go 驱动(GORM 封装 glebarez/sqlite),零 CGO、零外部依赖;
|
||||
// - postgres:可选,配置 FCB_DB_DRIVER=postgres + FCB_DB_DSN 后启用。
|
||||
//
|
||||
// 两方言共用 GORM 抽象层,AutoMigrate 与全部业务查询保持方言无关;
|
||||
// 唯一的原生 SQL(migrates 建表)已改为双方言分支。
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"fileshare/internal/config"
|
||||
"fileshare/internal/model"
|
||||
)
|
||||
|
||||
// Options 连接选项(main.go 从 config.Env 装配)。
|
||||
type Options struct {
|
||||
Driver string // sqlite | postgres(空按 sqlite 处理)
|
||||
DSN string // postgres 连接串;sqlite 为文件路径(空回退 config.DefaultSQLitePath)
|
||||
}
|
||||
|
||||
// Open 按驱动连接数据库并执行连接池设置与探活。
|
||||
func Open(ctx context.Context, opts Options) (*gorm.DB, error) {
|
||||
driver := strings.ToLower(strings.TrimSpace(opts.Driver))
|
||||
if driver == "" {
|
||||
driver = config.DBDriverSQLite
|
||||
}
|
||||
var dialector gorm.Dialector
|
||||
switch driver {
|
||||
case config.DBDriverSQLite:
|
||||
path := strings.TrimSpace(opts.DSN)
|
||||
if path == "" {
|
||||
path = config.DefaultSQLitePath
|
||||
}
|
||||
// 自动创建父目录(如 ./data),对齐参考实现 data_root 语义
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("database: 创建 SQLite 目录 %s 失败: %w", dir, err)
|
||||
}
|
||||
}
|
||||
// DSN 参数:busy_timeout 防写锁竞态;WAL 提升并发读写(Query 参数形式,驱动原生支持)
|
||||
dsn := path + "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
|
||||
dialector = sqlite.Open(dsn)
|
||||
case config.DBDriverPostgres:
|
||||
if strings.TrimSpace(opts.DSN) == "" {
|
||||
return nil, fmt.Errorf("database: FCB_DB_DRIVER=postgres 需要提供 FCB_DB_DSN")
|
||||
}
|
||||
dialector = postgres.Open(opts.DSN)
|
||||
default:
|
||||
return nil, fmt.Errorf("database: 不支持的数据库驱动 %q(仅支持 sqlite|postgres)", driver)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(dialector, &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
// 避免 GORM 生成方言特有子句;时间语义由应用层统一(容器本地时区)
|
||||
NowFunc: time.Now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("database: 连接 %s 失败: %w", driver, err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 连接池:SQLite 单文件场景保守设置;Postgres 沿用 v1 参数
|
||||
switch driver {
|
||||
case config.DBDriverSQLite:
|
||||
sqlDB.SetMaxOpenConns(8)
|
||||
sqlDB.SetMaxIdleConns(4)
|
||||
sqlDB.SetConnMaxLifetime(0) // 长连接文件句柄,无需轮换
|
||||
case config.DBDriverPostgres:
|
||||
sqlDB.SetMaxOpenConns(32)
|
||||
sqlDB.SetMaxIdleConns(8)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
}
|
||||
|
||||
// 连接探活(带超时)
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
if err := sqlDB.PingContext(pingCtx); err != nil {
|
||||
return nil, fmt.Errorf("database: %s 探活失败: %w", driver, err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Migrate 执行迁移:先建迁移台账表(双方言分支),再 AutoMigrate 全部模型。
|
||||
func Migrate(ctx context.Context, db *gorm.DB) error {
|
||||
if err := createMigratesTable(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := model.AutoMigrate(db); err != nil {
|
||||
return fmt.Errorf("database: AutoMigrate 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createMigratesTable 创建迁移台账表。
|
||||
// 双方言差异:自增主键 postgres 用 BIGSERIAL、sqlite 用 INTEGER PRIMARY KEY AUTOINCREMENT;
|
||||
// 时间戳默认值 postgres 用 CURRENT_TIMESTAMP、sqlite 用 CURRENT_TIMESTAMP(等价)。
|
||||
func createMigratesTable(ctx context.Context, db *gorm.DB) error {
|
||||
ddl := `
|
||||
CREATE TABLE IF NOT EXISTS migrates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
migration_file VARCHAR(255) NOT NULL UNIQUE,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
if db.Dialector.Name() == config.DBDriverPostgres {
|
||||
ddl = `
|
||||
CREATE TABLE IF NOT EXISTS migrates (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
migration_file VARCHAR(255) NOT NULL UNIQUE,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
}
|
||||
if err := db.WithContext(ctx).Exec(ddl).Error; err != nil {
|
||||
return fmt.Errorf("database: 创建 migrates 表失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭底层连接。
|
||||
func Close(db *gorm.DB) error {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// 数据库双方言测试(需求 ⑧):
|
||||
// - sqlite:始终执行(纯 Go,临时目录建库);
|
||||
// - postgres:设置 FCB_TEST_PG_DSN(真实连接串)后执行,未设置时跳过。
|
||||
//
|
||||
// 覆盖:Open/Migrate 全表建立、settings KV 读写、JSON 字段往返、
|
||||
// 分页查询(LIMIT/OFFSET 语义)、布尔/时间字段往返 —— 双方言逐项比对。
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"fileshare/internal/database"
|
||||
"fileshare/internal/model"
|
||||
)
|
||||
|
||||
// pgTestDSN 返回 Postgres 测试连接串;未设置 FCB_TEST_PG_DSN 时返回空。
|
||||
func pgTestDSN(t *testing.T) string {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("FCB_TEST_PG_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("未设置 FCB_TEST_PG_DSN,跳过 Postgres 双方言用例(sqlite 用例仍执行)")
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
// openTestDB 按方言打开数据库并执行迁移;返回 gorm 实例与关闭函数。
|
||||
func openTestDB(t *testing.T, driver, dsn string) (*gorm.DB, func()) {
|
||||
t.Helper()
|
||||
if dsn == "" {
|
||||
// sqlite:临时文件库
|
||||
dir := t.TempDir()
|
||||
dsn = filepath.Join(dir, "test.db")
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := database.Open(ctx, database.Options{Driver: driver, DSN: dsn})
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] Open 失败: %v", driver, err)
|
||||
}
|
||||
if err := database.Migrate(ctx, db); err != nil {
|
||||
_ = database.Close(db)
|
||||
t.Fatalf("[%s] Migrate 失败: %v", driver, err)
|
||||
}
|
||||
return db, func() { _ = database.Close(db) }
|
||||
}
|
||||
|
||||
// runDialectSuite 双方言共用的行为断言集。
|
||||
func runDialectSuite(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
// —— 1. 全表建立 ——
|
||||
for _, m := range model.AllModels() {
|
||||
if !db.Migrator().HasTable(m) {
|
||||
t.Fatalf("表 %T 未创建", m)
|
||||
}
|
||||
}
|
||||
|
||||
// —— 2. settings KV 读写 + JSON 字段往返 ——
|
||||
// GORM 软特性:KeyValue.Value 为 *string(JSON 文本),双方言 text 类型
|
||||
// 可重跑:先清掉同键旧行(共享测试库场景)
|
||||
if err := db.WithContext(ctx).Where(model.KeyValue{Key: "settings"}).Delete(&model.KeyValue{}).Error; err != nil {
|
||||
t.Fatalf("KV 旧数据清理失败: %v", err)
|
||||
}
|
||||
kv := map[string]any{"background_url": "https://example.com/bg.jpg", "footer_beian": "京ICP备2024000001号-1", "max_save_seconds": 3600}
|
||||
raw, err := json.Marshal(kv)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal KV: %v", err)
|
||||
}
|
||||
row := model.KeyValue{Key: "settings", Value: strPtr(string(raw))}
|
||||
if err := db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
t.Fatalf("KV 写入失败: %v", err)
|
||||
}
|
||||
var got model.KeyValue
|
||||
if err := db.WithContext(ctx).Where(model.KeyValue{Key: "settings"}).First(&got).Error; err != nil {
|
||||
t.Fatalf("KV 读取失败: %v", err)
|
||||
}
|
||||
parsed := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(*got.Value), &parsed); err != nil {
|
||||
t.Fatalf("KV JSON 解析失败: %v", err)
|
||||
}
|
||||
if parsed["background_url"] != "https://example.com/bg.jpg" {
|
||||
t.Fatalf("KV JSON 字段往返不一致: %v", parsed)
|
||||
}
|
||||
// 更新(先查后改,方言无关)
|
||||
if err := db.WithContext(ctx).Model(&got).Update("value", strPtr(`{"notify_enabled":0}`)).Error; err != nil {
|
||||
t.Fatalf("KV 更新失败: %v", err)
|
||||
}
|
||||
var got2 model.KeyValue
|
||||
_ = db.WithContext(ctx).Where(model.KeyValue{Key: "settings"}).First(&got2)
|
||||
if *got2.Value != `{"notify_enabled":0}` {
|
||||
t.Fatalf("KV 更新未生效: %s", *got2.Value)
|
||||
}
|
||||
|
||||
// —— 3. 分页查询(LIMIT/OFFSET)——
|
||||
// 每次运行用随机前缀避免脏数据互相影响
|
||||
prefix := fmt.Sprintf("pg%d_", time.Now().UnixNano())
|
||||
for i := 0; i < 25; i++ {
|
||||
fc := model.FileCodes{
|
||||
Code: fmt.Sprintf("%s%03d", prefix, i),
|
||||
ExpiredCount: -1,
|
||||
IsChunked: i%2 == 0, // 布尔字段往返
|
||||
}
|
||||
if err := db.WithContext(ctx).Create(&fc).Error; err != nil {
|
||||
t.Fatalf("FileCodes 写入失败: %v", err)
|
||||
}
|
||||
}
|
||||
var page []model.FileCodes
|
||||
if err := db.WithContext(ctx).
|
||||
Where("code LIKE ?", prefix+"%").
|
||||
Order("id ASC").
|
||||
Limit(10).Offset(20).
|
||||
Find(&page).Error; err != nil {
|
||||
t.Fatalf("分页查询失败: %v", err)
|
||||
}
|
||||
if len(page) != 5 {
|
||||
t.Fatalf("第二页应剩 5 条,实际 %d", len(page))
|
||||
}
|
||||
if page[0].Code != prefix+"020" {
|
||||
t.Fatalf("分页偏移错误: %s", page[0].Code)
|
||||
}
|
||||
var total int64
|
||||
if err := db.WithContext(ctx).Model(&model.FileCodes{}).
|
||||
Where("code LIKE ?", prefix+"%").Count(&total).Error; err != nil {
|
||||
t.Fatalf("计数查询失败: %v", err)
|
||||
}
|
||||
if total != 25 {
|
||||
t.Fatalf("总数应 25,实际 %d", total)
|
||||
}
|
||||
|
||||
// —— 4. 布尔/时间/可空字段往返 ——
|
||||
now := time.Now().Truncate(time.Second) // sqlite 秒级精度
|
||||
fc := model.FileCodes{
|
||||
Code: prefix + "special",
|
||||
ExpiredAt: &now,
|
||||
ExpiredCount: 5,
|
||||
Text: strPtr("你好 FileCodeBox"),
|
||||
FileHash: strPtr("abc123"),
|
||||
IsChunked: true,
|
||||
}
|
||||
if err := db.WithContext(ctx).Create(&fc).Error; err != nil {
|
||||
t.Fatalf("完整字段写入失败: %v", err)
|
||||
}
|
||||
var back model.FileCodes
|
||||
if err := db.WithContext(ctx).Where(model.FileCodes{Code: fc.Code}).First(&back).Error; err != nil {
|
||||
t.Fatalf("完整字段读取失败: %v", err)
|
||||
}
|
||||
if back.Text == nil || *back.Text != "你好 FileCodeBox" {
|
||||
t.Fatalf("text 字段往返不一致: %v", back.Text)
|
||||
}
|
||||
if !back.IsChunked {
|
||||
t.Fatal("布尔字段往返不一致")
|
||||
}
|
||||
if back.ExpiredAt == nil {
|
||||
t.Fatal("时间字段往返丢失")
|
||||
}
|
||||
if diff := back.ExpiredAt.Sub(now); diff > time.Second || diff < -time.Second {
|
||||
t.Fatalf("时间字段偏差过大: %v", diff)
|
||||
}
|
||||
if back.FileHash == nil || *back.FileHash != "abc123" {
|
||||
t.Fatalf("可空字段往返不一致: %v", back.FileHash)
|
||||
}
|
||||
// LOWER + LIKE(admin 列表检索路径:真实代码先对关键词小写化再拼 LIKE 模式,
|
||||
// 对齐 admin.go 的 "LOWER(code) LIKE ?" 用法,双方言均支持)
|
||||
var hits int64
|
||||
lowerPattern := "%" + strings.ToLower(prefix+"SPECIAL") + "%"
|
||||
if err := db.WithContext(ctx).Model(&model.FileCodes{}).
|
||||
Where("LOWER(code) LIKE ?", lowerPattern).Count(&hits).Error; err != nil {
|
||||
t.Fatalf("LOWER/LIKE 查询失败: %v", err)
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Fatalf("LOWER/LIKE 命中数应 1,实际 %d", hits)
|
||||
}
|
||||
// 可重跑:清理本前缀数据(共享测试库场景)
|
||||
if err := db.WithContext(ctx).Where("code LIKE ?", prefix+"%").Delete(&model.FileCodes{}).Error; err != nil {
|
||||
t.Fatalf("清理测试数据失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
// TestSQLiteDialect sqlite(默认模式):临时文件库全流程。
|
||||
func TestSQLiteDialect(t *testing.T) {
|
||||
db, closeFn := openTestDB(t, "sqlite", "")
|
||||
defer closeFn()
|
||||
runDialectSuite(t, db)
|
||||
}
|
||||
|
||||
// TestSQLiteInMemoryDialect sqlite 内存库(DSN 为 :memory: 等价路径场景)。
|
||||
func TestSQLiteInMemoryDialect(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, closeFn := openTestDB(t, "sqlite", filepath.Join(dir, "mem.db"))
|
||||
defer closeFn()
|
||||
runDialectSuite(t, db)
|
||||
}
|
||||
|
||||
// TestPostgresDialect postgres(可选模式):FCB_TEST_PG_DSN 指向真实实例。
|
||||
func TestPostgresDialect(t *testing.T) {
|
||||
dsn := pgTestDSN(t)
|
||||
db, closeFn := openTestDB(t, "postgres", dsn)
|
||||
defer closeFn()
|
||||
runDialectSuite(t, db)
|
||||
}
|
||||
|
||||
// TestOpenRejectsUnknownDriver 非法驱动应报错。
|
||||
func TestOpenRejectsUnknownDriver(t *testing.T) {
|
||||
if _, err := database.Open(context.Background(), database.Options{Driver: "mysql", DSN: "x"}); err == nil {
|
||||
t.Fatal("非法驱动应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenPostgresRequiresDSN postgres 模式缺 DSN 应报错。
|
||||
func TestOpenPostgresRequiresDSN(t *testing.T) {
|
||||
if _, err := database.Open(context.Background(), database.Options{Driver: "postgres", DSN: ""}); err == nil {
|
||||
t.Fatal("postgres 缺 DSN 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSQLiteAutoCreatesDataDir sqlite 默认相对路径下自动创建父目录。
|
||||
func TestSQLiteAutoCreatesDataDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
nested := filepath.Join(dir, "deep", "data", "fcb.db")
|
||||
db, closeFn := openTestDB(t, "sqlite", nested)
|
||||
defer closeFn()
|
||||
if _, err := os.Stat(nested); err != nil {
|
||||
t.Fatalf("数据库文件应已创建: %v", err)
|
||||
}
|
||||
runDialectSuite(t, db)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Package janitor 后台清理循环(安全审计 M5 / 26.9 过期回收):
|
||||
// 回收过期容量预留、超时未完成的上传会话(含其分片对象)、过期预签名会话
|
||||
// (direct 模式残留对象一并删除),以及过期/超留存期的分享记录与存储对象。
|
||||
package janitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"fileshare/internal/model"
|
||||
"fileshare/internal/storage"
|
||||
)
|
||||
|
||||
// chunkSessionMaxAge 未完成分片会话的最大保留时长(预留 TTL 为 2h,
|
||||
// 会话保留 24h 以支持断点续传;超时后由本循环清理)。
|
||||
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 取消时退出。
|
||||
// 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()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
Run(ctx, db, store)
|
||||
if recycler != nil && recycler.Enabled != nil && recycler.Enabled() {
|
||||
RecycleExpired(ctx, db, store, recycler)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Run 执行一轮基础设施清理;单项失败仅记日志,不影响其他项。
|
||||
func Run(ctx context.Context, db *gorm.DB, store *storage.Manager) {
|
||||
now := time.Now()
|
||||
cleanExpiredReservations(ctx, db, now)
|
||||
cleanExpiredChunkSessions(ctx, db, store, now)
|
||||
cleanExpiredPresignSessions(ctx, db, store, now)
|
||||
}
|
||||
|
||||
// cleanExpiredReservations 删除全部过期容量预留。
|
||||
func cleanExpiredReservations(ctx context.Context, db *gorm.DB, now time.Time) {
|
||||
if err := db.WithContext(ctx).
|
||||
Where("expires_at <= ?", now).
|
||||
Delete(&model.StorageReservation{}).Error; err != nil {
|
||||
log.Printf("[janitor] 清理过期容量预留失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// engineFor 按归属引擎取回实例;空/未知引擎回落当前引擎(对齐 API 层 storeFor 语义)。
|
||||
func engineFor(store *storage.Manager, name string) (storage.Storage, error) {
|
||||
if name != "" && storage.ValidEngine(name) {
|
||||
if s, err := store.EngineOf(name); err == nil {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
return store.Current(), nil
|
||||
}
|
||||
|
||||
// cleanExpiredChunkSessions 清理超时未完成的分片会话及其分片对象。
|
||||
func cleanExpiredChunkSessions(ctx context.Context, db *gorm.DB, store *storage.Manager, now time.Time) {
|
||||
var sessions []model.UploadChunk
|
||||
if err := db.WithContext(ctx).
|
||||
Where("chunk_index = -1 AND created_at < ?", now.Add(-chunkSessionMaxAge)).
|
||||
Limit(200).
|
||||
Find(&sessions).Error; err != nil {
|
||||
log.Printf("[janitor] 查询过期分片会话失败: %v", err)
|
||||
return
|
||||
}
|
||||
for _, s := range sessions {
|
||||
engine, err := engineFor(store, s.Engine)
|
||||
if err == nil && s.SavePath != "" {
|
||||
if err := engine.CleanChunks(ctx, s.UploadID, s.SavePath); err != nil &&
|
||||
!errors.Is(err, storage.ErrNotFound) && !errors.Is(err, storage.ErrInvalidPath) {
|
||||
log.Printf("[janitor] 清理分片对象失败 upload_id=%s: %v", s.UploadID, err)
|
||||
}
|
||||
}
|
||||
if err := db.WithContext(ctx).
|
||||
Where("upload_id = ?", s.UploadID).
|
||||
Delete(&model.UploadChunk{}).Error; err != nil {
|
||||
log.Printf("[janitor] 删除过期分片会话失败 upload_id=%s: %v", s.UploadID, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[janitor] 已清理超时分片会话 upload_id=%s file=%s", s.UploadID, s.FileName)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanExpiredPresignSessions 清理过期预签名会话;direct 模式残留对象一并删除。
|
||||
func cleanExpiredPresignSessions(ctx context.Context, db *gorm.DB, store *storage.Manager, now time.Time) {
|
||||
var sessions []model.PresignUploadSession
|
||||
if err := db.WithContext(ctx).
|
||||
Where("expires_at < ?", now.Add(-presignGrace)).
|
||||
Limit(200).
|
||||
Find(&sessions).Error; err != nil {
|
||||
log.Printf("[janitor] 查询过期预签名会话失败: %v", err)
|
||||
return
|
||||
}
|
||||
for _, s := range sessions {
|
||||
if s.Mode == "direct" && s.SavePath != "" {
|
||||
if engine, err := engineFor(store, s.Engine); err == nil {
|
||||
if err := engine.DeleteFile(ctx, s.SavePath); err != nil &&
|
||||
!errors.Is(err, storage.ErrNotFound) && !errors.Is(err, storage.ErrInvalidPath) {
|
||||
log.Printf("[janitor] 删除直传残留对象失败 upload_id=%s: %v", s.UploadID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := db.WithContext(ctx).
|
||||
Where("upload_id = ?", s.UploadID).
|
||||
Delete(&model.PresignUploadSession{}).Error; err != nil {
|
||||
log.Printf("[janitor] 删除过期预签名会话失败 upload_id=%s: %v", s.UploadID, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[janitor] 已清理过期预签名会话 upload_id=%s mode=%s", s.UploadID, s.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 26.9:过期分享回收 ============
|
||||
|
||||
// RecycleExpired 回收过期/超存储时长的分享记录与存储对象:
|
||||
// - 时间过期:expired_count<0 且 expired_at 已过;
|
||||
// - 次数耗尽:expired_count>=0 且 <=0;
|
||||
// - 超留存期:retentionDays>0 且 created_at 早于 now-retentionDays;
|
||||
// - 内容去重开启时同一存储对象可能被多条分享引用,删除前做引用计数
|
||||
// (按 ContentHash/Engine/UUIDFileName 统计),仅删除最后一个引用。
|
||||
//
|
||||
// 返回本轮回收的分享数。由 janitor 定时循环与管理端手动触发共用。
|
||||
func RecycleExpired(ctx context.Context, db *gorm.DB, store *storage.Manager, r *Recycler) int {
|
||||
now := time.Now()
|
||||
q := db.WithContext(ctx).Model(&model.FileCodes{}).
|
||||
Where("(expired_count < 0 AND expired_at IS NOT NULL AND expired_at < ?)"+
|
||||
" OR (expired_count >= 0 AND expired_count <= 0)", now)
|
||||
if r.RetentionDays != nil && r.RetentionDays() > 0 {
|
||||
cutoff := now.AddDate(0, 0, -int(r.RetentionDays()))
|
||||
q = q.Or("created_at < ?", cutoff)
|
||||
}
|
||||
var ids []int64
|
||||
if err := q.Limit(recycleBatch).Pluck("id", &ids).Error; err != nil {
|
||||
log.Printf("[recycle] 查询过期分享失败: %v", err)
|
||||
return 0
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, id := range ids {
|
||||
var fc model.FileCodes
|
||||
if err := db.WithContext(ctx).First(&fc, id).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
// 复核:Expired 语义(避免查询窗口内被取件续期)
|
||||
if !fc.Expired(now) {
|
||||
if r.RetentionDays == nil || r.RetentionDays() <= 0 || fc.CreatedAt.After(now.AddDate(0, 0, -int(r.RetentionDays()))) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
recycleOne(ctx, db, store, &fc, r)
|
||||
n++
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[recycle] 本轮回收 %d 条过期分享", n)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// RecycleRecord 回收单条分享(取件惰性回收入口):删除记录与存储对象(带引用计数)。
|
||||
// 记录不存在时为幂等空操作。
|
||||
func RecycleRecord(ctx context.Context, db *gorm.DB, store *storage.Manager, fc *model.FileCodes, r *Recycler) {
|
||||
// 存在性复核:可能已被定时循环/其他请求回收
|
||||
var cur model.FileCodes
|
||||
if err := db.WithContext(ctx).Where("id = ?", fc.ID).First(&cur).Error; err != nil {
|
||||
return
|
||||
}
|
||||
recycleOne(ctx, db, store, &cur, r)
|
||||
}
|
||||
|
||||
// recycleOne 删除单条分享记录及其存储对象(带去重引用计数)。
|
||||
func recycleOne(ctx context.Context, db *gorm.DB, store *storage.Manager, fc *model.FileCodes, r *Recycler) {
|
||||
if fc.Text == nil && fc.UUIDFileName != nil {
|
||||
engine, err := engineFor(store, fc.Engine)
|
||||
if err != nil {
|
||||
log.Printf("[recycle] 引擎不可用 code=%s: %v", fc.Code, err)
|
||||
// 引擎不可用也删记录,避免永久堆积;对象留给对账巡检
|
||||
} else {
|
||||
// 去重引用计数:同 ContentHash+Engine+UUIDFileName 的其他分享还在,则不删对象
|
||||
if fc.ContentHash != nil && *fc.ContentHash != "" {
|
||||
var cnt int64
|
||||
_ = db.WithContext(ctx).Model(&model.FileCodes{}).
|
||||
Where("content_hash = ? AND engine = ? AND uuid_file_name = ? AND id <> ?",
|
||||
*fc.ContentHash, fc.Engine, *fc.UUIDFileName, fc.ID).
|
||||
Count(&cnt).Error
|
||||
if cnt == 0 && fc.SavePath() != "" {
|
||||
delFile(ctx, engine, fc.SavePath(), fc.Code)
|
||||
}
|
||||
} else if fc.SavePath() != "" {
|
||||
delFile(ctx, engine, fc.SavePath(), fc.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := db.WithContext(ctx).Delete(fc).Error; err != nil {
|
||||
log.Printf("[recycle] 删除分享记录失败 code=%s: %v", fc.Code, err)
|
||||
return
|
||||
}
|
||||
if r != nil && r.OnRecycled != nil {
|
||||
r.OnRecycled(fc.Code, fc.Prefix+fc.Suffix, fc.Size)
|
||||
}
|
||||
}
|
||||
|
||||
// delFile 删除存储对象,NotFound 视为成功(幂等)。
|
||||
func delFile(ctx context.Context, engine storage.Storage, savePath, code string) {
|
||||
if err := engine.DeleteFile(ctx, savePath); err != nil &&
|
||||
!errors.Is(err, storage.ErrNotFound) && !errors.Is(err, storage.ErrInvalidPath) {
|
||||
log.Printf("[recycle] 删除存储对象失败 code=%s path=%s: %v", code, savePath, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"fileshare/internal/audit"
|
||||
"fileshare/internal/model"
|
||||
"fileshare/internal/response"
|
||||
)
|
||||
|
||||
// auditHooks 审计钩子:由 API 层在响应前后填充与落库。
|
||||
// 中间件负责计时与公共字段(IP/UA/设备/耗时),业务上下文通过 auditEntry 传递。
|
||||
type auditEntry struct {
|
||||
Entry audit.Entry
|
||||
// start 请求进入审计中间件的时刻,用于计算耗时。
|
||||
start time.Time
|
||||
// writer 下载动作时包装的响应计数器。
|
||||
writer *bytesCountWriter
|
||||
// skip 为 true 表示业务 handler 显式跳过审计(AuditSkip)。
|
||||
skip bool
|
||||
// recorded 防止重复落库。
|
||||
recorded bool
|
||||
}
|
||||
|
||||
// bytesCountWriter 统计响应体写出字节数(用于下载审计)。
|
||||
type bytesCountWriter struct {
|
||||
gin.ResponseWriter
|
||||
count int64
|
||||
}
|
||||
|
||||
func (w *bytesCountWriter) Write(b []byte) (int, error) {
|
||||
n, err := w.ResponseWriter.Write(b)
|
||||
w.count += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (w *bytesCountWriter) WriteString(s string) (int, error) {
|
||||
n, err := w.ResponseWriter.WriteString(s)
|
||||
w.count += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Classifier 判定请求是否属于需审计的动作;返回动作名与是否命中。
|
||||
type Classifier func(c *gin.Context) (action string, ok bool)
|
||||
|
||||
// DefaultClassifier 按任务合同的默认路由语义分类:
|
||||
// - 上传:POST /share/file、/share/text、/chunk/upload*、/presign*
|
||||
// - 下载:GET /share/download、/share/select、/share/metadata
|
||||
// - 管理(L5):POST/PATCH/DELETE 的敏感管理操作——登录/登出、配置与密码
|
||||
// 修改、存储引擎切换、文件更新/删除/策略动作
|
||||
//
|
||||
// API 层可传入自定义分类器覆盖。
|
||||
func DefaultClassifier(c *gin.Context) (string, bool) {
|
||||
path := c.FullPath()
|
||||
if path == "" {
|
||||
path = c.Request.URL.Path
|
||||
}
|
||||
p := strings.TrimRight(path, "/")
|
||||
switch c.Request.Method {
|
||||
case http.MethodPost, http.MethodPut:
|
||||
switch {
|
||||
case p == "/share/file" || p == "/share/text":
|
||||
return audit.ActionUpload, true
|
||||
case strings.HasPrefix(p, "/chunk/upload"):
|
||||
return audit.ActionUpload, true
|
||||
case strings.HasPrefix(p, "/presign"):
|
||||
return audit.ActionUpload, true
|
||||
}
|
||||
if adminAuditActions[p] {
|
||||
return audit.ActionAdmin, true
|
||||
}
|
||||
case http.MethodPatch, http.MethodDelete:
|
||||
if adminAuditActions[p] {
|
||||
return audit.ActionAdmin, true
|
||||
}
|
||||
case http.MethodGet:
|
||||
switch p {
|
||||
case "/share/download", "/share/select", "/share/metadata":
|
||||
return audit.ActionDownload, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// adminAuditActions 需要审计的管理端敏感操作路由(L5)。
|
||||
var adminAuditActions = map[string]bool{
|
||||
"/admin/login": true,
|
||||
"/admin/logout": true,
|
||||
"/admin/config/update": true,
|
||||
"/admin/settings/password": true,
|
||||
"/admin/storage/switch": true,
|
||||
"/admin/file/update": true,
|
||||
"/admin/file/delete": true,
|
||||
"/admin/file/batch-delete": true,
|
||||
"/admin/file/batch-update": true,
|
||||
"/admin/file/policy-action": true,
|
||||
"/admin/file/batch-policy-action": true,
|
||||
}
|
||||
|
||||
// Audit 审计中间件:对分类器命中的 upload/download/admin 动作写审计日志。
|
||||
// handler 通过 AuditSet 填充取件码/文件名/字节数等业务字段;
|
||||
// handler 未显式 AuditRecordRequest 时按 HTTP 状态兜底落库。
|
||||
func Audit(service *audit.Service, classify Classifier) gin.HandlerFunc {
|
||||
if classify == nil {
|
||||
classify = DefaultClassifier
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
|
||||
action, ok := classify(c)
|
||||
// 未命中审计动作的请求直接放行,不产生审计记录。
|
||||
// L5:admin 类动作同样需要建 auditEntry 并落库(登录失败/配置变更等)。
|
||||
if !ok {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
entry := audit.Entry{
|
||||
Action: action,
|
||||
IP: GetClientIP(c),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
}
|
||||
info := audit.ParseUserAgent(entry.UserAgent)
|
||||
entry.DeviceOS = info.OS
|
||||
entry.DeviceBrowser = info.Browser
|
||||
entry.DeviceType = info.Type
|
||||
|
||||
// 交给后续 handler 填充
|
||||
state := &auditEntry{Entry: entry, start: start}
|
||||
c.Set("auditEntry", state)
|
||||
|
||||
// 下载动作:包装 Writer 以捕获实际写出字节数(必须在 c.Next() 前替换)
|
||||
if action == audit.ActionDownload {
|
||||
state.writer = &bytesCountWriter{ResponseWriter: c.Writer}
|
||||
c.Writer = state.writer
|
||||
}
|
||||
|
||||
c.Next()
|
||||
|
||||
// 下载兜底统计:handler 未填 TransferredBytes 时取响应写出字节
|
||||
if action == audit.ActionDownload && state.Entry.TransferredBytes == 0 &&
|
||||
!state.recorded && !state.skip && state.writer != nil {
|
||||
state.Entry.TransferredBytes = state.writer.count
|
||||
}
|
||||
|
||||
// handler 未显式落库时兜底记录
|
||||
ae, exists := c.Get("auditEntry")
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
state, isState := ae.(*auditEntry)
|
||||
if !isState || state.recorded || state.skip {
|
||||
return
|
||||
}
|
||||
state.Entry.Duration = time.Since(start)
|
||||
state.Entry.Actor = resolveActor(c)
|
||||
status := c.Writer.Status()
|
||||
switch {
|
||||
case state.Entry.Result != "":
|
||||
// handler 已给出结论
|
||||
case status >= 500:
|
||||
state.Entry.Result = model.AuditResultFailed
|
||||
case status == 401 || status == 403 || status == 423 || status == 429 || status == 428:
|
||||
state.Entry.Result = model.AuditResultDenied
|
||||
case status >= 400:
|
||||
state.Entry.Result = model.AuditResultFailed
|
||||
default:
|
||||
state.Entry.Result = model.AuditResultSuccess
|
||||
}
|
||||
switch {
|
||||
case state.Entry.ErrorMsg != "":
|
||||
// handler 已给出错误信息
|
||||
case c.Errors.String() != "":
|
||||
state.Entry.ErrorMsg = c.Errors.String()
|
||||
case status >= 400:
|
||||
// 兜底:记录 HTTP 状态
|
||||
state.Entry.ErrorMsg = "HTTP " + itoa64(int64(status))
|
||||
}
|
||||
service.Record(state.Entry)
|
||||
state.recorded = true
|
||||
}
|
||||
}
|
||||
|
||||
// AuditEntry 获取当前请求的审计状态(由 Audit 中间件创建)。
|
||||
func AuditEntry(c *gin.Context) *auditEntry {
|
||||
if v, ok := c.Get("auditEntry"); ok {
|
||||
if ae, ok := v.(*auditEntry); ok {
|
||||
return ae
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuditSet 填充当前请求的审计字段;仅对已启用审计的请求生效。
|
||||
func AuditSet(c *gin.Context, fn func(e *audit.Entry)) {
|
||||
if ae := AuditEntry(c); ae != nil && fn != nil {
|
||||
fn(&ae.Entry)
|
||||
}
|
||||
}
|
||||
|
||||
// AuditRecordRequest 显式触发落库(含耗时);由 handler 在响应前调用。
|
||||
func AuditRecordRequest(c *gin.Context, service *audit.Service, result, errMsg string) {
|
||||
ae := AuditEntry(c)
|
||||
if ae == nil || ae.recorded || ae.skip {
|
||||
return
|
||||
}
|
||||
ae.Entry.Duration = time.Since(ae.start)
|
||||
ae.Entry.Result = result
|
||||
ae.Entry.ErrorMsg = errMsg
|
||||
ae.Entry.Actor = resolveActor(c)
|
||||
service.Record(ae.Entry)
|
||||
ae.recorded = true
|
||||
}
|
||||
|
||||
// AuditSkip 标记当前请求不写审计。
|
||||
func AuditSkip(c *gin.Context) {
|
||||
if ae := AuditEntry(c); ae != nil {
|
||||
ae.skip = true
|
||||
}
|
||||
}
|
||||
|
||||
// resolveActor 判断请求者角色:管理员 JWT 有效 → admin,否则 guest。
|
||||
func resolveActor(c *gin.Context) string {
|
||||
header := c.GetHeader("Authorization")
|
||||
if len(header) > 7 && header[:7] == "Bearer " {
|
||||
// 仅检查声明是否有效,不重复校验签名逻辑(AdminAuth 已处理受保护路由)
|
||||
if _, ok := c.Get("claims"); ok {
|
||||
return audit.ActorAdmin
|
||||
}
|
||||
}
|
||||
return audit.ActorGuest
|
||||
}
|
||||
|
||||
// AuditRecord 显式按结果落库;duration 由中间件按起始时间计算。
|
||||
func AuditRecord(c *gin.Context, service *audit.Service, result, errMsg string) {
|
||||
ae := AuditEntry(c)
|
||||
if ae == nil || ae.recorded || ae.skip {
|
||||
return
|
||||
}
|
||||
AuditRecordRequest(c, service, result, errMsg)
|
||||
}
|
||||
|
||||
// GuardNotInitialized 系统未初始化守卫:除 setup/health 外返回 428。
|
||||
func GuardNotInitialized(isInit func() bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if isInit() {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
if path == "/setup" || path == "/api/v1/health" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
response.Fail(c, 428, "系统未初始化,请先完成初始化")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// audit_l5_test.go — L5 回归:admin 类动作(如登录失败)必须落审计。
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"fileshare/internal/audit"
|
||||
"fileshare/internal/model"
|
||||
)
|
||||
|
||||
type captureSink struct {
|
||||
logs []model.AuditLog
|
||||
}
|
||||
|
||||
func (s *captureSink) Save(_ context.Context, logs []model.AuditLog) error {
|
||||
s.logs = append(s.logs, logs...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestAuditRecordsAdminActions L5:/admin/login 失败(401)后应产生一条
|
||||
// result=denied 的 admin 审计记录(此前 skip 条件把 admin 动作整体跳过)。
|
||||
func TestAuditRecordsAdminActions(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
sink := &captureSink{}
|
||||
svc := audit.NewService(sink)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(Audit(svc, nil)) // DefaultClassifier
|
||||
r.POST("/admin/login", func(c *gin.Context) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401})
|
||||
})
|
||||
r.POST("/share/text", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200})
|
||||
})
|
||||
r.GET("/healthz", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK) // 未分类动作:不应产生审计
|
||||
})
|
||||
|
||||
// 管理端:401 → denied
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("POST", "/admin/login", nil))
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("login should 401, got %d", w.Code)
|
||||
}
|
||||
// 上传类:200 → success
|
||||
w2 := httptest.NewRecorder()
|
||||
r.ServeHTTP(w2, httptest.NewRequest("POST", "/share/text", nil))
|
||||
// 未分类:不落库
|
||||
w3 := httptest.NewRecorder()
|
||||
r.ServeHTTP(w3, httptest.NewRequest("GET", "/healthz", nil))
|
||||
|
||||
// audit.Service 异步落库,轮询等待
|
||||
var actions []string
|
||||
for i := 0; i < 50; i++ {
|
||||
if len(sink.logs) >= 2 {
|
||||
break
|
||||
}
|
||||
waitMillis(20)
|
||||
}
|
||||
if len(sink.logs) != 2 {
|
||||
t.Fatalf("应恰好 2 条审计记录, got %d", len(sink.logs))
|
||||
}
|
||||
for _, l := range sink.logs {
|
||||
actions = append(actions, l.Action)
|
||||
switch l.Action {
|
||||
case audit.ActionAdmin:
|
||||
if l.Result != model.AuditResultDenied {
|
||||
t.Fatalf("admin 401 应记 denied, got %q", l.Result)
|
||||
}
|
||||
case audit.ActionUpload:
|
||||
if l.Result != model.AuditResultSuccess {
|
||||
t.Fatalf("upload 200 应记 success, got %q", l.Result)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("意外动作 %q", l.Action)
|
||||
}
|
||||
}
|
||||
_ = actions
|
||||
}
|
||||
|
||||
func waitMillis(ms int) {
|
||||
time.Sleep(time.Duration(ms) * time.Millisecond)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"fileshare/internal/audit"
|
||||
"fileshare/internal/model"
|
||||
)
|
||||
|
||||
// memSink 测试用内存落库实现。
|
||||
type memSink struct {
|
||||
mu sync.Mutex
|
||||
logs []model.AuditLog
|
||||
notif chan struct{}
|
||||
}
|
||||
|
||||
func newMemSink() *memSink { return &memSink{notif: make(chan struct{}, 16)} }
|
||||
|
||||
func (m *memSink) Save(_ context.Context, logs []model.AuditLog) error {
|
||||
m.mu.Lock()
|
||||
m.logs = append(m.logs, logs...)
|
||||
m.mu.Unlock()
|
||||
m.notif <- struct{}{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memSink) snapshot() []model.AuditLog {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]model.AuditLog, len(m.logs))
|
||||
copy(out, m.logs)
|
||||
return out
|
||||
}
|
||||
|
||||
// waitFor 等待 sink 收到 n 条记录(带超时)。
|
||||
func (m *memSink) waitFor(t *testing.T, n int) []model.AuditLog {
|
||||
t.Helper()
|
||||
deadline := time.After(2 * time.Second)
|
||||
for {
|
||||
logs := m.snapshot()
|
||||
if len(logs) >= n {
|
||||
return logs
|
||||
}
|
||||
select {
|
||||
case <-m.notif:
|
||||
case <-deadline:
|
||||
t.Fatalf("等待审计记录超时: 已收到 %d 条", len(logs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func auditRouter(svc *audit.Service) *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(ClientIP(nil))
|
||||
r.Use(Audit(svc, nil)) // 默认分类器
|
||||
// 上传路由:命中默认分类器(POST /share/file)
|
||||
r.POST("/share/file", func(c *gin.Context) {
|
||||
// 模拟 handler 填充业务字段并显式落库
|
||||
AuditSet(c, func(e *audit.Entry) {
|
||||
e.FileCode = "Ab3xY"
|
||||
e.FileName = "hello.zip"
|
||||
e.SizeBytes = 1024
|
||||
})
|
||||
AuditRecordRequest(c, svc, model.AuditResultSuccess, "")
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
})
|
||||
// 下载路由:命中默认分类器(GET /share/download)
|
||||
r.GET("/share/download", func(c *gin.Context) {
|
||||
AuditSet(c, func(e *audit.Entry) { e.FileCode = "Xy12Z" })
|
||||
c.JSON(404, gin.H{"msg": "文件已过期删除"}) // 未显式落库 → 状态码兜底
|
||||
})
|
||||
// 普通路由:不命中,不应产生审计
|
||||
r.GET("/plain", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAuditMiddlewareRecordsUpload(t *testing.T) {
|
||||
sink := newMemSink()
|
||||
svc := audit.NewService(sink)
|
||||
r := auditRouter(svc)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/share/file", nil)
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36")
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("上传应成功: %d", w.Code)
|
||||
}
|
||||
logs := sink.waitFor(t, 1)
|
||||
e := logs[0]
|
||||
if e.Action != audit.ActionUpload {
|
||||
t.Errorf("action = %s", e.Action)
|
||||
}
|
||||
if e.FileCode != "Ab3xY" || e.FileName != "hello.zip" {
|
||||
t.Errorf("file fields = %s/%s", e.FileCode, e.FileName)
|
||||
}
|
||||
if e.SizeBytes != 1024 {
|
||||
t.Errorf("size = %d", e.SizeBytes)
|
||||
}
|
||||
if e.Result != model.AuditResultSuccess {
|
||||
t.Errorf("result = %s", e.Result)
|
||||
}
|
||||
if e.DeviceOS != "Windows" || e.DeviceBrowser != "Chrome" || e.DeviceType != "desktop" {
|
||||
t.Errorf("device = %s/%s/%s", e.DeviceOS, e.DeviceBrowser, e.DeviceType)
|
||||
}
|
||||
if e.DurationMs < 0 {
|
||||
t.Errorf("duration = %d", e.DurationMs)
|
||||
}
|
||||
if e.Actor != audit.ActorGuest {
|
||||
t.Errorf("actor = %s", e.Actor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditMiddlewareSkipsPlainRoutes(t *testing.T) {
|
||||
sink := newMemSink()
|
||||
svc := audit.NewService(sink)
|
||||
r := auditRouter(svc)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/plain", nil))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("plain 路由应成功: %d", w.Code)
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if logs := sink.snapshot(); len(logs) != 0 {
|
||||
t.Fatalf("普通路由不应产生审计记录: %v", logs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditFailedDownloadFallback(t *testing.T) {
|
||||
sink := newMemSink()
|
||||
svc := audit.NewService(sink)
|
||||
r := auditRouter(svc)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/share/download?code=xyz", nil)
|
||||
req.Header.Set("User-Agent", "curl/8.4.0")
|
||||
r.ServeHTTP(w, req)
|
||||
logs := sink.waitFor(t, 1)
|
||||
e := logs[0]
|
||||
if e.Action != audit.ActionDownload {
|
||||
t.Errorf("action = %s", e.Action)
|
||||
}
|
||||
if e.Result != model.AuditResultFailed {
|
||||
t.Errorf("4xx 兜底 result = %s", e.Result)
|
||||
}
|
||||
if e.DeviceType != "bot" {
|
||||
t.Errorf("curl 应识别为 bot: %s", e.DeviceType)
|
||||
}
|
||||
if e.ErrorMsg == "" {
|
||||
t.Error("失败记录应包含错误信息")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditDeniedStatusMapping(t *testing.T) {
|
||||
sink := newMemSink()
|
||||
svc := audit.NewService(sink)
|
||||
r := gin.New()
|
||||
r.Use(ClientIP(nil))
|
||||
r.Use(Audit(svc, nil))
|
||||
r.GET("/share/select", func(c *gin.Context) { c.AbortWithStatus(429) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/share/select?code=abc", nil))
|
||||
logs := sink.waitFor(t, 1)
|
||||
if logs[0].Result != model.AuditResultDenied {
|
||||
t.Errorf("429 应映射为 denied: %s", logs[0].Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditDownloadBytesCounted(t *testing.T) {
|
||||
sink := newMemSink()
|
||||
svc := audit.NewService(sink)
|
||||
r := gin.New()
|
||||
r.Use(ClientIP(nil))
|
||||
r.Use(Audit(svc, nil))
|
||||
r.GET("/share/download", func(c *gin.Context) {
|
||||
payload := []byte("0123456789abcdef") // 16 字节
|
||||
c.Data(200, "application/octet-stream", payload)
|
||||
// 未显式落库 → 中间件兜底;TransferredBytes 应等于写出字节
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/share/download?code=bytes", nil))
|
||||
logs := sink.waitFor(t, 1)
|
||||
if logs[0].TransferredBytes != 16 {
|
||||
t.Errorf("下载字节数 = %d, want 16", logs[0].TransferredBytes)
|
||||
}
|
||||
if logs[0].Result != model.AuditResultSuccess {
|
||||
t.Errorf("result = %s", logs[0].Result)
|
||||
}
|
||||
}
|
||||
@@ -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 构造限速 reader;rate<=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 }
|
||||
@@ -0,0 +1,30 @@
|
||||
// Package middleware — bodylimit.go:全局请求体大小限制。
|
||||
//
|
||||
// 安全审计 M3:此前服务对请求体无任何上限,text/plain 形态的绑定会先
|
||||
// io.ReadAll 全量进内存、multipart 大文件先完整落盘后才被策略拒绝,
|
||||
// 未认证攻击者可借大 body 消耗内存/磁盘/带宽。
|
||||
// 本中间件用 http.MaxBytesReader 按「路径类别」给出上限:
|
||||
// - 管理端(/admin/*):1MiB;
|
||||
// - 文本/元信息/取件类(/share/text|metadata|select):1MiB;
|
||||
// - 其余(含上传):maxFileSize(0=回落 uploadSize,仍为 0 时 64MiB 兜底)+ 2MiB 表单开销。
|
||||
//
|
||||
// 超限时后续读取返回错误,统一被 handler 的 bind 错误路径映射为 400。
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// BodyLimit 按请求路径动态限制请求体大小(limit<=0 表示不限制)。
|
||||
func BodyLimit(limitFn func(c *gin.Context) int64) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Body != nil && limitFn != nil {
|
||||
if limit := limitFn(c); limit > 0 {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Cors 跨域中间件(L6 收紧):
|
||||
// - 公开接口:维持 allow_origins=*(Bearer Token 认证,无 Cookie CSRF 面);
|
||||
// - 管理端(/admin/*):当请求携带 Origin 且既不同源也不在允许域名列表时,
|
||||
// 不回 CORS 头(浏览器将拦截跨域读取)。防止管理端 token 泄露后
|
||||
// 被任意第三方页面直接跨域调用。无 Origin 的非浏览器请求不受影响。
|
||||
//
|
||||
// extraAllowedOrigins:管理端额外允许的来源(如 site_domain 配置的对外域名)。
|
||||
func Cors(extraAllowedOrigins ...string) gin.HandlerFunc {
|
||||
allowedHosts := map[string]bool{}
|
||||
for _, o := range extraAllowedOrigins {
|
||||
if o == "" {
|
||||
continue
|
||||
}
|
||||
raw := strings.TrimSpace(o)
|
||||
if !strings.Contains(raw, "://") {
|
||||
raw = "https://" + raw
|
||||
}
|
||||
if u, err := url.Parse(raw); err == nil && u.Host != "" {
|
||||
allowedHosts[u.Host] = true
|
||||
}
|
||||
}
|
||||
|
||||
// adminCrossOriginBlocked 判断 /admin 请求是否应拒绝跨域:
|
||||
// 仅在「带 Origin 且 Origin 既不同源也不在白名单」时为 true。
|
||||
adminBlocked := func(c *gin.Context) bool {
|
||||
p := c.Request.URL.Path
|
||||
if p != "/admin" && !strings.HasPrefix(p, "/admin/") {
|
||||
return false
|
||||
}
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
o, err := url.Parse(origin)
|
||||
if err != nil || o.Host == "" {
|
||||
return true // Origin 非法:按跨域拒绝处理
|
||||
}
|
||||
if o.Host == c.Request.Host || allowedHosts[o.Host] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
if adminBlocked(c) {
|
||||
// 不回 ACAO;预检直接 204(浏览器会因无 CORS 头拦截后续请求)
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, HEAD")
|
||||
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, Content-Disposition, X-Requested-With")
|
||||
c.Header("Access-Control-Expose-Headers", "Content-Disposition, Content-Length")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"fileshare/internal/response"
|
||||
)
|
||||
|
||||
// jwtClaims 自定义声明:对齐参考实现(payload 含 is_admin 与 exp)。
|
||||
type jwtClaims struct {
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// 签发/校验相关错误。
|
||||
var (
|
||||
ErrTokenExpired = errors.New("token已过期")
|
||||
ErrTokenInvalid = errors.New("无效的签名")
|
||||
ErrNotAdmin = errors.New("未授权或授权校验失败")
|
||||
)
|
||||
|
||||
// SignAdminToken 用 HS256 签发管理员 JWT。
|
||||
// secret 为数据库 settings 中的 jwt_secret;expires 为会话有效期。
|
||||
func SignAdminToken(secret string, expires time.Duration) (string, time.Time, error) {
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return "", time.Time{}, errors.New("JWT签名密钥未初始化")
|
||||
}
|
||||
expiresAt := time.Now().Add(expires)
|
||||
claims := jwtClaims{
|
||||
IsAdmin: true,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "fileshare",
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := token.SignedString([]byte(secret))
|
||||
return signed, expiresAt, err
|
||||
}
|
||||
|
||||
// VerifyAdminToken 校验管理员 JWT:签名、过期时间与 is_admin 声明。
|
||||
func VerifyAdminToken(secret, token string) (*jwtClaims, error) {
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return nil, errors.New("JWT签名密钥未初始化")
|
||||
}
|
||||
parsed, err := jwt.ParseWithClaims(token, &jwtClaims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
return []byte(secret), nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
claims, ok := parsed.Claims.(*jwtClaims)
|
||||
if !ok || !parsed.Valid {
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
if !claims.IsAdmin {
|
||||
return nil, ErrNotAdmin
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// SecretProvider 动态提供当前 jwt_secret(settings KV 运行时可变)。
|
||||
type SecretProvider func() string
|
||||
|
||||
// AdminAuth 管理员鉴权中间件:校验 Authorization: Bearer <token>。
|
||||
// 成功后把声明写入 gin 上下文(ctxClaims)。
|
||||
func AdminAuth(secret SecretProvider) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
header := c.GetHeader("Authorization")
|
||||
if !strings.HasPrefix(header, "Bearer ") {
|
||||
response.Fail(c, 401, "未授权或授权校验失败")
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||
if token == "" {
|
||||
response.Fail(c, 401, "未授权或授权校验失败")
|
||||
return
|
||||
}
|
||||
claims, err := VerifyAdminToken(secret(), token)
|
||||
if err != nil {
|
||||
response.Fail(c, 401, err.Error())
|
||||
return
|
||||
}
|
||||
c.Set("claims", claims)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"fileshare/internal/response"
|
||||
)
|
||||
|
||||
const testSecret = "unit-test-secret-0123456789abcdef"
|
||||
|
||||
func init() { gin.SetMode(gin.TestMode) }
|
||||
|
||||
func TestSignAndVerifyAdminToken(t *testing.T) {
|
||||
token, expiresAt, err := SignAdminToken(testSecret, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("签发失败: %v", err)
|
||||
}
|
||||
if expiresAt.Before(time.Now()) {
|
||||
t.Fatal("过期时间不合理")
|
||||
}
|
||||
claims, err := VerifyAdminToken(testSecret, token)
|
||||
if err != nil {
|
||||
t.Fatalf("校验失败: %v", err)
|
||||
}
|
||||
if !claims.IsAdmin {
|
||||
t.Fatal("is_admin 应为 true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyTamperedToken(t *testing.T) {
|
||||
token, _, _ := SignAdminToken(testSecret, time.Hour)
|
||||
claims, err := VerifyAdminToken(testSecret+"-wrong", token)
|
||||
if err == nil || claims != nil {
|
||||
t.Fatal("密钥不匹配应校验失败")
|
||||
}
|
||||
// 篡改 payload
|
||||
tampered := token[:len(token)-3] + "abc"
|
||||
if _, err := VerifyAdminToken(testSecret, tampered); err == nil {
|
||||
t.Fatal("篡改的 token 应校验失败")
|
||||
}
|
||||
// 非 HMAC 算法拒绝
|
||||
algNone := "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc19hZG1pbiI6dHJ1ZX0."
|
||||
if _, err := VerifyAdminToken(testSecret, algNone); err == nil {
|
||||
t.Fatal("none 算法应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAuthMiddleware(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.GET("/protected", AdminAuth(func() string { return testSecret }), func(c *gin.Context) {
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
// 无 token → 401
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/protected", nil))
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("无 token 应 401: %d", w.Code)
|
||||
}
|
||||
// 有效 token → 200
|
||||
token, _, _ := SignAdminToken(testSecret, time.Hour)
|
||||
w = httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("有效 token 应 200: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// 过期 token → 401
|
||||
expired, _, _ := SignAdminToken(testSecret, -time.Minute)
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+expired)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("过期 token 应 401: %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"fileshare/internal/cache"
|
||||
"fileshare/internal/response"
|
||||
)
|
||||
|
||||
// 限流类别(对齐参考 apps/base/utils.py 的 ip_limit)。
|
||||
const (
|
||||
LimitError = "error" // 取件错误(密码错误、取件失败)
|
||||
LimitUpload = "upload" // 上传次数
|
||||
LimitLogin = "login" // 管理员登录失败
|
||||
LimitMeta = "metadata" // 分享元信息查询
|
||||
)
|
||||
|
||||
// LimitRule 限流规则:window 内最多 count 次。
|
||||
type LimitRule struct {
|
||||
Count int // 允许次数
|
||||
Window time.Duration // 时间窗口
|
||||
}
|
||||
|
||||
// clientIP 解析客户端真实 IP:仅当直连地址属于可信代理时才采信 X-Forwarded-For / X-Real-IP。
|
||||
// 语义对齐参考 apps/base/dependencies.py 的 get_client_ip。
|
||||
func clientIP(c *gin.Context, trustedProxies []*net.IPNet) string {
|
||||
remote := net.ParseIP(c.RemoteIP())
|
||||
parse := func(s string) net.IP {
|
||||
ip := net.ParseIP(strings.TrimSpace(s))
|
||||
return ip
|
||||
}
|
||||
isTrusted := func(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, n := range trustedProxies {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if !isTrusted(remote) {
|
||||
return remote.String()
|
||||
}
|
||||
// X-Forwarded-For:从右往左找第一个非可信代理地址
|
||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
candidate := parse(parts[i])
|
||||
if candidate == nil {
|
||||
return remote.String()
|
||||
}
|
||||
if !isTrusted(candidate) {
|
||||
return candidate.String()
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
if xr := c.GetHeader("X-Real-IP"); xr != "" {
|
||||
if ip := parse(xr); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
return remote.String()
|
||||
}
|
||||
|
||||
// ParseTrustedProxies 把 CIDR/单 IP 字符串解析为网络列表。
|
||||
func ParseTrustedProxies(items []string) []*net.IPNet {
|
||||
var out []*net.IPNet
|
||||
for _, item := range items {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(item, "/") {
|
||||
item += "/32"
|
||||
if strings.Contains(item, ":") { // IPv6
|
||||
item = item[:len(item)-3] + "/128"
|
||||
}
|
||||
}
|
||||
_, network, err := net.ParseCIDR(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, network)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ClientIP 中间件:解析真实 IP 并写入上下文(ctxClientIP)。
|
||||
func ClientIP(trustedProxies []*net.IPNet) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Set("ctxClientIP", clientIP(c, trustedProxies))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetClientIP 从 gin 上下文取解析后的客户端 IP。
|
||||
func GetClientIP(c *gin.Context) string {
|
||||
if v, ok := c.Get("ctxClientIP"); ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if ip := net.ParseIP(c.RemoteIP()); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
return c.RemoteIP()
|
||||
}
|
||||
|
||||
// RateLimiter 基于 cache.Cache 的固定窗口 IP 限流器。
|
||||
// 计数语义对齐参考实现:check 通过时放行,业务方在发生"计数事件"(如失败/成功上传)后调用 Add。
|
||||
//
|
||||
// L9:缓存故障降级——此前 cache.Get/Incr 失败(如 Redis 宕机)时一律放行,
|
||||
// 登录爆破防护随之失效。现降级为进程内固定窗口计数(单实例语义),
|
||||
// 缓存恢复后自动回到共享缓存计数。降级期间计数独立于缓存,不叠加。
|
||||
type RateLimiter struct {
|
||||
cache cache.Cache
|
||||
limits map[string]LimitRule
|
||||
prefix string
|
||||
|
||||
fbMu sync.Mutex
|
||||
fallback map[string]*fallbackEntry // 进程内降级计数
|
||||
lastPrune time.Time
|
||||
}
|
||||
|
||||
type fallbackEntry struct {
|
||||
count int64
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// fallbackMaxEntries 降级计数表上限(超出即整体重置,防内存增长)。
|
||||
const fallbackMaxEntries = 8192
|
||||
|
||||
// NewRateLimiter 构造限流器;limits 为各类别规则(来自 settings 的 errorCount/errorMinute 等)。
|
||||
func NewRateLimiter(cache cache.Cache, limits map[string]LimitRule) *RateLimiter {
|
||||
if limits == nil {
|
||||
limits = map[string]LimitRule{}
|
||||
}
|
||||
return &RateLimiter{cache: cache, limits: limits, prefix: "fcb:rl", fallback: map[string]*fallbackEntry{}}
|
||||
}
|
||||
|
||||
// SetRule 运行时更新规则(settings KV 变更后调用)。
|
||||
func (r *RateLimiter) SetRule(kind string, rule LimitRule) {
|
||||
r.limits[kind] = rule
|
||||
}
|
||||
|
||||
func (r *RateLimiter) windowKey(kind, ip string, now time.Time) string {
|
||||
// 固定窗口:按窗口起点分桶
|
||||
bucket := now.Unix() / int64(r.limits[kind].Window/time.Second)
|
||||
return r.prefix + ":" + kind + ":" + ip + ":" + itoa64(bucket)
|
||||
}
|
||||
|
||||
// Check 只读检查该 IP 在当前窗口内是否仍被允许(不计数)。
|
||||
// 对齐参考 check_ip:已用次数 >= 上限即拒绝。
|
||||
// 缓存键不存在(ErrNotFound)视为 0 次;缓存故障时降级为进程内计数。
|
||||
func (r *RateLimiter) Check(c *gin.Context, kind string) (bool, int64) {
|
||||
rule, ok := r.limits[kind]
|
||||
if !ok || rule.Count <= 0 || rule.Window <= 0 {
|
||||
return true, 0
|
||||
}
|
||||
ip := GetClientIP(c)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
now := time.Now()
|
||||
raw, err := r.cache.Get(ctx, r.windowKey(kind, ip, now))
|
||||
if err != nil {
|
||||
if errors.Is(err, cache.ErrNotFound) {
|
||||
return true, 0 // 键不存在:窗口内尚无计数
|
||||
}
|
||||
// 缓存故障:降级进程内计数判定
|
||||
return r.fallbackCount(kind, ip, rule, now) < int64(rule.Count), 0
|
||||
}
|
||||
n := parseInt64(raw)
|
||||
return n < int64(rule.Count), n
|
||||
}
|
||||
|
||||
// Add 记录一次计数事件(对齐参考 add_ip:调用即计数,如上传成功/登录失败/取件错误)。
|
||||
// 缓存故障时降级为进程内计数。
|
||||
func (r *RateLimiter) Add(c *gin.Context, kind string) {
|
||||
rule, ok := r.limits[kind]
|
||||
if !ok || rule.Count <= 0 || rule.Window <= 0 {
|
||||
return
|
||||
}
|
||||
ip := GetClientIP(c)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if _, err := r.cache.Incr(ctx, r.windowKey(kind, ip, time.Now()), rule.Window); err != nil &&
|
||||
!errors.Is(err, cache.ErrNotFound) {
|
||||
// Incr 正常情况下不会因键不存在失败(缺键即从 0 起);
|
||||
// 其余错误视为缓存故障 → 进程内计数
|
||||
r.fallbackIncr(kind, ip, rule, time.Now())
|
||||
}
|
||||
}
|
||||
|
||||
// —— 进程内降级计数(L9)——
|
||||
|
||||
func (r *RateLimiter) fallbackIncr(kind, ip string, rule LimitRule, now time.Time) {
|
||||
key := r.windowKey(kind, ip, now)
|
||||
expires := now.Add(rule.Window)
|
||||
r.fbMu.Lock()
|
||||
defer r.fbMu.Unlock()
|
||||
r.pruneFallbackLocked(now)
|
||||
if len(r.fallback) >= fallbackMaxEntries {
|
||||
r.fallback = map[string]*fallbackEntry{} // 极端情况整体重置,防内存无限增长
|
||||
}
|
||||
e, ok := r.fallback[key]
|
||||
if !ok || now.After(e.expires) {
|
||||
r.fallback[key] = &fallbackEntry{count: 1, expires: expires}
|
||||
return
|
||||
}
|
||||
e.count++
|
||||
}
|
||||
|
||||
func (r *RateLimiter) fallbackCount(kind, ip string, rule LimitRule, now time.Time) int64 {
|
||||
key := r.windowKey(kind, ip, now)
|
||||
r.fbMu.Lock()
|
||||
defer r.fbMu.Unlock()
|
||||
e, ok := r.fallback[key]
|
||||
if !ok || now.After(e.expires) {
|
||||
return 0
|
||||
}
|
||||
return e.count
|
||||
}
|
||||
|
||||
// pruneFallbackLocked 清理已过窗口的降级计数(低频触发:每 1000 条或 10 分钟一次)。
|
||||
func (r *RateLimiter) pruneFallbackLocked(now time.Time) {
|
||||
if r.lastPrune.IsZero() || len(r.fallback) >= 1024 || now.Sub(r.lastPrune) >= 10*time.Minute {
|
||||
for k, e := range r.fallback {
|
||||
if now.After(e.expires) {
|
||||
delete(r.fallback, k)
|
||||
}
|
||||
}
|
||||
r.lastPrune = now
|
||||
}
|
||||
}
|
||||
|
||||
// RequireRateLimit 中间件:请求进入即检查,请求完成即计数。
|
||||
// 适用于"每次访问都计数"的类别(如 metadata 查询);
|
||||
// 上传/登录等"仅成功/失败才计数"的场景由 handler 显式调用 Check/Add。
|
||||
func (r *RateLimiter) RequireRateLimit(kind string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
allowed, _ := r.Check(c, kind)
|
||||
if !allowed {
|
||||
response.Fail(c, http.StatusLocked, "请求次数过多,请稍后再试")
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
r.Add(c, kind)
|
||||
}
|
||||
}
|
||||
|
||||
// parseInt64 解析十进制整数字符串,非法输入返回 0。
|
||||
func parseInt64(s string) int64 {
|
||||
var n int64
|
||||
for _, ch := range s {
|
||||
if ch < '0' || ch > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int64(ch-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func itoa64(n int64) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var buf [21]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"fileshare/internal/cache"
|
||||
)
|
||||
|
||||
// failingCache 模拟缓存故障(Get/Incr 均返回非 ErrNotFound 错误)。
|
||||
type failingCache struct{ cache.Cache }
|
||||
|
||||
func (f *failingCache) Get(_ context.Context, _ string) (string, error) {
|
||||
return "", context.DeadlineExceeded
|
||||
}
|
||||
func (f *failingCache) Set(_ context.Context, _, _ string, _ time.Duration) error {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
func (f *failingCache) Incr(_ context.Context, _ string, _ time.Duration) (int64, error) {
|
||||
return 0, context.DeadlineExceeded
|
||||
}
|
||||
|
||||
func newLimiterTest(c *gin.Context, cacheImpl cache.Cache, count int) *RateLimiter {
|
||||
return NewRateLimiter(cacheImpl, map[string]LimitRule{
|
||||
LimitLogin: {Count: count, Window: time.Minute},
|
||||
})
|
||||
}
|
||||
|
||||
func ginTestContext() *gin.Context {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/admin/login", nil)
|
||||
return c
|
||||
}
|
||||
|
||||
// TestRateLimiterFallbackOnCacheFailure L9:缓存故障时限流降级为进程内计数,
|
||||
// 超过上限后 Check 拒绝(此前 fail-open 会一直放行)。
|
||||
func TestRateLimiterFallbackOnCacheFailure(t *testing.T) {
|
||||
c := ginTestContext()
|
||||
rl := newLimiterTest(c, &failingCache{cache.NewMemory()}, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
if ok, _ := rl.Check(c, LimitLogin); !ok {
|
||||
t.Fatalf("第 %d 次检查不应拒绝", i+1)
|
||||
}
|
||||
rl.Add(c, LimitLogin)
|
||||
}
|
||||
if ok, _ := rl.Check(c, LimitLogin); ok {
|
||||
t.Fatal("缓存故障降级下,超过上限后 Check 应拒绝(fail-close)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimiterNormalCacheCounting 正常缓存路径行为不变。
|
||||
func TestRateLimiterNormalCacheCounting(t *testing.T) {
|
||||
c := ginTestContext()
|
||||
rl := newLimiterTest(c, cache.NewMemory(), 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
rl.Add(c, LimitLogin)
|
||||
}
|
||||
if ok, _ := rl.Check(c, LimitLogin); ok {
|
||||
t.Fatal("达到上限后 Check 应拒绝")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"fileshare/internal/cache"
|
||||
)
|
||||
|
||||
func rateLimitRouter(rl *RateLimiter, kind string) *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(ClientIP(nil))
|
||||
r.GET("/limited", rl.RequireRateLimit(kind), func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestRateLimiterBlocksAfterCount(t *testing.T) {
|
||||
mem := cache.NewMemory()
|
||||
defer mem.Close()
|
||||
rl := NewRateLimiter(mem, map[string]LimitRule{
|
||||
LimitMeta: {Count: 3, Window: time.Minute},
|
||||
})
|
||||
r := rateLimitRouter(rl, LimitMeta)
|
||||
|
||||
// 前 3 次通过
|
||||
for i := 0; i < 3; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/limited", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("第 %d 次应通过: %d", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
// 第 4 次 423
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/limited", nil))
|
||||
if w.Code != http.StatusLocked {
|
||||
t.Fatalf("超限应 423: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterAddAfterSuccess(t *testing.T) {
|
||||
// 模拟 upload 语义:Check 放行 + handler 成功后 Add
|
||||
mem := cache.NewMemory()
|
||||
defer mem.Close()
|
||||
rl := NewRateLimiter(mem, map[string]LimitRule{
|
||||
LimitUpload: {Count: 2, Window: time.Minute},
|
||||
})
|
||||
r := gin.New()
|
||||
r.Use(ClientIP(nil))
|
||||
r.POST("/upload", func(c *gin.Context) {
|
||||
if allowed, _ := rl.Check(c, LimitUpload); !allowed {
|
||||
c.JSON(http.StatusLocked, gin.H{"err": "too many"})
|
||||
return
|
||||
}
|
||||
rl.Add(c, LimitUpload) // 成功上传计数
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("POST", "/upload", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("第 %d 次上传应通过", i+1)
|
||||
}
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("POST", "/upload", nil))
|
||||
if w.Code != http.StatusLocked {
|
||||
t.Fatalf("第 3 次上传应被拒绝: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrustedProxies(t *testing.T) {
|
||||
nets := ParseTrustedProxies([]string{"10.0.0.0/8", "192.168.1.1", "", "bad-input"})
|
||||
if len(nets) != 2 {
|
||||
t.Fatalf("应解析出 2 个可信网段: %d", len(nets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPFromTrustedProxy(t *testing.T) {
|
||||
// 对齐参考语义:仅当直连地址可信时才解析 XFF,
|
||||
// 且从右往左返回第一个"非可信代理"地址(该地址即最近可信代理看到的客户端)。
|
||||
nets := ParseTrustedProxies([]string{"127.0.0.0/8", "10.0.0.0/8"})
|
||||
r := gin.New()
|
||||
r.Use(ClientIP(nets))
|
||||
var seen string
|
||||
r.GET("/ip", func(c *gin.Context) { seen = GetClientIP(c) })
|
||||
|
||||
req := httptest.NewRequest("GET", "/ip", nil)
|
||||
req.RemoteAddr = "127.0.0.1:5000"
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1")
|
||||
r.ServeHTTP(httptest.NewRecorder(), req)
|
||||
if seen != "203.0.113.9" {
|
||||
t.Fatalf("多级可信代理下应取最左非可信地址: %s", seen)
|
||||
}
|
||||
|
||||
// 仅直连可信:XFF 右起第一个(10.0.0.1)非可信 → 取它
|
||||
nets2 := ParseTrustedProxies([]string{"127.0.0.0/8"})
|
||||
r2 := gin.New()
|
||||
r2.Use(ClientIP(nets2))
|
||||
var seen2 string
|
||||
r2.GET("/ip", func(c *gin.Context) { seen2 = GetClientIP(c) })
|
||||
req2 := httptest.NewRequest("GET", "/ip", nil)
|
||||
req2.RemoteAddr = "127.0.0.1:5000"
|
||||
req2.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1")
|
||||
r2.ServeHTTP(httptest.NewRecorder(), req2)
|
||||
if seen2 != "10.0.0.1" {
|
||||
t.Fatalf("右起第一个非可信地址应为 10.0.0.1: %s", seen2)
|
||||
}
|
||||
|
||||
// 非可信直连:忽略伪造头
|
||||
seen = ""
|
||||
req = httptest.NewRequest("GET", "/ip", nil)
|
||||
req.RemoteAddr = "8.8.8.8:1234"
|
||||
req.Header.Set("X-Forwarded-For", "1.2.3.4")
|
||||
r.ServeHTTP(httptest.NewRecorder(), req)
|
||||
if seen != "8.8.8.8" {
|
||||
t.Fatalf("非可信直连应忽略 XFF: %s", seen)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Package model 定义 GORM 数据模型与 Postgres 自动迁移。
|
||||
// 字段对齐参考实现 apps/base/models.py,并新增审计日志表。
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FileCodes 文件/文本分享记录(对齐参考 FileCodes)。
|
||||
type FileCodes struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Code string `gorm:"column:code;size:255;uniqueIndex;not null" json:"code"` // 取件码
|
||||
Prefix string `gorm:"size:255;default:''" json:"prefix"` // 文件名前缀/文本分享标记
|
||||
Suffix string `gorm:"size:255;default:''" json:"suffix"` // 文件名后缀(含扩展名)
|
||||
UUIDFileName *string `gorm:"size:255" json:"uuid_file_name"` // 存储侧 UUID 文件名
|
||||
FilePath *string `gorm:"size:255" json:"file_path"` // 存储侧相对路径
|
||||
Size int64 `gorm:"default:0" json:"size"` // 字节数;文本为字符数
|
||||
Text *string `gorm:"type:text" json:"text"` // 文本分享内容
|
||||
ExpiredAt *time.Time `json:"expired_at"` // 过期时间;永久分享为 NULL
|
||||
ExpiredCount int `gorm:"default:0" json:"expired_count"` // 剩余可取次数;<0 表示按时间过期
|
||||
UsedCount int `gorm:"default:0" json:"used_count"` // 已取次数
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
FileHash *string `gorm:"size:64" json:"file_hash"` // SHA256
|
||||
ContentHash *string `gorm:"size:128;index" json:"content_hash"` // 26.9:SHA512(内容去重;同哈希分享复用同一存储对象)
|
||||
IsChunked bool `gorm:"default:false" json:"is_chunked"`
|
||||
UploadID *string `gorm:"size:36" json:"upload_id"` // 分片上传会话 ID
|
||||
Engine string `gorm:"size:16;default:''" json:"engine"` // 归属存储引擎(26.9:local|s3|webdav;空=历史数据按当前引擎取)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return false
|
||||
}
|
||||
if f.ExpiredCount < 0 {
|
||||
return f.ExpiredAt.Before(now)
|
||||
}
|
||||
return f.ExpiredCount <= 0
|
||||
}
|
||||
|
||||
// UploadChunk 分片上传记录(对齐参考 UploadChunk)。
|
||||
type UploadChunk struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UploadID string `gorm:"size:36;index:idx_upload_chunk,unique,priority:1;not null" json:"upload_id"`
|
||||
ChunkIndex int `gorm:"index:idx_upload_chunk,unique,priority:2;not null" json:"chunk_index"`
|
||||
ChunkHash string `gorm:"size:64;not null" json:"chunk_hash"` // 分片 SHA256
|
||||
TotalChunks int `json:"total_chunks"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
ChunkSize int `json:"chunk_size"`
|
||||
FileName string `gorm:"size:255" json:"file_name"`
|
||||
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"` // 归属存储引擎(26.9:分片与会话记录当时引擎,合并走同一引擎)
|
||||
}
|
||||
|
||||
// TableName 表名。
|
||||
func (UploadChunk) TableName() string { return "upload_chunks" }
|
||||
|
||||
// KeyValue 运行时配置键值(对齐参考 KeyValue)。value 存 JSON。
|
||||
type KeyValue struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Key string `gorm:"size:255;uniqueIndex;not null" json:"key"`
|
||||
Value *string `gorm:"type:text" json:"value"` // JSON 字符串
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName 表名。
|
||||
func (KeyValue) TableName() string { return "key_values" }
|
||||
|
||||
// PresignUploadSession 预签名直传会话(对齐参考 PresignUploadSession)。
|
||||
type PresignUploadSession struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UploadID string `gorm:"size:36;uniqueIndex;not null" json:"upload_id"`
|
||||
FileName string `gorm:"size:255" json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
SavePath string `gorm:"size:512" json:"save_path"`
|
||||
Mode string `gorm:"size:10" json:"mode"` // direct=客户端直传 | proxy=服务器代理
|
||||
ExpireValue int `json:"expire_value"`
|
||||
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"` // 归属存储引擎(26.9:直传/代理完成走同一引擎取回)
|
||||
}
|
||||
|
||||
// TableName 表名。
|
||||
func (PresignUploadSession) TableName() string { return "presign_upload_sessions" }
|
||||
|
||||
// IsExpired 会话是否已过期。
|
||||
func (p *PresignUploadSession) IsExpired(now time.Time) bool { return p.ExpiresAt.Before(now) }
|
||||
|
||||
// StorageReservation 上传容量预留(尚未写入 file_codes 的占位)。
|
||||
type StorageReservation struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Token string `gorm:"size:64;uniqueIndex;not null" json:"token"`
|
||||
Size int64 `json:"size"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
}
|
||||
|
||||
// TableName 表名。
|
||||
func (StorageReservation) TableName() string { return "storage_reservations" }
|
||||
|
||||
// 审计结果常量。
|
||||
const (
|
||||
AuditResultSuccess = "success" // 操作成功
|
||||
AuditResultDenied = "denied" // 被拒绝(限流/鉴权/策略)
|
||||
AuditResultFailed = "failed" // 执行失败(服务端/客户端错误)
|
||||
)
|
||||
|
||||
// AuditLog 上传/下载审计日志(需求 ③)。
|
||||
type AuditLog struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Action string `gorm:"size:32;index" json:"action"` // upload | download
|
||||
FileCode string `gorm:"size:64;index" json:"file_code"` // 取件码(上传时为生成的码)
|
||||
FileName string `gorm:"size:255" json:"file_name"` // 原始文件名/文本标记
|
||||
SizeBytes int64 `json:"size_bytes"` // 文件总字节数
|
||||
TransferredBytes int64 `json:"transferred_bytes"` // 本次实际传输字节数
|
||||
IP string `gorm:"size:64;index" json:"ip"`
|
||||
UserAgent string `gorm:"size:512" json:"user_agent"`
|
||||
DeviceOS string `gorm:"size:64" json:"device_os"` // Windows/macOS/Android/iOS/Linux/Unknown
|
||||
DeviceBrowser string `gorm:"size:64" json:"device_browser"` // Chrome/Firefox/Safari/Edge/...
|
||||
DeviceType string `gorm:"size:32" json:"device_type"` // desktop/mobile/tablet/bot/other
|
||||
Actor string `gorm:"size:64" json:"actor"` // admin | guest
|
||||
Result string `gorm:"size:16;index" json:"result"` // success | denied | failed
|
||||
ErrorMsg string `gorm:"size:512" json:"error_msg"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"` // 操作时间
|
||||
}
|
||||
|
||||
// TableName 表名。
|
||||
func (AuditLog) TableName() string { return "audit_logs" }
|
||||
|
||||
// AllModels 全部需要迁移的模型。
|
||||
func AllModels() []any {
|
||||
return []any{
|
||||
&FileCodes{},
|
||||
&UploadChunk{},
|
||||
&KeyValue{},
|
||||
&PresignUploadSession{},
|
||||
&StorageReservation{},
|
||||
&AuditLog{},
|
||||
}
|
||||
}
|
||||
|
||||
// AutoMigrate 在 Postgres 上建表/补列;服务启动时调用。
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(AllModels()...)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Package response 提供统一响应封装:{"code":200,"msg":"...","data":...}。
|
||||
package response
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Body 统一响应体。
|
||||
type Body struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// OK 成功响应(code=200)。
|
||||
func OK(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, Body{Code: 200, Msg: "ok", Data: data})
|
||||
}
|
||||
|
||||
// Fail 失败响应,httpStatus 与 code 语义一致(404 过期/不存在、403 拒绝、429 限流、500 服务端错误)。
|
||||
func Fail(c *gin.Context, httpStatus int, msg string) {
|
||||
c.AbortWithStatusJSON(httpStatus, Body{Code: httpStatus, Msg: msg})
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// settings 包双方言测试:Manager 全流程(ensure 行、KV 读写合并、Reload、
|
||||
// UpdateKV 屏蔽内部键、SystemStart)分别在 sqlite(默认)与 postgres(FCB_TEST_PG_DSN)上执行。
|
||||
package settings_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"fileshare/internal/config"
|
||||
"fileshare/internal/database"
|
||||
"fileshare/internal/settings"
|
||||
)
|
||||
|
||||
// pgTestDSN 返回 Postgres 测试连接串;未设置 FCB_TEST_PG_DSN 时跳过。
|
||||
func pgTestDSN(t *testing.T) string {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("FCB_TEST_PG_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("未设置 FCB_TEST_PG_DSN,跳过 Postgres 双方言用例")
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
// newTestManager 按方言构造库 + Manager(已完成 Migrate)。
|
||||
func newTestManager(t *testing.T, driver, dsn string) (*settings.Manager, *gorm.DB, func()) {
|
||||
t.Helper()
|
||||
if dsn == "" {
|
||||
dsn = filepath.Join(t.TempDir(), "settings-test.db")
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := database.Open(ctx, database.Options{Driver: driver, DSN: dsn})
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] Open: %v", driver, err)
|
||||
}
|
||||
if err := database.Migrate(ctx, db); err != nil {
|
||||
_ = database.Close(db)
|
||||
t.Fatalf("[%s] Migrate: %v", driver, err)
|
||||
}
|
||||
// Config 仅作内存载体(驱动不回连数据库),postgres 模式给占位 DSN 以通过校验
|
||||
t.Setenv("FCB_DB_DRIVER", driver)
|
||||
if driver == "postgres" {
|
||||
t.Setenv("FCB_DB_DSN", dsn)
|
||||
} else {
|
||||
t.Setenv("FCB_DB_DSN", "")
|
||||
}
|
||||
cfg, err := config.New()
|
||||
if err != nil {
|
||||
_ = database.Close(db)
|
||||
t.Fatalf("[%s] config.New: %v", driver, err)
|
||||
}
|
||||
mgr, err := settings.NewManager(ctx, db, cfg)
|
||||
if err != nil {
|
||||
_ = database.Close(db)
|
||||
t.Fatalf("[%s] NewManager: %v", driver, err)
|
||||
}
|
||||
return mgr, db, func() { _ = database.Close(db) }
|
||||
}
|
||||
|
||||
// runManagerSuite 双方言共用的 Manager 行为断言。
|
||||
func runManagerSuite(t *testing.T, mgr *settings.Manager) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. 初始:未初始化(admin_token 空)
|
||||
if mgr.IsInitialized() {
|
||||
t.Fatal("初始 admin_token 为空应视为未初始化")
|
||||
}
|
||||
|
||||
// 2. UpdateKV 写入策略键 → Reload 后读取生效
|
||||
patch := map[string]any{
|
||||
settings.KeyBackgroundURL: "https://example.com/bg.png",
|
||||
settings.KeyFooterText: "自建部署,仅供内部演示",
|
||||
settings.KeyFooterBeian: "京ICP备2024000001号-1",
|
||||
settings.KeyNotifyEnabled: 0,
|
||||
settings.KeyMaxSaveSeconds: 86400,
|
||||
"_internal_secret": "must-drop", // 下划线内部键必须被拒
|
||||
}
|
||||
if err := mgr.UpdateKV(ctx, patch); err != nil {
|
||||
t.Fatalf("UpdateKV 失败: %v", err)
|
||||
}
|
||||
if err := mgr.Reload(ctx); err != nil {
|
||||
t.Fatalf("Reload 失败: %v", err)
|
||||
}
|
||||
cfg := mgr.Get()
|
||||
if got := cfg.GetString(settings.KeyBackgroundURL); got != "https://example.com/bg.png" {
|
||||
t.Fatalf("background_url 未生效: %q", got)
|
||||
}
|
||||
if got := cfg.GetString(settings.KeyFooterBeian); got != "京ICP备2024000001号-1" {
|
||||
t.Fatalf("footer_beian 未生效: %q", got)
|
||||
}
|
||||
if cfg.GetBool(settings.KeyNotifyEnabled) {
|
||||
t.Fatal("notify_enabled=0 应生效")
|
||||
}
|
||||
if got := cfg.MaxSaveSeconds(); got != 86400 {
|
||||
t.Fatalf("max_save_seconds 未生效: %d", got)
|
||||
}
|
||||
if _, ok := cfg.Get("_internal_secret"); ok {
|
||||
t.Fatal("下划线内部键不应进入运行时配置")
|
||||
}
|
||||
|
||||
// 3. KV 合并语义:二次 UpdateKV 不覆盖未提及键
|
||||
if err := mgr.UpdateKV(ctx, map[string]any{settings.KeyNotifyEnabled: 1}); err != nil {
|
||||
t.Fatalf("二次 UpdateKV: %v", err)
|
||||
}
|
||||
if err := mgr.Reload(ctx); err != nil {
|
||||
t.Fatalf("二次 Reload: %v", err)
|
||||
}
|
||||
cfg = mgr.Get()
|
||||
if !cfg.GetBool(settings.KeyNotifyEnabled) {
|
||||
t.Fatal("notify_enabled 二次写入应生效")
|
||||
}
|
||||
if got := cfg.GetString(settings.KeyFooterText); got == "" {
|
||||
t.Fatal("二次写入不应清空 footer_text")
|
||||
}
|
||||
|
||||
// 4. SystemStart:sys_start 键写入且为毫秒时间戳
|
||||
mgr.SystemStart(ctx)
|
||||
|
||||
// 5. 敏感键判定(双模式一致)
|
||||
if !settings.IsSensitiveKey("admin_token") || !settings.IsSensitiveKey("jwt_secret") {
|
||||
t.Fatal("admin_token/jwt_secret 应为敏感键")
|
||||
}
|
||||
if settings.IsSensitiveKey("footer_text") {
|
||||
t.Fatal("footer_text 不应为敏感键")
|
||||
}
|
||||
|
||||
// 6. KV schema 表完整性:全部键可从默认值读取
|
||||
for _, e := range settings.KVSchema() {
|
||||
if _, ok := cfg.Get(e.Key); !ok {
|
||||
t.Fatalf("schema 键 %q 在默认配置中不存在", e.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerSQLite(t *testing.T) {
|
||||
mgr, _, closeFn := newTestManager(t, "sqlite", "")
|
||||
defer closeFn()
|
||||
runManagerSuite(t, mgr)
|
||||
}
|
||||
|
||||
func TestManagerPostgres(t *testing.T) {
|
||||
dsn := pgTestDSN(t)
|
||||
mgr, _, closeFn := newTestManager(t, "postgres", dsn)
|
||||
defer closeFn()
|
||||
runManagerSuite(t, mgr)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Package settings 密码哈希与校验:
|
||||
// 新密码使用 bcrypt(格式 bcrypt$<bcrypt原生哈希串>);同时兼容两代旧格式——
|
||||
// sha256$salt$hash(上一版)与旧版明文(迁移校验)。
|
||||
// 安全审计 M1:单轮 SHA256+盐抗 GPU 爆破不足,新哈希统一升级 bcrypt。
|
||||
// 兼容策略:VerifyPassword 支持全部三代格式;调用方可用 NeedsRehash 判定
|
||||
// 登录成功后是否需要用新算法重哈希写回(登录升级路径见 api.adminLogin)。
|
||||
package settings
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// bcryptCost bcrypt 工作因子:12(2026 年桌面 CPU 单次校验约 100-250ms,
|
||||
// 离线爆破成本相比单轮 SHA256 提升数个数量级)。
|
||||
const bcryptCost = 12
|
||||
|
||||
// bcryptMaxLen bcrypt 算法只取前 72 字节;超长输入统一截断,
|
||||
// 避免 GenerateFromPassword/CompareHashAndPassword 对 >72 字节返回错误。
|
||||
const bcryptMaxLen = 72
|
||||
|
||||
func bcryptBytes(password string) []byte {
|
||||
b := []byte(password)
|
||||
if len(b) > bcryptMaxLen {
|
||||
b = b[:bcryptMaxLen]
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// HashPassword 生成 bcrypt$<hash> 格式密码哈希(<hash> 为 bcrypt 原生串
|
||||
// `$2a$<cost>$<salt><hash>`,cost 内嵌于哈希串中)。
|
||||
func HashPassword(password string) string {
|
||||
sum, err := bcrypt.GenerateFromPassword(bcryptBytes(password), bcryptCost)
|
||||
if err != nil {
|
||||
// 截断后仅剩非法 cost 等实现级错误:确定性失败优于弱哈希回落
|
||||
panic("settings: bcrypt 哈希失败: " + err.Error())
|
||||
}
|
||||
return "bcrypt$" + string(sum)
|
||||
}
|
||||
|
||||
// VerifyPassword 校验密码:支持 bcrypt$、sha256$salt$hash 与旧版明文三种格式。
|
||||
func VerifyPassword(password, hashed string) bool {
|
||||
if hashed == "" {
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(hashed, "bcrypt$"):
|
||||
return bcrypt.CompareHashAndPassword([]byte(hashed[len("bcrypt$"):]), bcryptBytes(password)) == nil
|
||||
case strings.HasPrefix(hashed, "sha256$"):
|
||||
parts := strings.Split(hashed, "$")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
salt, stored := parts[1], parts[2]
|
||||
sum := sha256.Sum256([]byte(salt + password))
|
||||
return hmac.Equal([]byte(hex.EncodeToString(sum[:])), []byte(stored))
|
||||
}
|
||||
// 旧版明文比较(兼容迁移)
|
||||
return hmac.Equal([]byte(password), []byte(hashed))
|
||||
}
|
||||
|
||||
// NeedsRehash 判断哈希是否需要升级为当前算法/成本(登录成功后判定,透明迁移)。
|
||||
// sha256 与明文一律 true;bcrypt 成本低于当前 bcryptCost 时 true。
|
||||
func NeedsRehash(hashed string) bool {
|
||||
if !strings.HasPrefix(hashed, "bcrypt$") {
|
||||
return true
|
||||
}
|
||||
// bcrypt 原生串格式:$2a$<cost>$<salt><hash>
|
||||
parts := strings.Split(hashed[len("bcrypt$"):], "$")
|
||||
if len(parts) < 4 {
|
||||
return true
|
||||
}
|
||||
cost, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return cost < bcryptCost
|
||||
}
|
||||
|
||||
// IsPasswordHashed 判断是否为受支持的哈希格式(bcrypt / sha256)。
|
||||
func IsPasswordHashed(s string) bool {
|
||||
return strings.HasPrefix(s, "bcrypt$") || strings.HasPrefix(s, "sha256$")
|
||||
}
|
||||
|
||||
// GenerateJWTSecret 生成 64 字符十六进制随机密钥。
|
||||
func GenerateJWTSecret() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("settings: crypto/rand 不可用: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package settings
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHashPasswordRoundTrip(t *testing.T) {
|
||||
h := HashPassword("s3cret-密码")
|
||||
if !IsPasswordHashed(h) {
|
||||
t.Fatalf("哈希格式不对: %s", h)
|
||||
}
|
||||
if !VerifyPassword("s3cret-密码", h) {
|
||||
t.Fatal("正确密码校验失败")
|
||||
}
|
||||
if VerifyPassword("wrong", h) {
|
||||
t.Fatal("错误密码竟通过校验")
|
||||
}
|
||||
if h == HashPassword("s3cret-密码") {
|
||||
t.Fatal("盐值未随机化")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLegacyPlaintext(t *testing.T) {
|
||||
if !VerifyPassword("FileCodeBox2023", "FileCodeBox2023") {
|
||||
t.Fatal("旧版明文兼容校验失败")
|
||||
}
|
||||
if VerifyPassword("nope", "FileCodeBox2023") {
|
||||
t.Fatal("明文比较不应放行其他密码")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateJWTSecretLength(t *testing.T) {
|
||||
s := GenerateJWTSecret()
|
||||
if len(s) < 32 {
|
||||
t.Fatalf("密钥太短: %d", len(s))
|
||||
}
|
||||
if s == GenerateJWTSecret() {
|
||||
t.Fatal("密钥未随机化")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Package settings — sanitize.go:受控 HTML 白名单净化(安全审计 L7)。
|
||||
//
|
||||
// notify_content 设计上「允许 <a> 等受控 HTML」,此前由管理端任意写入并经
|
||||
// 前端 v-html 直出——管理员账号一旦被盗即可对全站访客注入脚本。
|
||||
// 本净化器只保留纯文本与 <a href="http(s)|/|#">,其余标签连同其内层内容
|
||||
// 一并丢弃(不做 HTML 转义输出,避免脚本字面量进入页面 DOM),
|
||||
// 在公开配置读取与保存两处调用(双保险,覆盖历史存量数据)。
|
||||
package settings
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// dropContentTags 标签内部内容也一并丢弃的危险标签(script/style 等)。
|
||||
var dropContentTags = map[string]bool{
|
||||
"script": true, "style": true, "iframe": true, "object": true, "embed": true,
|
||||
"title": true, "textarea": true, "noscript": true, "template": true,
|
||||
"svg": true, "math": true, "xmp": true, "noembed": true, "noframes": true,
|
||||
}
|
||||
|
||||
// SanitizeInlineHTML 白名单净化内联 HTML:
|
||||
// - <script>/<style>/<iframe> 等危险标签连同内部内容整体丢弃;
|
||||
// - 其他非 <a> 标签仅丢弃标签本身、保留其内层文本(如 <b>加粗</b> → 加粗);
|
||||
// - <a> 仅保留 href 属性,且值必须以 http://、https://、/ 或 # 开头;
|
||||
// - HTML 注释(<!-- -->)丢弃,未闭合的危险标签丢弃其后全部内容;
|
||||
// - 文本片段原样保留(不含 '<',渲染时为安全文本节点)。
|
||||
func SanitizeInlineHTML(input string) string {
|
||||
if input == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(input))
|
||||
i := 0
|
||||
pendingAnchor := false
|
||||
writeClose := func() {
|
||||
if pendingAnchor {
|
||||
b.WriteString("</a>")
|
||||
pendingAnchor = false
|
||||
}
|
||||
}
|
||||
for i < len(input) {
|
||||
lt := strings.IndexByte(input[i:], '<')
|
||||
if lt < 0 {
|
||||
b.WriteString(input[i:])
|
||||
break
|
||||
}
|
||||
b.WriteString(input[i : i+lt])
|
||||
rest := input[i+lt:]
|
||||
// 注释:整体丢弃
|
||||
if strings.HasPrefix(rest, "<!--") {
|
||||
end := strings.Index(rest, "-->")
|
||||
if end < 0 {
|
||||
break // 未闭合注释:丢弃剩余全部
|
||||
}
|
||||
i += lt + end + 3
|
||||
continue
|
||||
}
|
||||
end := findTagEnd(rest)
|
||||
if end < 0 {
|
||||
break // 未闭合标签:丢弃剩余全部(不当作文本,防 < 绕过)
|
||||
}
|
||||
rawTag := rest[:end+1] // 形如 "<a href=..>"、"</div>"、"<img .../>"
|
||||
name, closing, _ := parseTagName(rawTag)
|
||||
if name != "" && !closing {
|
||||
if dropContentTags[name] {
|
||||
// 危险标签:连内层跳到对应闭合标签;无闭合(如 <script> 到结尾)则全丢
|
||||
closeIdx := findClosingTag(input, i+lt+end+1, name)
|
||||
if closeIdx < 0 {
|
||||
writeClose()
|
||||
return b.String()
|
||||
}
|
||||
i = closeIdx
|
||||
continue
|
||||
}
|
||||
if name == "a" {
|
||||
writeClose()
|
||||
if href, ok := parseAllowedAnchor(rawTag); ok {
|
||||
b.WriteString(`<a href="` + escapeAttr(href) + `">`)
|
||||
pendingAnchor = true
|
||||
}
|
||||
// href 非法的 <a>:标签丢弃,但内层文本仍保留
|
||||
}
|
||||
// 其余开标签:丢弃标签本身,保留内层文本
|
||||
i += lt + end + 1
|
||||
continue
|
||||
}
|
||||
if name != "" && closing && name == "a" {
|
||||
writeClose() // 仅在存在未闭合的合法 <a> 时输出
|
||||
}
|
||||
// 其余闭标签:丢弃
|
||||
i += lt + end + 1
|
||||
}
|
||||
writeClose()
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// findTagEnd 返回标签结束 '>' 的下标(跳过引号内的 '>',如 href="a<b">);未找到返回 -1。
|
||||
func findTagEnd(s string) int {
|
||||
inQuote := byte(0)
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if inQuote != 0 {
|
||||
if c == inQuote {
|
||||
inQuote = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '"', '\'':
|
||||
inQuote = c
|
||||
case '>':
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// parseTagName 解析标签名:返回 (小写名, 是否闭合标签, 是否自闭合 "/>")。
|
||||
func parseTagName(tag string) (name string, closing, selfClosing bool) {
|
||||
if len(tag) < 3 || tag[0] != '<' || tag[len(tag)-1] != '>' {
|
||||
return "", false, false
|
||||
}
|
||||
inner := tag[1 : len(tag)-1]
|
||||
if strings.HasSuffix(inner, "/") {
|
||||
selfClosing = true
|
||||
inner = inner[:len(inner)-1]
|
||||
}
|
||||
if strings.HasPrefix(inner, "/") {
|
||||
closing = true
|
||||
inner = inner[1:]
|
||||
}
|
||||
end := 0
|
||||
for end < len(inner) {
|
||||
r := inner[end]
|
||||
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
|
||||
break
|
||||
}
|
||||
end++
|
||||
}
|
||||
if end == 0 {
|
||||
return "", closing, selfClosing
|
||||
}
|
||||
return strings.ToLower(inner[:end]), closing, selfClosing
|
||||
}
|
||||
|
||||
// findClosingTag 从 from 开始查找 </name>,返回闭合标签结束位置(不含);找不到返回 -1。
|
||||
func findClosingTag(s string, from int, name string) int {
|
||||
needle := "</" + name
|
||||
lower := strings.ToLower(s)
|
||||
pos := from
|
||||
for {
|
||||
idx := strings.Index(lower[pos:], needle)
|
||||
if idx < 0 {
|
||||
return -1
|
||||
}
|
||||
at := pos + idx
|
||||
after := at + len(needle)
|
||||
if after < len(s) {
|
||||
r := lower[after]
|
||||
if r != '>' && r != ' ' && r != '\t' && r != '\n' && r != '\r' && r != '/' {
|
||||
pos = after
|
||||
continue // 形如 </scriptx> 的伪闭合,继续找
|
||||
}
|
||||
}
|
||||
end := strings.IndexByte(s[after:], '>')
|
||||
if end < 0 {
|
||||
return -1
|
||||
}
|
||||
return after + end + 1
|
||||
}
|
||||
}
|
||||
|
||||
// parseAllowedAnchor 解析 <a ...> 标签:仅当 href 合法时返回 (href, true)。
|
||||
func parseAllowedAnchor(tag string) (string, bool) {
|
||||
inner := tag[1 : len(tag)-1]
|
||||
// 标签名
|
||||
nameEnd := 0
|
||||
for nameEnd < len(inner) {
|
||||
r := inner[nameEnd]
|
||||
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
|
||||
break
|
||||
}
|
||||
nameEnd++
|
||||
}
|
||||
href, found := scanAttr(inner[nameEnd:], "href")
|
||||
if !found {
|
||||
return "", false // 无 href 的 <a> 不放行(避免依赖默认行为)
|
||||
}
|
||||
href = strings.TrimSpace(href)
|
||||
lower := strings.ToLower(href)
|
||||
if !(strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") ||
|
||||
strings.HasPrefix(href, "/") || strings.HasPrefix(href, "#")) {
|
||||
return "", false // javascript:/data: 等一律拒绝
|
||||
}
|
||||
return href, true
|
||||
}
|
||||
|
||||
// scanAttr 扫描属性串中的目标属性(支持双引号/单引号/无引号值)。
|
||||
func scanAttr(s, name string) (string, bool) {
|
||||
lower := strings.ToLower(s)
|
||||
want := strings.ToLower(name)
|
||||
for i := 0; i < len(lower); {
|
||||
// 跳过空白
|
||||
for i < len(lower) && (lower[i] == ' ' || lower[i] == '\t' || lower[i] == '\n' || lower[i] == '\r') {
|
||||
i++
|
||||
}
|
||||
if i >= len(lower) {
|
||||
break
|
||||
}
|
||||
// 属性名
|
||||
start := i
|
||||
for i < len(lower) && lower[i] != '=' && lower[i] != ' ' && lower[i] != '\t' && lower[i] != '\n' && lower[i] != '\r' {
|
||||
i++
|
||||
}
|
||||
attrName := lower[start:i]
|
||||
// 跳过空白
|
||||
for i < len(lower) && (lower[i] == ' ' || lower[i] == '\t' || lower[i] == '\n' || lower[i] == '\r') {
|
||||
i++
|
||||
}
|
||||
if i < len(lower) && lower[i] == '=' {
|
||||
i++ // 跳过 '='
|
||||
for i < len(lower) && (lower[i] == ' ' || lower[i] == '\t' || lower[i] == '\n' || lower[i] == '\r') {
|
||||
i++
|
||||
}
|
||||
var val string
|
||||
if i < len(lower) && (lower[i] == '"' || lower[i] == '\'') {
|
||||
q := lower[i]
|
||||
i++
|
||||
vs := i
|
||||
for i < len(lower) && lower[i] != q {
|
||||
i++
|
||||
}
|
||||
val = s[vs:i]
|
||||
if i < len(lower) {
|
||||
i++ // 跳过闭合引号
|
||||
}
|
||||
} else {
|
||||
vs := i
|
||||
for i < len(lower) && lower[i] != ' ' && lower[i] != '\t' && lower[i] != '\n' && lower[i] != '\r' {
|
||||
i++
|
||||
}
|
||||
val = s[vs:i]
|
||||
}
|
||||
if attrName == want {
|
||||
return val, true
|
||||
}
|
||||
} else if attrName == want {
|
||||
return "", true // 布尔属性:存在即命中(值空,调用方按非法处理)
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// escapeAttr HTML 属性转义。
|
||||
func escapeAttr(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
||||
return r.Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// sanitize_test.go — SanitizeInlineHTML 单测(安全审计 L7)。
|
||||
package settings
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSanitizeInlineHTML(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"空串", "", ""},
|
||||
{"纯文本保留", "欢迎使用文件快传", "欢迎使用文件快传"},
|
||||
{"合法链接保留", `<a href="https://example.com">官网</a>`, `<a href="https://example.com">官网</a>`},
|
||||
{"相对路径链接", `<a href="/docs">文档</a>`, `<a href="/docs">文档</a>`},
|
||||
{"锚点链接", `<a href="#top">顶部</a>`, `<a href="#top">顶部</a>`},
|
||||
{"script 整体丢弃", `hello<script>alert(1)</script>world`, "helloworld"},
|
||||
{"img 丢弃保留文本", `a<img src=x onerror=alert(1)>b`, "ab"},
|
||||
{"javascript href 拒绝", `<a href="javascript:alert(1)">x</a>`, "x"},
|
||||
{"data href 拒绝", `<a href="data:text/html,<script>">x</a>`, "x"},
|
||||
{"事件属性不透传", `<a href="/x" onclick="evil()">y</a>`, `<a href="/x">y</a>`},
|
||||
{"注释丢弃", `a<!-- secret -->b`, "ab"},
|
||||
{"未闭合标签丢弃剩余", `ok<script>alert(1)`, "ok"},
|
||||
{"iframe 丢弃", `<iframe src="//evil"></iframe>text`, "text"},
|
||||
{"样式标签丢弃", `<style>*{}</style>plain`, "plain"},
|
||||
{"嵌套危险标签", `<div onclick=e><b>bold</b></div>`, "bold"},
|
||||
{"大小写标签", `<A HREF="https://e.com">L</A>`, `<a href="https://e.com">L</a>`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := SanitizeInlineHTML(tc.in); got != tc.want {
|
||||
t.Fatalf("SanitizeInlineHTML(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeInlineHTMLNoScriptContent(t *testing.T) {
|
||||
// script 内部文本也必须丢弃(不做 HTML 转义输出,避免 alert 字样进入页面 DOM)
|
||||
got := SanitizeInlineHTML(`<script>var x = "</b>"; alert(1)</script>fine`)
|
||||
if got != "fine" {
|
||||
t.Fatalf("script 内容应整体丢弃, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Package settings — schema.go:26.9 配置键 schema 常量与元数据表。
|
||||
//
|
||||
// 键名常量的单一事实来源在 internal/config/schema.go(defaults() 需引用);
|
||||
// 本文件 re-export 供 API/管理层使用,并提供「键名/类型/默认值」全量表,
|
||||
// 供管理端设置页与文档生成(t4)对齐。新增键必须同步:
|
||||
// 1. config/schema.go 键名与边界常量
|
||||
// 2. config/config.go defaults() 默认值
|
||||
// 3. 本文件 KVSchema() 元数据行
|
||||
// 4. schema 同步测试(config schema_test / settings schema_test)
|
||||
package settings
|
||||
|
||||
import "fileshare/internal/config"
|
||||
|
||||
// —— 键名 re-export(与 config 包保持同一字符串,避免魔法值散落)——
|
||||
const (
|
||||
// 需求 ① 背景图
|
||||
KeyBackground = config.KeyBackground
|
||||
KeyBackgroundURL = config.KeyBackgroundURL
|
||||
// 需求 ② 页脚
|
||||
KeyFooterText = config.KeyFooterText
|
||||
KeyFooterBeian = config.KeyFooterBeian
|
||||
// 需求 ③ 系统通知
|
||||
KeyNotifyEnabled = config.KeyNotifyEnabled
|
||||
KeyNotifyTitle = config.KeyNotifyTitle
|
||||
KeyNotifyContent = config.KeyNotifyContent
|
||||
// 需求 ④ 保存策略与上传频率限制
|
||||
KeyMaxSaveSeconds = config.KeyMaxSaveSeconds
|
||||
KeyMaxSaveCount = config.KeyMaxSaveCount
|
||||
KeyExpireStyle = config.KeyExpireStyle
|
||||
KeyUploadCount = config.KeyUploadCount
|
||||
KeyUploadMinute = config.KeyUploadMinute
|
||||
// 需求 ④⑩ 存储策略
|
||||
KeyUploadSize = config.KeyUploadSize
|
||||
KeyMaxFileSize = config.KeyMaxFileSize
|
||||
KeyAllowedTypes = config.KeyAllowedTypes
|
||||
KeyStorageLimit = config.KeyStorageLimit
|
||||
KeyOpenUpload = config.KeyOpenUpload
|
||||
// 26.9 存储引擎
|
||||
KeyStorageEngine = config.KeyStorageEngine
|
||||
)
|
||||
|
||||
// —— 取值边界 re-export ——
|
||||
const (
|
||||
MaxSaveSecondsMax = config.MaxSaveSecondsMax // 最长保存秒数上限(365 天)
|
||||
MaxSaveCountMax = config.MaxSaveCountMax // 保存次数上限
|
||||
MaxFileSizeMax = config.MaxFileSizeMax // 单文件大小上限(10 GiB)
|
||||
BackgroundURLMaxLen = config.BackgroundURLMaxLen // 背景图 URL 长度上限
|
||||
FooterTextMaxLen = config.FooterTextMaxLen // 页脚内容长度上限
|
||||
FooterBeianMaxLen = config.FooterBeianMaxLen // 备案号长度上限
|
||||
NotifyTitleMaxLen = config.NotifyTitleMaxLen // 通知标题长度上限
|
||||
NotifyContentMaxLen = config.NotifyContentMaxLen // 通知内容长度上限
|
||||
)
|
||||
|
||||
// 敏感键:不允许出现在管理端 config get 下发/前端可见集合中(双模式下一致生效)。
|
||||
// 26.9:引擎凭据(webdav_password/s3_secret_access_key/aws_session_token)加入敏感集——
|
||||
// 管理端 get 返回掩码占位,update 时空串/掩码=不修改;公开 config 永不下发。
|
||||
var SensitiveKeys = []string{
|
||||
"admin_token", "jwt_secret",
|
||||
"webdav_password", "s3_secret_access_key", "aws_session_token",
|
||||
}
|
||||
|
||||
// SensitiveMaskValue 敏感键掩码占位(管理端 get 展示用)。
|
||||
const SensitiveMaskValue = "******"
|
||||
|
||||
// IsSensitiveKey 判断键是否为敏感键(config get 必须屏蔽)。
|
||||
func IsSensitiveKey(key string) bool {
|
||||
for _, k := range SensitiveKeys {
|
||||
if k == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// KVSchema 返回 26.9 全量配置键元数据(键名/类型/默认值/边界/说明)。
|
||||
// 默认值必须与 config defaults() 一致(schema 同步测试保证)。
|
||||
func KVSchema() []config.KVSchemaEntry { return config.KVSchema() }
|
||||
|
||||
// KVSchemaByKey 以键名为索引查看 schema;未知键返回 nil。
|
||||
func KVSchemaByKey(key string) *config.KVSchemaEntry {
|
||||
for i := range config.KVSchema() {
|
||||
if config.KVSchema()[i].Key == key {
|
||||
return &config.KVSchema()[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Package settings 提供数据库 settings KV 的运行时读写:
|
||||
// env(FCB_*)提供基线,DB KV 覆盖可变项;管理端修改后立即生效。
|
||||
package settings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"fileshare/internal/config"
|
||||
"fileshare/internal/model"
|
||||
)
|
||||
|
||||
// settingsKey 数据库中的配置键(对齐参考实现)。
|
||||
const settingsKey = "settings"
|
||||
|
||||
// Manager 设置管理器:线程安全,缓存 KV 覆盖到内存。
|
||||
type Manager struct {
|
||||
db *gorm.DB
|
||||
cfg *config.Config
|
||||
|
||||
mu sync.RWMutex
|
||||
secret string // jwt_secret(频繁使用,单独缓存)
|
||||
initPwd string // admin_token 哈希(频繁使用,单独缓存)
|
||||
}
|
||||
|
||||
// NewManager 构造设置管理器并加载 DB KV。
|
||||
// ensure 默认配置行(首次启动时写入 settings 键)。
|
||||
func NewManager(ctx context.Context, db *gorm.DB, cfg *config.Config) (*Manager, error) {
|
||||
m := &Manager{db: db, cfg: cfg}
|
||||
if err := m.ensureSettingsRow(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.Reload(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ensureSettingsRow 首次启动时把默认安全配置写入 KV(对齐 ensure_settings_row)。
|
||||
func (m *Manager) ensureSettingsRow(ctx context.Context) error {
|
||||
var row model.KeyValue
|
||||
err := m.db.WithContext(ctx).Where(model.KeyValue{Key: settingsKey}).First(&row).Error
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// 双方言:必须用 errors.Is 判定(sqlite 驱动错误链与字符串消息与 postgres 不同)
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
// 不存在:写入初始配置(不含 admin_token/jwt_secret,保持未初始化状态)
|
||||
initial := map[string]any{}
|
||||
raw, _ := json.Marshal(initial)
|
||||
row = model.KeyValue{Key: settingsKey, Value: strPtr(string(raw))}
|
||||
if err := m.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
log.Println("[settings] 系统尚未初始化,请在浏览器中打开站点并完成管理员密码设置")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload 从数据库加载 settings KV 并覆盖到运行时配置。
|
||||
func (m *Manager) Reload(ctx context.Context) error {
|
||||
var row model.KeyValue
|
||||
err := m.db.WithContext(ctx).Where(model.KeyValue{Key: settingsKey}).First(&row).Error
|
||||
if err != nil {
|
||||
// 行不存在时保持现有覆盖(双方言:errors.Is 判定)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
kv := map[string]any{}
|
||||
if row.Value != nil && *row.Value != "" {
|
||||
if err := json.Unmarshal([]byte(*row.Value), &kv); err != nil {
|
||||
log.Printf("[settings] settings KV 解析失败: %v", err)
|
||||
}
|
||||
}
|
||||
// 内部键不允许通过 KV 覆盖(_ 开头)
|
||||
safe := map[string]any{}
|
||||
for k, v := range kv {
|
||||
if len(k) > 0 && k[0] == '_' {
|
||||
continue
|
||||
}
|
||||
safe[k] = v
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.cfg.ApplyKV(safe)
|
||||
m.secret, _ = safe["jwt_secret"].(string)
|
||||
m.initPwd, _ = safe["admin_token"].(string)
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get 返回当前配置(只读使用;不要修改返回值)。
|
||||
func (m *Manager) Get() *config.Config { return m.cfg }
|
||||
|
||||
// SecretProvider 返回 jwt_secret 读取函数(JWT 中间件用)。
|
||||
func (m *Manager) SecretProvider() func() string {
|
||||
return func() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.secret
|
||||
}
|
||||
}
|
||||
|
||||
// IsInitialized 系统是否已完成初始化(管理员密码已设置且非默认密码)。
|
||||
func (m *Manager) IsInitialized() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.initPwd == "" {
|
||||
return false
|
||||
}
|
||||
// 旧版默认密码视为未初始化(对齐 LEGACY_DEFAULT_ADMIN_TOKEN 检查)
|
||||
return !verifyLegacyDefault(m.initPwd)
|
||||
}
|
||||
|
||||
// legacyDefaultToken 参考实现的旧默认管理员密码。
|
||||
const legacyDefaultToken = "FileCodeBox2023"
|
||||
|
||||
// verifyLegacyDefault 检查哈希是否对应旧默认密码。
|
||||
func verifyLegacyDefault(hashed string) bool {
|
||||
if hashed == "" {
|
||||
return false
|
||||
}
|
||||
return VerifyPassword(legacyDefaultToken, hashed)
|
||||
}
|
||||
|
||||
// UpdateKV 合并更新 settings KV(管理端保存配置)。
|
||||
func (m *Manager) UpdateKV(ctx context.Context, patch map[string]any) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
// 读现有值
|
||||
var row model.KeyValue
|
||||
err := m.db.WithContext(ctx).Where(model.KeyValue{Key: settingsKey}).First(&row).Error
|
||||
kv := map[string]any{}
|
||||
if err == nil && row.Value != nil {
|
||||
_ = json.Unmarshal([]byte(*row.Value), &kv)
|
||||
}
|
||||
for k, v := range patch {
|
||||
if len(k) > 0 && k[0] == '_' {
|
||||
continue
|
||||
}
|
||||
kv[k] = v
|
||||
}
|
||||
raw, err := json.Marshal(kv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err == nil && row.ID > 0 {
|
||||
row.Value = strPtr(string(raw))
|
||||
return m.db.WithContext(ctx).Model(&row).Update("value", row.Value).Error
|
||||
}
|
||||
row = model.KeyValue{Key: settingsKey, Value: strPtr(string(raw))}
|
||||
return m.db.WithContext(ctx).Create(&row).Error
|
||||
}
|
||||
|
||||
// SystemStart 记录系统启动时间(对齐 sys_start 键)。
|
||||
func (m *Manager) SystemStart(ctx context.Context) {
|
||||
now := time.Now().UnixMilli()
|
||||
raw, _ := json.Marshal(now)
|
||||
_ = m.db.WithContext(ctx).Where(model.KeyValue{Key: "sys_start"}).
|
||||
Assign(model.KeyValue{Value: strPtr(string(raw))}).
|
||||
FirstOrCreate(&model.KeyValue{Key: "sys_start"}).Error
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
@@ -0,0 +1,10 @@
|
||||
package storage
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrRangeNotSatisfiable 请求的字节范围超出文件大小(对应 HTTP 416)。
|
||||
// 属于增量错误定义,不改动 interface.go 的既有签名。
|
||||
var ErrRangeNotSatisfiable = errors.New("storage: 请求范围超出文件大小")
|
||||
|
||||
// ErrHashMismatch 分片哈希校验失败(合并分片时检测到数据损坏)。
|
||||
var ErrHashMismatch = errors.New("storage: 分片哈希校验失败")
|
||||
@@ -0,0 +1,26 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Factory 按配置构造存储引擎。由 go-storage 提供 New* 实现后接入。
|
||||
// 这里提供注册表模式:各引擎实现注册自己的构造函数,main.go 按名称选择。
|
||||
type Factory func(ctx context.Context) (Storage, error)
|
||||
|
||||
var registry = map[string]Factory{}
|
||||
|
||||
// RegisterEngine 注册引擎构造函数(init 时调用,名称:local|s3|webdav)。
|
||||
func RegisterEngine(name string, f Factory) {
|
||||
registry[name] = f
|
||||
}
|
||||
|
||||
// NewEngine 按名称构造引擎。
|
||||
func NewEngine(ctx context.Context, name string) (Storage, error) {
|
||||
f, ok := registry[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("storage: 未知存储引擎 %q(仅支持 local|s3|webdav)", name)
|
||||
}
|
||||
return f(ctx)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Package storage 定义存储引擎统一契约。
|
||||
//
|
||||
// 本文件是 go-storage 并行开发的接口契约:签名一经定义不再改动。
|
||||
// 三种引擎(local/s3/webdav)都要实现该接口;工厂按 FCB_STORAGE_ENGINE 选择。
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// 错误定义:实现方应返回这些哨兵错误(可用 %w 包装),便于 API 层映射 HTTP 状态码。
|
||||
var (
|
||||
// ErrNotFound 文件不存在(HTTP 404)。
|
||||
ErrNotFound = errors.New("storage: 文件不存在")
|
||||
// ErrInvalidPath 非法路径(路径穿越等,HTTP 400)。
|
||||
ErrInvalidPath = errors.New("storage: 非法文件路径")
|
||||
// ErrUnavailable 存储服务不可用(连接失败等,HTTP 503)。
|
||||
ErrUnavailable = errors.New("storage: 存储服务不可用")
|
||||
)
|
||||
|
||||
// FileMeta 文件元信息(大小等)。
|
||||
type FileMeta struct {
|
||||
Size int64 // 字节数
|
||||
ContentType string // MIME 类型,可为空
|
||||
AcceptRanges bool // 是否支持 Range 请求
|
||||
}
|
||||
|
||||
// Download 流式下载句柄。调用方负责 Close。
|
||||
type Download struct {
|
||||
// ReadCloser 文件内容流(已按 Range 重定位)。
|
||||
io.ReadCloser
|
||||
// Meta 文件元信息。
|
||||
Meta FileMeta
|
||||
// Start 当前流的起始字节偏移(Range 请求时为 rangeStart)。
|
||||
Start int64
|
||||
// End 流的结束字节偏移(含);未知为 -1。
|
||||
End int64
|
||||
// Total 文件总大小(字节);未知为 -1。
|
||||
Total int64
|
||||
}
|
||||
|
||||
// Range 字节范围(对齐 HTTP Range 语义)。
|
||||
// nil 指针表示完整文件。
|
||||
type Range struct {
|
||||
Start int64 // 起始字节(含)
|
||||
End int64 // 结束字节(含);-1 表示到文件末尾
|
||||
}
|
||||
|
||||
// Storage 存储引擎统一接口。
|
||||
//
|
||||
// 约定:
|
||||
// - savePath 为存储侧相对路径(引擎内部负责安全解析,拒绝 .. 穿越);
|
||||
// - 所有方法必须是并发安全的;
|
||||
// - 实现方遇到不可恢复错误时返回本包哨兵错误(或用 %w 包装)。
|
||||
type Storage interface {
|
||||
// SaveFile 流式保存文件:r 读取到 EOF 即完成,返回实际写入字节数。
|
||||
// 引擎必须按 256KB 级别分块读取,不得将整个文件读入内存。
|
||||
SaveFile(ctx context.Context, r io.Reader, savePath string) (int64, error)
|
||||
|
||||
// DeleteFile 删除文件;文件不存在时返回 ErrNotFound 或 nil 均可接受。
|
||||
DeleteFile(ctx context.Context, savePath string) error
|
||||
|
||||
// Open 以下载模式打开文件,支持 HTTP Range 请求语义:
|
||||
// - rng 为 nil:返回完整文件流(Start=0,End=Total-1);
|
||||
// - rng 非 nil:返回 [Start, End] 区间流。
|
||||
// 引擎应尽量透传 Range(WebDAV/S3)或按块 seek(local)。
|
||||
Open(ctx context.Context, savePath string, rng *Range) (*Download, error)
|
||||
|
||||
// Stat 获取文件元信息;不存在返回 ErrNotFound。
|
||||
Stat(ctx context.Context, savePath string) (*FileMeta, error)
|
||||
|
||||
// SaveChunk 保存一个分片到临时区(upload_id 隔离),返回分片字节数。
|
||||
SaveChunk(ctx context.Context, uploadID string, chunkIndex int, r io.Reader, savePath string) (int64, error)
|
||||
|
||||
// MergeChunks 按索引 0..total-1 有序合并分片并落为正式文件。
|
||||
// verifyHash 为nil 时不校验;否则为分片 SHA256 校验函数(输入索引,输出期望哈希,空串表示跳过)。
|
||||
// 返回 (最终文件大小, 整个文件 SHA256)。
|
||||
MergeChunks(ctx context.Context, uploadID string, total int, verifyHash func(index int) (string, error), savePath string) (int64, string, error)
|
||||
|
||||
// CleanChunks 清理分片临时区;不存在时静默成功。
|
||||
CleanChunks(ctx context.Context, uploadID string, savePath string) error
|
||||
|
||||
// FileExists 检查文件是否存在。
|
||||
FileExists(ctx context.Context, savePath string) (bool, error)
|
||||
|
||||
// HeadMeta 读取对象元信息与头部字节(可选能力,供直传 confirm 校验实际
|
||||
// 大小与内容;不支持时返回 ErrNotSupported)。
|
||||
// meta 允许为 nil(仅取头部);head 为对象前 headBytes 字节(不足时取实际长度)。
|
||||
HeadMeta(ctx context.Context, savePath string, headBytes int64) (*FileMeta, []byte, error)
|
||||
|
||||
// PresignGetURL 生成限时直链(下载);不支持直链的引擎返回 ErrNotSupported。
|
||||
PresignGetURL(ctx context.Context, savePath string, expires int64) (string, error)
|
||||
|
||||
// PresignPutURL 生成限时直传(上传)URL;不支持直传的引擎返回 ErrNotSupported。
|
||||
PresignPutURL(ctx context.Context, savePath string, expires int64) (string, error)
|
||||
|
||||
// HealthCheck 引擎健康检查(启动时与 /health 使用)。
|
||||
HealthCheck(ctx context.Context) error
|
||||
}
|
||||
|
||||
// ErrNotSupported 当前引擎不支持该能力(如本地引擎不支持预签名)。
|
||||
var ErrNotSupported = errors.New("storage: 当前引擎不支持该操作")
|
||||
|
||||
// ChunkPath 返回分片临时路径(约定统一为 <dir>/chunks/<upload_id>/<index>.part)。
|
||||
// 引擎可使用 ChunkDir 拼接自身路径。
|
||||
type PathBuilder interface {
|
||||
// ChunkDir 分片临时目录(相对 savePath 所在目录)。
|
||||
ChunkDir(savePath, uploadID string) string
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// 每次读写使用的缓冲大小:256KB,对齐参考实现 SystemFileStorage.chunk_size。
|
||||
const localChunkSize = 256 * 1024
|
||||
|
||||
// LocalStorage 本地文件系统引擎。
|
||||
//
|
||||
// 相比参考实现(SystemFileStorage)的改进:
|
||||
// - 双重路径防护:清洗相对路径 + 根目录前缀校验 + 符号链接逃逸校验;
|
||||
// - 全部落盘走「临时文件 + fsync + 原子重命名」,断电/中断不产生半截文件;
|
||||
// - 下载使用 io.NewSectionReader 支持任意 Range,无需整文件读入内存。
|
||||
type LocalStorage struct {
|
||||
// root 存储根目录(绝对路径)。
|
||||
root string
|
||||
// rootReal 经符号链接解析后的真实根目录,用于逃逸校验。
|
||||
rootReal string
|
||||
}
|
||||
|
||||
// NewLocalStorage 构造本地引擎。root 为空时使用系统临时目录下的 fileshare_storage。
|
||||
func NewLocalStorage(root string) (*LocalStorage, error) {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
root = filepath.Join(os.TempDir(), "fileshare_storage")
|
||||
}
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage/local: 解析根目录失败: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(abs, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("storage/local: 创建根目录失败: %w", err)
|
||||
}
|
||||
real := abs
|
||||
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
real = resolved
|
||||
}
|
||||
return &LocalStorage{root: abs, rootReal: real}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterEngine("local", func(ctx context.Context) (Storage, error) {
|
||||
return NewLocalStorage(engineOptions.Local.Root)
|
||||
})
|
||||
}
|
||||
|
||||
// withinRoot 判断路径 p 是否位于 root 内(含 root 本身)。
|
||||
func withinRoot(p, root string) bool {
|
||||
p = filepath.Clean(p)
|
||||
root = filepath.Clean(root)
|
||||
if p == root {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(p, root+string(os.PathSeparator))
|
||||
}
|
||||
|
||||
// absPath 将存储侧相对路径解析为根目录内的绝对路径。
|
||||
// 任何路径穿越或符号链接逃逸都会返回 ErrInvalidPath。
|
||||
func (l *LocalStorage) absPath(savePath string) (string, error) {
|
||||
cleaned, ok := SanitizePath(savePath)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%w: %q", ErrInvalidPath, savePath)
|
||||
}
|
||||
full := filepath.Join(l.root, filepath.FromSlash(cleaned))
|
||||
if !withinRoot(full, l.root) {
|
||||
return "", fmt.Errorf("%w: %q", ErrInvalidPath, savePath)
|
||||
}
|
||||
// 符号链接逃逸校验:文件已存在时解析真实路径;不存在时校验最深已存在的父目录。
|
||||
if real, err := filepath.EvalSymlinks(full); err == nil {
|
||||
if !withinRoot(real, l.rootReal) {
|
||||
return "", fmt.Errorf("%w: 符号链接逃逸 %q", ErrInvalidPath, savePath)
|
||||
}
|
||||
} else {
|
||||
dir := filepath.Dir(full)
|
||||
if realDir, err := filepath.EvalSymlinks(dir); err == nil && !withinRoot(realDir, l.rootReal) {
|
||||
return "", fmt.Errorf("%w: 符号链接逃逸 %q", ErrInvalidPath, savePath)
|
||||
}
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// SaveFile 流式保存:256KB 分块读取写入临时文件,fsync 后原子重命名。
|
||||
func (l *LocalStorage) SaveFile(ctx context.Context, r io.Reader, savePath string) (int64, error) {
|
||||
full, err := l.absPath(savePath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
src := &countingReader{r: r}
|
||||
if err := writeFileAtomic(full, src); err != nil {
|
||||
return src.count(), err
|
||||
}
|
||||
return src.count(), nil
|
||||
}
|
||||
|
||||
// DeleteFile 删除文件;文件不存在时静默成功(对齐契约)。
|
||||
func (l *LocalStorage) DeleteFile(ctx context.Context, savePath string) error {
|
||||
full, err := l.absPath(savePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(full); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("storage/local: 删除失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Open 打开文件下载流;rng 非 nil 时用 SectionReader 实现 Range 语义。
|
||||
func (l *LocalStorage) Open(ctx context.Context, savePath string, rng *Range) (*Download, error) {
|
||||
full, err := l.absPath(savePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.Open(full)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("storage/local: 打开文件失败: %w", err)
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("storage/local: 获取文件信息失败: %w", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
_ = f.Close()
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
size := info.Size()
|
||||
start, end := int64(0), size-1
|
||||
if rng != nil {
|
||||
if rng.Start < 0 || (rng.End != -1 && rng.End < rng.Start) {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("%w: 非法 Range", ErrRangeNotSatisfiable)
|
||||
}
|
||||
if rng.Start >= size {
|
||||
_ = f.Close()
|
||||
return nil, ErrRangeNotSatisfiable
|
||||
}
|
||||
start = rng.Start
|
||||
end = size - 1
|
||||
if rng.End != -1 && rng.End < end {
|
||||
end = rng.End
|
||||
}
|
||||
}
|
||||
section := io.NewSectionReader(f, start, end-start+1)
|
||||
dl := &Download{
|
||||
ReadCloser: &fileSection{Reader: section, closer: f},
|
||||
Start: start,
|
||||
End: end,
|
||||
Total: size,
|
||||
Meta: FileMeta{
|
||||
Size: size,
|
||||
ContentType: mime.TypeByExtension(strings.ToLower(filepath.Ext(full))),
|
||||
AcceptRanges: true,
|
||||
},
|
||||
}
|
||||
if end < 0 { // 空文件:End 语义上等于 -1(未知),Total=0 已表达大小
|
||||
dl.End = -1
|
||||
}
|
||||
return dl, nil
|
||||
}
|
||||
|
||||
// fileSection 组合 SectionReader 与文件关闭器。
|
||||
type fileSection struct {
|
||||
io.Reader
|
||||
closer io.Closer
|
||||
}
|
||||
|
||||
func (f *fileSection) Close() error { return f.closer.Close() }
|
||||
|
||||
// Stat 获取文件元信息。
|
||||
func (l *LocalStorage) Stat(ctx context.Context, savePath string) (*FileMeta, error) {
|
||||
full, err := l.absPath(savePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Stat(full)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("storage/local: Stat 失败: %w", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &FileMeta{
|
||||
Size: info.Size(),
|
||||
ContentType: mime.TypeByExtension(strings.ToLower(filepath.Ext(full))),
|
||||
AcceptRanges: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveChunk 保存分片到 <父目录>/chunks/<uploadID>/<index>.part,原子写入。
|
||||
func (l *LocalStorage) SaveChunk(ctx context.Context, uploadID string, chunkIndex int, r io.Reader, savePath string) (int64, error) {
|
||||
// 先校验目标路径合法性,分片目录随合法路径派生。
|
||||
if _, err := l.absPath(savePath); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
chunkRel, ok := SanitizePath(ChunkPartPath(savePath, uploadID, chunkIndex))
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("%w: 分片路径非法 %q", ErrInvalidPath, savePath)
|
||||
}
|
||||
full := filepath.Join(l.root, filepath.FromSlash(chunkRel))
|
||||
if !withinRoot(full, l.root) {
|
||||
return 0, fmt.Errorf("%w: 分片路径非法 %q", ErrInvalidPath, chunkRel)
|
||||
}
|
||||
src := &countingReader{r: r}
|
||||
if err := writeFileAtomic(full, src); err != nil {
|
||||
return src.count(), err
|
||||
}
|
||||
return src.count(), nil
|
||||
}
|
||||
|
||||
// MergeChunks 按索引 0..total-1 有序合并分片:
|
||||
// - 逐分片流式拷贝到临时输出(边拷贝边计算整文件与分片 SHA256);
|
||||
// - verifyHash 非 nil 时校验分片哈希(空串跳过);
|
||||
// - 全部通过后 fsync + 原子重命名,并清理分片临时目录。
|
||||
func (l *LocalStorage) MergeChunks(ctx context.Context, uploadID string, total int, verifyHash func(index int) (string, error), savePath string) (int64, string, error) {
|
||||
if total <= 0 {
|
||||
return 0, "", fmt.Errorf("storage/local: 非法分片总数 %d", total)
|
||||
}
|
||||
full, err := l.absPath(savePath)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
return 0, "", fmt.Errorf("storage/local: 创建目标目录失败: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(full), "."+filepath.Base(full)+".merging-*")
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("storage/local: 创建临时文件失败: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName) // 成功时已被重命名,删除静默失败
|
||||
}()
|
||||
|
||||
totalHash := sha256.New()
|
||||
buf := make([]byte, localChunkSize)
|
||||
var size int64
|
||||
for i := 0; i < total; i++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
partRel, ok := SanitizePath(ChunkPartPath(savePath, uploadID, i))
|
||||
if !ok {
|
||||
return 0, "", fmt.Errorf("%w: 分片 %d 路径非法", ErrInvalidPath, i)
|
||||
}
|
||||
partPath := filepath.Join(l.root, filepath.FromSlash(partRel))
|
||||
in, err := os.Open(partPath)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("storage/local: 分片 %d 不存在: %w", i, err)
|
||||
}
|
||||
chunkHash := sha256.New()
|
||||
n, err := io.CopyBuffer(io.MultiWriter(tmp, totalHash, chunkHash), in, buf)
|
||||
_ = in.Close()
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("storage/local: 读取分片 %d 失败: %w", i, err)
|
||||
}
|
||||
if verifyHash != nil {
|
||||
expected, err := verifyHash(i)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
if expected != "" && expected != hex.EncodeToString(chunkHash.Sum(nil)) {
|
||||
return 0, "", fmt.Errorf("%w: 分片 %d 期望 %s", ErrHashMismatch, i, expected)
|
||||
}
|
||||
}
|
||||
size += n
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return 0, "", fmt.Errorf("storage/local: 落盘失败: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return 0, "", fmt.Errorf("storage/local: 关闭临时文件失败: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, full); err != nil {
|
||||
return 0, "", fmt.Errorf("storage/local: 原子重命名失败: %w", err)
|
||||
}
|
||||
// 合并成功后清理分片临时目录(静默容错,不掩盖成功结果)。
|
||||
_ = l.CleanChunks(ctx, uploadID, savePath)
|
||||
return size, hex.EncodeToString(totalHash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// CleanChunks 清理分片临时目录;不存在时静默成功,并尝试移除空 chunks 父目录。
|
||||
func (l *LocalStorage) CleanChunks(ctx context.Context, uploadID string, savePath string) error {
|
||||
dirRel, ok := SanitizePath(chunkDirOf(savePath, uploadID))
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: 分片目录非法 %q", ErrInvalidPath, savePath)
|
||||
}
|
||||
dir := filepath.Join(l.root, filepath.FromSlash(dirRel))
|
||||
if !withinRoot(dir, l.root) {
|
||||
return fmt.Errorf("%w: 分片目录非法 %q", ErrInvalidPath, dirRel)
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return fmt.Errorf("storage/local: 清理分片目录失败: %w", err)
|
||||
}
|
||||
// 父级 chunks 目录为空则一并清理(对齐参考实现)。
|
||||
chunksParent := filepath.Dir(dir)
|
||||
if entries, err := os.ReadDir(chunksParent); err == nil && len(entries) == 0 {
|
||||
_ = os.Remove(chunksParent)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// chunkDirOf 返回分片目录(去掉文件名部分):<父目录>/chunks/<uploadID>。
|
||||
func chunkDirOf(savePath, uploadID string) string {
|
||||
cd := ChunkDir(savePath, uploadID)
|
||||
// ChunkDir 返回 "<dir>/chunks/<uploadID>/<name>",去掉末段文件名即目录。
|
||||
if idx := strings.LastIndex(cd, "/"); idx > 0 {
|
||||
return cd[:idx]
|
||||
}
|
||||
return cd
|
||||
}
|
||||
|
||||
// FileExists 检查文件是否存在;非法路径按不存在处理(对齐参考实现)。
|
||||
func (l *LocalStorage) FileExists(ctx context.Context, savePath string) (bool, error) {
|
||||
full, err := l.absPath(savePath)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
info, err := os.Stat(full)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("storage/local: Stat 失败: %w", err)
|
||||
}
|
||||
return !info.IsDir(), nil
|
||||
}
|
||||
|
||||
// HeadMeta 读取文件元信息与前 n 字节(本地引擎实现)。
|
||||
func (l *LocalStorage) HeadMeta(ctx context.Context, savePath string, headBytes int64) (*FileMeta, []byte, error) {
|
||||
full, err := l.absPath(savePath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
f, err := os.Open(full)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil, ErrNotFound
|
||||
}
|
||||
return nil, nil, fmt.Errorf("storage/local: 打开文件失败: %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("storage/local: Stat 失败: %w", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, nil, ErrNotFound
|
||||
}
|
||||
head := make([]byte, headBytes)
|
||||
n, _ := io.ReadFull(f, head)
|
||||
return &FileMeta{
|
||||
Size: info.Size(),
|
||||
ContentType: mime.TypeByExtension(strings.ToLower(filepath.Ext(full))),
|
||||
AcceptRanges: true,
|
||||
}, head[:n], nil
|
||||
}
|
||||
|
||||
// PresignGetURL 本地引擎不支持直链。
|
||||
func (l *LocalStorage) PresignGetURL(ctx context.Context, savePath string, expires int64) (string, error) {
|
||||
return "", ErrNotSupported
|
||||
}
|
||||
|
||||
// PresignPutURL 本地引擎不支持直传。
|
||||
func (l *LocalStorage) PresignPutURL(ctx context.Context, savePath string, expires int64) (string, error) {
|
||||
return "", ErrNotSupported
|
||||
}
|
||||
|
||||
// HealthCheck 健康检查:根目录可写(写入并删除探针文件)。
|
||||
func (l *LocalStorage) HealthCheck(ctx context.Context) error {
|
||||
if err := os.MkdirAll(l.root, 0o755); err != nil {
|
||||
return fmt.Errorf("%w: 本地存储根目录不可创建: %v", ErrUnavailable, err)
|
||||
}
|
||||
probe := filepath.Join(l.root, ".health-probe")
|
||||
if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil {
|
||||
return fmt.Errorf("%w: 本地存储不可写: %v", ErrUnavailable, err)
|
||||
}
|
||||
_ = os.Remove(probe)
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeFileAtomic 临时文件 + fsync + rename 的原子落盘。
|
||||
func writeFileAtomic(dst string, src io.Reader) error {
|
||||
dir := filepath.Dir(dst)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("storage/local: 创建目录失败: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "."+filepath.Base(dst)+".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage/local: 创建临时文件失败: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
cleanup := func() { _ = tmp.Close(); _ = os.Remove(tmpName) }
|
||||
if _, err := io.CopyBuffer(tmp, src, make([]byte, localChunkSize)); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("storage/local: 写入失败: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("storage/local: fsync 失败: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("storage/local: 关闭临时文件失败: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, dst); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("storage/local: 原子重命名失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// countingReader 统计累计读取字节数(并发安全)。
|
||||
type countingReader struct {
|
||||
r io.Reader
|
||||
n atomic.Int64
|
||||
}
|
||||
|
||||
func (c *countingReader) Read(p []byte) (int, error) {
|
||||
n, err := c.r.Read(p)
|
||||
c.n.Add(int64(n))
|
||||
return n, err
|
||||
}
|
||||
|
||||
// count 返回累计字节数。
|
||||
func (c *countingReader) count() int64 { return c.n.Load() }
|
||||
|
||||
// reset 归零计数(请求体重放时使用)。
|
||||
func (c *countingReader) reset() { c.n.Store(0) }
|
||||
|
||||
// 接口编译期断言。
|
||||
var _ Storage = (*LocalStorage)(nil)
|
||||
@@ -0,0 +1,346 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newTestLocal 构造以临时目录为根的本地引擎。
|
||||
func newTestLocal(t *testing.T) *LocalStorage {
|
||||
t.Helper()
|
||||
st, err := NewLocalStorage(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalStorage: %v", err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func sha256Hex(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// TestLocalSaveOpenRange 保存/Stat/完整与 Range 下载。
|
||||
func TestLocalSaveOpenRange(t *testing.T) {
|
||||
st := newTestLocal(t)
|
||||
ctx := context.Background()
|
||||
data := []byte("hello fileshare 本地引擎 0123456789")
|
||||
|
||||
n, err := st.SaveFile(ctx, bytes.NewReader(data), "2025/08/测试文件.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveFile: %v", err)
|
||||
}
|
||||
if n != int64(len(data)) {
|
||||
t.Fatalf("SaveFile n = %d, want %d", n, len(data))
|
||||
}
|
||||
|
||||
// Stat
|
||||
meta, err := st.Stat(ctx, "2025/08/测试文件.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("Stat: %v", err)
|
||||
}
|
||||
if meta.Size != int64(len(data)) || !meta.AcceptRanges {
|
||||
t.Fatalf("Stat = %+v", meta)
|
||||
}
|
||||
|
||||
// 完整下载:对齐 go-api 约定 Start=0、End=Total-1
|
||||
dl, err := st.Open(ctx, "2025/08/测试文件.bin", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
got, err := io.ReadAll(dl)
|
||||
_ = dl.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("read full: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Fatalf("full content mismatch")
|
||||
}
|
||||
if dl.Start != 0 || dl.End != int64(len(data))-1 || dl.Total != int64(len(data)) {
|
||||
t.Fatalf("full download offsets = %d,%d,%d", dl.Start, dl.End, dl.Total)
|
||||
}
|
||||
|
||||
// Range 下载 [2, 7]
|
||||
dl, err = st.Open(ctx, "2025/08/测试文件.bin", &Range{Start: 2, End: 7})
|
||||
if err != nil {
|
||||
t.Fatalf("Open range: %v", err)
|
||||
}
|
||||
got, err = io.ReadAll(dl)
|
||||
_ = dl.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("read range: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, data[2:8]) {
|
||||
t.Fatalf("range content = %q, want %q", got, data[2:8])
|
||||
}
|
||||
|
||||
// Range end 越界自动钳制
|
||||
dl, err = st.Open(ctx, "2025/08/测试文件.bin", &Range{Start: 5, End: 99999})
|
||||
if err != nil {
|
||||
t.Fatalf("Open clamp range: %v", err)
|
||||
}
|
||||
got, _ = io.ReadAll(dl)
|
||||
_ = dl.Close()
|
||||
if !bytes.Equal(got, data[5:]) {
|
||||
t.Fatalf("clamp range mismatch")
|
||||
}
|
||||
|
||||
// 起点越界 → 416
|
||||
if _, err := st.Open(ctx, "2025/08/测试文件.bin", &Range{Start: int64(len(data)) + 1, End: -1}); err == nil {
|
||||
t.Fatalf("out-of-range start should fail")
|
||||
} else if !strings.Contains(err.Error(), ErrRangeNotSatisfiable.Error()) {
|
||||
t.Fatalf("want ErrRangeNotSatisfiable, got %v", err)
|
||||
}
|
||||
|
||||
// 不存在 → 404
|
||||
if _, err := st.Open(ctx, "no/such/file.bin", nil); err == nil || !strings.Contains(err.Error(), ErrNotFound.Error()) {
|
||||
t.Fatalf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalSaveFileAtomic 原子写:目录自动创建,无临时残留。
|
||||
func TestLocalSaveFileAtomic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
st2, _ := NewLocalStorage(dir)
|
||||
|
||||
big := bytes.Repeat([]byte("abc123"), 128*1024) // 768KB,跨多个 256KB 缓冲
|
||||
if _, err := st2.SaveFile(ctx, bytes.NewReader(big), "a/b/c/big.bin"); err != nil {
|
||||
t.Fatalf("SaveFile: %v", err)
|
||||
}
|
||||
// 无临时残留
|
||||
entries, _ := os.ReadDir(filepath.Join(dir, "a", "b", "c"))
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), ".") {
|
||||
t.Fatalf("临时文件残留: %s", e.Name())
|
||||
}
|
||||
}
|
||||
got, _ := os.ReadFile(filepath.Join(dir, "a", "b", "c", "big.bin"))
|
||||
if !bytes.Equal(got, big) {
|
||||
t.Fatalf("content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalTraversal 防路径穿越(含符号链接逃逸)。
|
||||
func TestLocalTraversal(t *testing.T) {
|
||||
st := newTestLocal(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, p := range []string{"../escape.txt", "a/../../escape", "..", "/../x"} {
|
||||
if _, err := st.SaveFile(ctx, strings.NewReader("x"), p); err == nil ||
|
||||
!strings.Contains(err.Error(), ErrInvalidPath.Error()) {
|
||||
t.Fatalf("SaveFile(%q) 应拒绝: %v", p, err)
|
||||
}
|
||||
if _, err := st.Open(ctx, p, nil); err == nil || !strings.Contains(err.Error(), ErrInvalidPath.Error()) {
|
||||
t.Fatalf("Open(%q) 应拒绝: %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 符号链接逃逸
|
||||
outside := filepath.Join(t.TempDir(), "outside.txt")
|
||||
if err := os.WriteFile(outside, []byte("secret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(st.root, "link.txt")
|
||||
if err := os.Symlink(outside, link); err != nil {
|
||||
t.Skipf("symlink 不可用: %v", err)
|
||||
}
|
||||
if _, err := st.Open(ctx, "link.txt", nil); err == nil ||
|
||||
!strings.Contains(err.Error(), ErrInvalidPath.Error()) {
|
||||
t.Fatalf("symlink escape 应拒绝: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalChunkLifecycle 分片保存/合并(索引有序 + SHA256 校验 + 清理临时目录)。
|
||||
func TestLocalChunkLifecycle(t *testing.T) {
|
||||
st := newTestLocal(t)
|
||||
ctx := context.Background()
|
||||
savePath := "2025/09/chunked.bin"
|
||||
uploadID := "upload-abc"
|
||||
|
||||
chunks := [][]byte{[]byte("AAAA"), []byte("BB"), []byte("CCCCCC")}
|
||||
hashes := make([]string, len(chunks))
|
||||
var total int64
|
||||
for i, c := range chunks {
|
||||
n, err := st.SaveChunk(ctx, uploadID, i, bytes.NewReader(c), savePath)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveChunk %d: %v", i, err)
|
||||
}
|
||||
if n != int64(len(c)) {
|
||||
t.Fatalf("SaveChunk %d n = %d", i, n)
|
||||
}
|
||||
hashes[i] = sha256Hex(c)
|
||||
total += int64(len(c))
|
||||
}
|
||||
|
||||
// 分片文件确实存在于临时目录
|
||||
for i := range chunks {
|
||||
exists, err := st.FileExists(ctx, ChunkPartPath(savePath, uploadID, i))
|
||||
if err != nil || !exists {
|
||||
t.Fatalf("分片 %d 应存在: %v %v", i, exists, err)
|
||||
}
|
||||
}
|
||||
|
||||
size, fileHash, err := st.MergeChunks(ctx, uploadID, len(chunks), func(i int) (string, error) {
|
||||
return hashes[i], nil
|
||||
}, savePath)
|
||||
if err != nil {
|
||||
t.Fatalf("MergeChunks: %v", err)
|
||||
}
|
||||
if size != total {
|
||||
t.Fatalf("merged size = %d, want %d", size, total)
|
||||
}
|
||||
want := sha256Hex(bytes.Join(chunks, nil))
|
||||
if fileHash != want {
|
||||
t.Fatalf("file hash = %s, want %s", fileHash, want)
|
||||
}
|
||||
// 合并内容 = 按索引有序拼接
|
||||
got, err := os.ReadFile(filepath.Join(st.root, filepath.FromSlash(savePath)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, bytes.Join(chunks, nil)) {
|
||||
t.Fatalf("merged content mismatch")
|
||||
}
|
||||
// 合并后分片目录已清理
|
||||
exists, _ := st.FileExists(ctx, ChunkPartPath(savePath, uploadID, 0))
|
||||
if exists {
|
||||
t.Fatalf("合并后分片应已清理")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(st.root, "2025/09/chunks")); !os.IsNotExist(err) {
|
||||
t.Fatalf("chunks 父目录应已清理: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalChunkHashMismatch 哈希不匹配 → 合并失败且不留输出文件。
|
||||
func TestLocalChunkHashMismatch(t *testing.T) {
|
||||
st := newTestLocal(t)
|
||||
ctx := context.Background()
|
||||
savePath := "mismatch.bin"
|
||||
if _, err := st.SaveChunk(ctx, "uid", 0, strings.NewReader("data"), savePath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, _, err := st.MergeChunks(ctx, "uid", 1, func(i int) (string, error) {
|
||||
return sha256Hex([]byte("WRONG")), nil
|
||||
}, savePath)
|
||||
if err == nil || !strings.Contains(err.Error(), ErrHashMismatch.Error()) {
|
||||
t.Fatalf("want ErrHashMismatch, got %v", err)
|
||||
}
|
||||
exists, _ := st.FileExists(ctx, savePath)
|
||||
if exists {
|
||||
t.Fatalf("校验失败不应产出正式文件")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalVerifyHashAbort verifyHash 返回 error → 合并中止。
|
||||
func TestLocalVerifyHashAbort(t *testing.T) {
|
||||
st := newTestLocal(t)
|
||||
ctx := context.Background()
|
||||
if _, err := st.SaveChunk(ctx, "uid2", 0, strings.NewReader("data"), "v.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
boom := context.Canceled
|
||||
if _, _, err := st.MergeChunks(ctx, "uid2", 1, func(i int) (string, error) {
|
||||
return "", boom
|
||||
}, "v.bin"); err == nil {
|
||||
t.Fatalf("verifyHash 错误应向上传播")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalDeleteExistsClean 删除/存在性/清理。
|
||||
func TestLocalDeleteExistsClean(t *testing.T) {
|
||||
st := newTestLocal(t)
|
||||
ctx := context.Background()
|
||||
if _, err := st.SaveFile(ctx, strings.NewReader("x"), "d/f.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, _ := st.FileExists(ctx, "d/f.bin"); !ok {
|
||||
t.Fatalf("文件应存在")
|
||||
}
|
||||
if err := st.DeleteFile(ctx, "d/f.bin"); err != nil {
|
||||
t.Fatalf("DeleteFile: %v", err)
|
||||
}
|
||||
if ok, _ := st.FileExists(ctx, "d/f.bin"); ok {
|
||||
t.Fatalf("文件应已删除")
|
||||
}
|
||||
// 删除不存在 → nil
|
||||
if err := st.DeleteFile(ctx, "d/f.bin"); err != nil {
|
||||
t.Fatalf("删除不存在应静默: %v", err)
|
||||
}
|
||||
// CleanChunks 幂等
|
||||
if err := st.CleanChunks(ctx, "uid-x", "y.bin"); err != nil {
|
||||
t.Fatalf("CleanChunks: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalPresignNotSupported 预签名 → ErrNotSupported。
|
||||
func TestLocalPresignNotSupported(t *testing.T) {
|
||||
st := newTestLocal(t)
|
||||
ctx := context.Background()
|
||||
if _, err := st.PresignGetURL(ctx, "a.bin", 60); err == nil || !strings.Contains(err.Error(), ErrNotSupported.Error()) {
|
||||
t.Fatalf("PresignGetURL want ErrNotSupported, got %v", err)
|
||||
}
|
||||
if _, err := st.PresignPutURL(ctx, "a.bin", 60); err == nil || !strings.Contains(err.Error(), ErrNotSupported.Error()) {
|
||||
t.Fatalf("PresignPutURL want ErrNotSupported, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalHealthCheck 健康检查。
|
||||
func TestLocalHealthCheck(t *testing.T) {
|
||||
st := newTestLocal(t)
|
||||
if err := st.HealthCheck(context.Background()); err != nil {
|
||||
t.Fatalf("HealthCheck: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalConcurrent 并发安全冒烟。
|
||||
func TestLocalConcurrent(t *testing.T) {
|
||||
st := newTestLocal(t)
|
||||
ctx := context.Background()
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 16; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
p := "conc/" + itoa(i) + ".bin"
|
||||
if _, err := st.SaveFile(ctx, strings.NewReader(strings.Repeat("x", i+1)), p); err != nil {
|
||||
t.Errorf("save %d: %v", i, err)
|
||||
return
|
||||
}
|
||||
dl, err := st.Open(ctx, p, nil)
|
||||
if err != nil {
|
||||
t.Errorf("open %d: %v", i, err)
|
||||
return
|
||||
}
|
||||
_, _ = io.ReadAll(dl)
|
||||
_ = dl.Close()
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestLocalFactoryRegistry 工厂注册与构造。
|
||||
func TestLocalFactoryRegistry(t *testing.T) {
|
||||
prev := engineOptions.Local.Root
|
||||
engineOptions.Local.Root = t.TempDir()
|
||||
defer func() { engineOptions.Local.Root = prev }()
|
||||
st, err := NewEngine(context.Background(), "local")
|
||||
if err != nil {
|
||||
t.Fatalf("NewEngine(local): %v", err)
|
||||
}
|
||||
if err := st.HealthCheck(context.Background()); err != nil {
|
||||
t.Fatalf("HealthCheck: %v", err)
|
||||
}
|
||||
if _, err := NewEngine(context.Background(), "unknown"); err == nil {
|
||||
t.Fatalf("未知引擎应报错")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Manager 存储引擎管理器:实现 Storage 全接口并支持运行时热切换。
|
||||
//
|
||||
// 26.9 需求:管理后台可设置存储类型(local|s3|webdav)与各引擎参数,
|
||||
// 保存后无需重启即生效。设计要点:
|
||||
// - 读写/保存类操作全部委托到"当前引擎"(原子指针,无锁热路径);
|
||||
// - Switch 先构建并健康检查新引擎,成功才替换指针,失败保持原引擎;
|
||||
// - EngineOf 按名字取引擎实例(带缓存),供"按文件归属引擎取回旧文件"使用;
|
||||
// - 管理端修改引擎参数后调用 Invalidate 使对应实例缓存失效,下次构建生效。
|
||||
type Manager struct {
|
||||
// build 构建指定引擎实例(由装配方注入:内部刷新全局 EngineOptions 后走工厂)。
|
||||
build func(name string) (Storage, error)
|
||||
|
||||
mu sync.RWMutex
|
||||
current Storage
|
||||
curName string
|
||||
cache map[string]Storage
|
||||
}
|
||||
|
||||
// validEngines 合法引擎名(与 FCB_STORAGE_ENGINE 枚举一致)。
|
||||
var validEngines = map[string]bool{"local": true, "s3": true, "webdav": true}
|
||||
|
||||
// ValidEngine 校验引擎名是否合法。
|
||||
func ValidEngine(name string) bool { return validEngines[name] }
|
||||
|
||||
// NewManager 创建管理器:current 为启动时已构建的引擎(主装配流已做过健康检查)。
|
||||
// build 注入构建函数(管理端切换/参数变更时使用,内部须串行——Manager 已加锁)。
|
||||
func NewManager(name string, current Storage, build func(name string) (Storage, error)) *Manager {
|
||||
return &Manager{
|
||||
build: build,
|
||||
current: current,
|
||||
curName: name,
|
||||
cache: map[string]Storage{name: current},
|
||||
}
|
||||
}
|
||||
|
||||
// —— Storage 接口委托(全部走当前引擎)——
|
||||
|
||||
// SaveFile 流式保存文件(委托当前引擎)。
|
||||
func (m *Manager) SaveFile(ctx context.Context, r io.Reader, savePath string) (int64, error) {
|
||||
return m.current.SaveFile(ctx, r, savePath)
|
||||
}
|
||||
|
||||
// DeleteFile 删除文件(委托当前引擎)。
|
||||
func (m *Manager) DeleteFile(ctx context.Context, savePath string) error {
|
||||
return m.current.DeleteFile(ctx, savePath)
|
||||
}
|
||||
|
||||
// Open 打开文件流(委托当前引擎;旧文件由 API 层先经 EngineOf 按归属引擎取)。
|
||||
func (m *Manager) Open(ctx context.Context, savePath string, rng *Range) (*Download, error) {
|
||||
return m.current.Open(ctx, savePath, rng)
|
||||
}
|
||||
|
||||
// Stat 文件元信息(委托当前引擎)。
|
||||
func (m *Manager) Stat(ctx context.Context, savePath string) (*FileMeta, error) {
|
||||
return m.current.Stat(ctx, savePath)
|
||||
}
|
||||
|
||||
// SaveChunk 保存分片(委托当前引擎)。
|
||||
func (m *Manager) SaveChunk(ctx context.Context, uploadID string, chunkIndex int, r io.Reader, savePath string) (int64, error) {
|
||||
return m.current.SaveChunk(ctx, uploadID, chunkIndex, r, savePath)
|
||||
}
|
||||
|
||||
// MergeChunks 合并分片(委托当前引擎)。
|
||||
func (m *Manager) MergeChunks(ctx context.Context, uploadID string, total int, verifyHash func(index int) (string, error), savePath string) (int64, string, error) {
|
||||
return m.current.MergeChunks(ctx, uploadID, total, verifyHash, savePath)
|
||||
}
|
||||
|
||||
// CleanChunks 清理分片临时区(委托当前引擎)。
|
||||
func (m *Manager) CleanChunks(ctx context.Context, uploadID string, savePath string) error {
|
||||
return m.current.CleanChunks(ctx, uploadID, savePath)
|
||||
}
|
||||
|
||||
// FileExists 文件存在性(委托当前引擎)。
|
||||
func (m *Manager) FileExists(ctx context.Context, savePath string) (bool, error) {
|
||||
return m.current.FileExists(ctx, savePath)
|
||||
}
|
||||
|
||||
// HeadMeta 元信息与头部字节(委托当前引擎;供直传 confirm 校验)。
|
||||
func (m *Manager) HeadMeta(ctx context.Context, savePath string, headBytes int64) (*FileMeta, []byte, error) {
|
||||
return m.current.HeadMeta(ctx, savePath, headBytes)
|
||||
}
|
||||
|
||||
// PresignGetURL 限时直链下载(委托当前引擎)。
|
||||
func (m *Manager) PresignGetURL(ctx context.Context, savePath string, expires int64) (string, error) {
|
||||
return m.current.PresignGetURL(ctx, savePath, expires)
|
||||
}
|
||||
|
||||
// PresignPutURL 限时直传(委托当前引擎)。
|
||||
func (m *Manager) PresignPutURL(ctx context.Context, savePath string, expires int64) (string, error) {
|
||||
return m.current.PresignPutURL(ctx, savePath, expires)
|
||||
}
|
||||
|
||||
// HealthCheck 健康检查(委托当前引擎)。
|
||||
func (m *Manager) HealthCheck(ctx context.Context) error {
|
||||
return m.current.HealthCheck(ctx)
|
||||
}
|
||||
|
||||
// —— 管理面:当前引擎名 / 按名取实例 / 热切换 / 缓存失效 ——
|
||||
|
||||
// CurrentName 当前引擎名(管理端展示与文件归属戳用;并发安全)。
|
||||
func (m *Manager) CurrentName() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.curName
|
||||
}
|
||||
|
||||
// Current 当前引擎实例。
|
||||
func (m *Manager) Current() Storage {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.current
|
||||
}
|
||||
|
||||
// EngineOf 按名字取引擎实例(带缓存;用于按文件归属引擎取回旧文件)。
|
||||
// 实例不存在时现场构建(不健康检查——读旧文件尽力而为,构建失败即报错)。
|
||||
func (m *Manager) EngineOf(name string) (Storage, error) {
|
||||
if !validEngines[name] {
|
||||
return nil, fmt.Errorf("storage: 未知存储引擎 %q(仅支持 local|s3|webdav)", name)
|
||||
}
|
||||
m.mu.RLock()
|
||||
if s, ok := m.cache[name]; ok {
|
||||
m.mu.RUnlock()
|
||||
return s, nil
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
// 双检:拿写锁期间可能已被并发构建
|
||||
if s, ok := m.cache[name]; ok {
|
||||
return s, nil
|
||||
}
|
||||
s, err := m.build(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.cache[name] = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Switch 热切换当前引擎:构建新实例 → 健康检查 → 成功才替换指针。
|
||||
// 任一步失败返回错误且当前引擎保持不变(管理端 503 上报)。
|
||||
func (m *Manager) Switch(name string) (Storage, error) {
|
||||
if !validEngines[name] {
|
||||
return nil, fmt.Errorf("storage: 未知存储引擎 %q(仅支持 local|s3|webdav)", name)
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.curName == name {
|
||||
return m.current, nil
|
||||
}
|
||||
s, ok := m.cache[name]
|
||||
if !ok {
|
||||
var err error
|
||||
s, err = m.build(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: 构建 %s 引擎失败: %w", name, err)
|
||||
}
|
||||
}
|
||||
if err := s.HealthCheck(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("storage: %s 引擎健康检查未通过: %w", name, err)
|
||||
}
|
||||
m.current = s
|
||||
m.curName = name
|
||||
m.cache[name] = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Invalidate 引擎参数变更后使对应实例缓存失效(下次 EngineOf/Switch 重建生效)。
|
||||
// 当前引擎不受影响(运行中实例继续服务,直到显式 Switch)。
|
||||
func (m *Manager) Invalidate(name string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if name == m.curName {
|
||||
return // 当前引擎实例仍被热路径使用,不重建;参数生效由下一次 Switch 完成
|
||||
}
|
||||
delete(m.cache, name)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeEngine 可配置健康检查结果的桩引擎。
|
||||
type fakeEngine struct{ failHealth bool }
|
||||
|
||||
func (f *fakeEngine) SaveFile(ctx context.Context, r io.Reader, savePath string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (f *fakeEngine) DeleteFile(ctx context.Context, savePath string) error { return nil }
|
||||
func (f *fakeEngine) Open(ctx context.Context, savePath string, rng *Range) (*Download, error) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
func (f *fakeEngine) Stat(ctx context.Context, savePath string) (*FileMeta, error) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
func (f *fakeEngine) SaveChunk(ctx context.Context, uploadID string, chunkIndex int, r io.Reader, savePath string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (f *fakeEngine) MergeChunks(ctx context.Context, uploadID string, total int, verifyHash func(index int) (string, error), savePath string) (int64, string, error) {
|
||||
return 0, "", nil
|
||||
}
|
||||
func (f *fakeEngine) CleanChunks(ctx context.Context, uploadID string, savePath string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeEngine) FileExists(ctx context.Context, savePath string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (f *fakeEngine) HeadMeta(ctx context.Context, savePath string, headBytes int64) (*FileMeta, []byte, error) {
|
||||
return nil, nil, ErrNotSupported
|
||||
}
|
||||
func (f *fakeEngine) PresignGetURL(ctx context.Context, savePath string, expires int64) (string, error) {
|
||||
return "", ErrNotSupported
|
||||
}
|
||||
func (f *fakeEngine) PresignPutURL(ctx context.Context, savePath string, expires int64) (string, error) {
|
||||
return "", ErrNotSupported
|
||||
}
|
||||
func (f *fakeEngine) HealthCheck(ctx context.Context) error {
|
||||
if f.failHealth {
|
||||
return ErrUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newTestManager 构造测试用 Manager:local 健康引擎起步;s3/webdav 由计数器控制健康。
|
||||
func newTestManager(s3Fail *atomic.Bool) *Manager {
|
||||
build := func(name string) (Storage, error) {
|
||||
switch name {
|
||||
case "local":
|
||||
return &fakeEngine{}, nil
|
||||
case "s3":
|
||||
return &fakeEngine{failHealth: s3Fail.Load()}, nil
|
||||
case "webdav":
|
||||
return &fakeEngine{}, nil
|
||||
}
|
||||
return nil, errors.New("unknown")
|
||||
}
|
||||
return NewManager("local", &fakeEngine{}, build)
|
||||
}
|
||||
|
||||
// TestSwitchSuccessAndCurrentName 切换成功后当前引擎名与实例更新。
|
||||
func TestSwitchSuccessAndCurrentName(t *testing.T) {
|
||||
var s3Fail atomic.Bool
|
||||
m := newTestManager(&s3Fail)
|
||||
if m.CurrentName() != "local" {
|
||||
t.Fatalf("初始引擎应为 local,得到 %s", m.CurrentName())
|
||||
}
|
||||
if _, err := m.Switch("s3"); err != nil {
|
||||
t.Fatalf("Switch(s3) 失败: %v", err)
|
||||
}
|
||||
if m.CurrentName() != "s3" {
|
||||
t.Fatalf("切换后引擎应为 s3,得到 %s", m.CurrentName())
|
||||
}
|
||||
if _, err := m.Switch("webdav"); err != nil {
|
||||
t.Fatalf("Switch(webdav) 失败: %v", err)
|
||||
}
|
||||
if m.CurrentName() != "webdav" {
|
||||
t.Fatalf("切换后引擎应为 webdav,得到 %s", m.CurrentName())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwitchFailureKeepsCurrent 健康检查失败时保持原引擎(26.9 核心语义)。
|
||||
func TestSwitchFailureKeepsCurrent(t *testing.T) {
|
||||
var s3Fail atomic.Bool
|
||||
s3Fail.Store(true) // s3 不健康
|
||||
m := newTestManager(&s3Fail)
|
||||
if _, err := m.Switch("s3"); err == nil {
|
||||
t.Fatal("s3 不健康时 Switch 应失败")
|
||||
}
|
||||
if m.CurrentName() != "local" {
|
||||
t.Fatalf("切换失败后应保持 local,得到 %s", m.CurrentName())
|
||||
}
|
||||
// 恢复健康后可切换成功
|
||||
s3Fail.Store(false)
|
||||
if _, err := m.Switch("s3"); err != nil {
|
||||
t.Fatalf("恢复健康后 Switch(s3) 应成功: %v", err)
|
||||
}
|
||||
if m.CurrentName() != "s3" {
|
||||
t.Fatalf("恢复后引擎应为 s3,得到 %s", m.CurrentName())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwitchInvalidName 非法引擎名拒绝。
|
||||
func TestSwitchInvalidName(t *testing.T) {
|
||||
m := newTestManager(&atomic.Bool{})
|
||||
if _, err := m.Switch("ftp"); err == nil || !strings.Contains(err.Error(), "未知存储引擎") {
|
||||
t.Fatalf("非法引擎名应报未知存储引擎,得到 %v", err)
|
||||
}
|
||||
if !ValidEngine("local") || ValidEngine("ftp") {
|
||||
t.Fatal("ValidEngine 判定错误")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngineOfCacheAndInvalidate EngineOf 缓存命中 + Invalidate 后重建(参数生效路径)。
|
||||
func TestEngineOfCacheAndInvalidate(t *testing.T) {
|
||||
var builds atomic.Int64
|
||||
build := func(name string) (Storage, error) {
|
||||
builds.Add(1)
|
||||
return &fakeEngine{failHealth: false}, nil
|
||||
}
|
||||
m := NewManager("local", &fakeEngine{}, build)
|
||||
|
||||
s1, err := m.EngineOf("s3")
|
||||
if err != nil {
|
||||
t.Fatalf("EngineOf(s3): %v", err)
|
||||
}
|
||||
s2, err := m.EngineOf("s3")
|
||||
if err != nil {
|
||||
t.Fatalf("EngineOf(s3) second: %v", err)
|
||||
}
|
||||
if s1 != s2 {
|
||||
t.Fatal("EngineOf 应命中缓存返回同一实例")
|
||||
}
|
||||
if n := builds.Load(); n != 1 {
|
||||
t.Fatalf("应只构建 1 次,实际 %d", n)
|
||||
}
|
||||
|
||||
// Invalidate 后下次取重建新实例
|
||||
m.Invalidate("s3")
|
||||
s3, err := m.EngineOf("s3")
|
||||
if err != nil {
|
||||
t.Fatalf("EngineOf(s3) after invalidate: %v", err)
|
||||
}
|
||||
if s3 == s1 {
|
||||
t.Fatal("Invalidate 后应返回重建的新实例")
|
||||
}
|
||||
if n := builds.Load(); n != 2 {
|
||||
t.Fatalf("Invalidate 后应再构建 1 次,实际累计 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvalidateCurrentNoop Invalidate 当前引擎不生效(热路径实例保持)。
|
||||
func TestInvalidateCurrentNoop(t *testing.T) {
|
||||
m := newTestManager(&atomic.Bool{})
|
||||
cur := m.Current()
|
||||
m.Invalidate("local") // 当前引擎:应为 no-op
|
||||
if m.Current() != cur {
|
||||
t.Fatal("Invalidate 当前引擎不应替换实例")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwitchSameNameNoop 同名 Switch 幂等。
|
||||
func TestSwitchSameNameNoop(t *testing.T) {
|
||||
m := newTestManager(&atomic.Bool{})
|
||||
s, err := m.Switch("local")
|
||||
if err != nil {
|
||||
t.Fatalf("Switch(local) 同名应成功: %v", err)
|
||||
}
|
||||
if s != m.Current() {
|
||||
t.Fatal("同名 Switch 应返回当前实例")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelegateToCurrent 保存/读取类操作委托当前引擎(切换后指向新引擎)。
|
||||
func TestDelegateToCurrent(t *testing.T) {
|
||||
var s3Fail atomic.Bool
|
||||
m := newTestManager(&s3Fail)
|
||||
ctx := context.Background()
|
||||
// local 引擎 HealthCheck 健康
|
||||
if err := m.HealthCheck(ctx); err != nil {
|
||||
t.Fatalf("委托 HealthCheck(local): %v", err)
|
||||
}
|
||||
if _, err := m.Switch("s3"); err != nil {
|
||||
t.Fatalf("Switch(s3): %v", err)
|
||||
}
|
||||
if err := m.HealthCheck(ctx); err != nil {
|
||||
t.Fatalf("委托 HealthCheck(s3): %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package storage
|
||||
|
||||
// EngineOptions 引擎构造选项:由 main.go(API 层任务)从 config KV 填充。
|
||||
// 各引擎的 RegisterEngine 工厂读取本结构;零值即安全默认。
|
||||
type EngineOptions struct {
|
||||
// Local 本地引擎选项。
|
||||
Local LocalOptions
|
||||
// S3 S3 引擎选项。
|
||||
S3 S3Options
|
||||
// WebDAV WebDAV 引擎选项。
|
||||
WebDAV WebDAVOptions
|
||||
}
|
||||
|
||||
// LocalOptions 本地引擎配置(对齐 local_storage_path)。
|
||||
type LocalOptions struct {
|
||||
// Root 存储根目录;空则使用系统临时目录。
|
||||
Root string
|
||||
}
|
||||
|
||||
// S3Options S3 引擎配置(对齐 s3_* 配置键)。
|
||||
type S3Options struct {
|
||||
AccessKeyID string // s3_access_key_id
|
||||
SecretAccessKey string // s3_secret_access_key
|
||||
SessionToken string // aws_session_token
|
||||
Bucket string // s3_bucket_name
|
||||
Endpoint string // s3_endpoint_url(MinIO 等;空则 AWS 默认端点)
|
||||
Region string // s3_region_name,默认 auto
|
||||
AddressingStyle string // s3_addressing_style: auto|path|virtual
|
||||
}
|
||||
|
||||
// WebDAVOptions WebDAV 引擎配置(对齐 webdav_* 配置键 + 本次优化项)。
|
||||
type WebDAVOptions struct {
|
||||
// BaseURL 服务地址,如 https://dav.example.com/dav/。
|
||||
BaseURL string
|
||||
// Username/Password 凭据(Basic 与 Digest 共用)。
|
||||
Username string
|
||||
Password string
|
||||
// RootPath 远端根目录(webdav_root_path),会自动逐级创建。
|
||||
RootPath string
|
||||
// MaxRetries 5xx/网络错误最大重试次数(指数退避),0 取默认 3。
|
||||
MaxRetries int
|
||||
// BaseBackoff 重试基础退避时长,0 取默认 200ms。
|
||||
BaseBackoff int64
|
||||
// Timeout 单请求超时秒数,0 取默认 30s。
|
||||
Timeout int64
|
||||
// MaxIdleConnsPerHost 连接池每主机最大空闲连接,0 取默认 16(连接复用优化)。
|
||||
MaxIdleConnsPerHost int
|
||||
}
|
||||
|
||||
// engineOptions 全局引擎选项(由 main.go 注入;默认零值)。
|
||||
var engineOptions EngineOptions
|
||||
|
||||
// SetEngineOptions 注入引擎构造选项(在 RegisterEngine 工厂执行前调用)。
|
||||
func SetEngineOptions(opts EngineOptions) { engineOptions = opts }
|
||||
|
||||
// applyDefaults 填充零值默认项。
|
||||
func (o *WebDAVOptions) applyDefaults() {
|
||||
if o.MaxRetries <= 0 {
|
||||
o.MaxRetries = 3
|
||||
}
|
||||
if o.BaseBackoff <= 0 {
|
||||
o.BaseBackoff = 200
|
||||
}
|
||||
if o.Timeout <= 0 {
|
||||
o.Timeout = 30
|
||||
}
|
||||
if o.MaxIdleConnsPerHost <= 0 {
|
||||
o.MaxIdleConnsPerHost = 16
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ChunkDir 实现默认分片目录约定:<父目录>/chunks/<uploadID>。
|
||||
// local/s3/webdav 三引擎共用,保持分片路径一致。
|
||||
func ChunkDir(savePath, uploadID string) string {
|
||||
dir := path.Dir(savePath)
|
||||
name := path.Base(savePath)
|
||||
// 防御:savePath 非法时仍返回明确结构,具体引擎再做安全校验
|
||||
if name == "." || name == "/" {
|
||||
name = "file"
|
||||
}
|
||||
return path.Join(dir, "chunks", uploadID) + "/" + name
|
||||
}
|
||||
|
||||
// ChunkPartPath 分片对象完整路径(相对存储根)。
|
||||
func ChunkPartPath(savePath, uploadID string, index int) string {
|
||||
dir := path.Dir(savePath)
|
||||
return path.Join(dir, "chunks", uploadID, itoa(index)+".part")
|
||||
}
|
||||
|
||||
// SanitizePath 清理相对路径:统一斜杠、去首尾斜杠、拒绝 .. 穿越。
|
||||
// 返回清理后的相对路径与是否合法。
|
||||
func SanitizePath(p string) (string, bool) {
|
||||
raw := strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
|
||||
raw = strings.TrimPrefix(raw, "/")
|
||||
if raw == "" {
|
||||
return "", false
|
||||
}
|
||||
cleaned := path.Clean(raw)
|
||||
if cleaned == ".." || strings.HasPrefix(cleaned, "../") || path.IsAbs(cleaned) {
|
||||
return "", false
|
||||
}
|
||||
// 拒绝任何单独的 .. 段
|
||||
for _, seg := range strings.Split(cleaned, "/") {
|
||||
if seg == ".." {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return cleaned, true
|
||||
}
|
||||
|
||||
// SanitizeFileName 清理文件名:剥离路径、替换非法字符、限制长度。
|
||||
// 对齐参考 core/utils.py 的 sanitize_filename。
|
||||
func SanitizeFileName(name string) string {
|
||||
// 剥离路径
|
||||
if idx := strings.LastIndexAny(name, "/\\"); idx >= 0 {
|
||||
name = name[idx+1:]
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r < 0x20 || r == 0x7f:
|
||||
b.WriteByte('_')
|
||||
case strings.ContainsRune(`\*?:"<>|`, r):
|
||||
b.WriteByte('_')
|
||||
case r == ' ':
|
||||
b.WriteByte('_')
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
cleaned := b.String()
|
||||
// 压缩连续下划线
|
||||
for strings.Contains(cleaned, "__") {
|
||||
cleaned = strings.ReplaceAll(cleaned, "__", "_")
|
||||
}
|
||||
cleaned = strings.Trim(cleaned, "._")
|
||||
if cleaned == "" {
|
||||
return "unnamed_file"
|
||||
}
|
||||
if len(cleaned) > 255 {
|
||||
cleaned = cleaned[:255]
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// itoa 小整数转字符串。
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var buf [21]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSanitizePath 校验路径穿越防护。
|
||||
func TestSanitizePath(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
ok bool
|
||||
out string
|
||||
}{
|
||||
{"2025/08/uuid.zip", true, "2025/08/uuid.zip"},
|
||||
{"/2025/08/uuid.zip", true, "2025/08/uuid.zip"},
|
||||
{"a\\b\\c.txt", true, "a/b/c.txt"},
|
||||
{"../etc/passwd", false, ""},
|
||||
{"a/../../b", false, ""},
|
||||
{"..", false, ""},
|
||||
{"", false, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := SanitizePath(tc.in)
|
||||
if ok != tc.ok || (ok && got != tc.out) {
|
||||
t.Errorf("SanitizePath(%q) = (%q, %v), want (%q, %v)", tc.in, got, ok, tc.out, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeFileName 校验文件名清理。
|
||||
func TestSanitizeFileName(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"hello world.zip", "hello_world.zip"},
|
||||
{"/path/to/file.txt", "file.txt"},
|
||||
{"a<b>:c?.mp4", "a_b_c_.mp4"}, // 连续下划线压缩,对齐参考 re.sub(r"_+", "_")
|
||||
{"", "unnamed_file"},
|
||||
{"__..__", "unnamed_file"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := SanitizeFileName(tc.in); got != tc.want {
|
||||
t.Errorf("SanitizeFileName(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChunkPartPath 校验分片路径约定。
|
||||
func TestChunkPartPath(t *testing.T) {
|
||||
got := ChunkPartPath("2025/08/uuid.zip", "upload-1", 3)
|
||||
want := "2025/08/chunks/upload-1/3.part"
|
||||
if got != want {
|
||||
t.Errorf("ChunkPartPath = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChunkDir 校验分片目录约定。
|
||||
func TestChunkDir(t *testing.T) {
|
||||
got := ChunkDir("2025/08/uuid.zip", "upload-1")
|
||||
want := "2025/08/chunks/upload-1/uuid.zip"
|
||||
if got != want {
|
||||
t.Errorf("ChunkDir = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSHA256Helper 辅助:确认 sha256 用法一致(合并校验依赖)。
|
||||
func TestSHA256Helper(t *testing.T) {
|
||||
h := sha256.Sum256([]byte("abc"))
|
||||
if got := hex.EncodeToString(h[:]); got != "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" {
|
||||
t.Errorf("sha256(abc) = %s", got)
|
||||
}
|
||||
_ = io.EOF
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/aws/smithy-go"
|
||||
)
|
||||
|
||||
// S3Storage 基于 aws-sdk-go-v2 的 S3 兼容对象存储引擎(AWS / MinIO / R2 / OSS 等)。
|
||||
//
|
||||
// 相比参考实现(S3FileStorage,aioboto3)的改进:
|
||||
// - 单例客户端 + 自定义连接池 Transport(参考实现每次操作新建 session);
|
||||
// - SaveFile 走 manager.Uploader 分片并发上传(未知长度也可流式,内存占用 ≤ partSize);
|
||||
// - SaveChunk 落本地临时文件获取精确 Content-Length(参考实现整块读入内存);
|
||||
// - MergeChunks 用 S3 原生 multipart 流式合并,边读边校验哈希(不落盘、不整块进内存);
|
||||
// - 5xx/网络错误由 SDK 内置指数退避重试器处理(可配次数)。
|
||||
type S3Storage struct {
|
||||
client *s3.Client
|
||||
presigner *s3.PresignClient
|
||||
uploader *manager.Uploader
|
||||
bucket string
|
||||
}
|
||||
|
||||
// NewS3Storage 构造 S3 引擎。
|
||||
func NewS3Storage(opts S3Options) (*S3Storage, error) {
|
||||
if strings.TrimSpace(opts.Bucket) == "" {
|
||||
return nil, fmt.Errorf("storage/s3: 缺少 bucket 配置(s3_bucket_name)")
|
||||
}
|
||||
region := strings.TrimSpace(opts.Region)
|
||||
if region == "" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
loadOpts := []func(*config.LoadOptions) error{
|
||||
config.WithRegion(region),
|
||||
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
||||
opts.AccessKeyID, opts.SecretAccessKey, opts.SessionToken,
|
||||
)),
|
||||
// SDK 内置重试器:标准模式,指数退避 + 抖动,覆盖 5xx 与网络错误。
|
||||
config.WithRetryMaxAttempts(3),
|
||||
// 兼容性:仅协议要求时才计算校验和。默认的 trailing CRC32 需要可重放流
|
||||
// 或 TLS,MinIO/R2 等自建端点通常不需要,关闭后 MergeChunks 的
|
||||
// GET→UploadPart 纯流式转发才能工作。
|
||||
config.WithRequestChecksumCalculation(aws.RequestChecksumCalculationWhenRequired),
|
||||
config.WithResponseChecksumValidation(aws.ResponseChecksumValidationWhenRequired),
|
||||
}
|
||||
if ep := strings.TrimSpace(opts.Endpoint); ep != "" {
|
||||
loadOpts = append(loadOpts, config.WithBaseEndpoint(ep))
|
||||
}
|
||||
awsCfg, err := config.LoadDefaultConfig(context.Background(), loadOpts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage/s3: 初始化 SDK 配置失败: %w", err)
|
||||
}
|
||||
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
|
||||
// 寻址风格:path 显式启用;auto 时自定义端点(自建 MinIO 等)默认 path-style。
|
||||
switch strings.ToLower(strings.TrimSpace(opts.AddressingStyle)) {
|
||||
case "path":
|
||||
o.UsePathStyle = true
|
||||
case "virtual":
|
||||
o.UsePathStyle = false
|
||||
default: // auto
|
||||
o.UsePathStyle = strings.TrimSpace(opts.Endpoint) != ""
|
||||
}
|
||||
// 连接复用:自定义 Transport 连接池。
|
||||
o.HTTPClient = newPooledHTTPClient()
|
||||
})
|
||||
st := &S3Storage{
|
||||
client: client,
|
||||
presigner: s3.NewPresignClient(client),
|
||||
bucket: opts.Bucket,
|
||||
}
|
||||
st.uploader = manager.NewUploader(client, func(u *manager.Uploader) {
|
||||
u.PartSize = 5 * 1024 * 1024 // 5MB,S3 multipart 最小分片
|
||||
u.Concurrency = 4
|
||||
u.LeavePartsOnError = false
|
||||
})
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterEngine("s3", func(ctx context.Context) (Storage, error) {
|
||||
return NewS3Storage(engineOptions.S3)
|
||||
})
|
||||
}
|
||||
|
||||
// newPooledHTTPClient 供 SDK 使用的连接池化 HTTP 客户端。
|
||||
func newPooledHTTPClient() *http.Client {
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 16,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
ResponseHeaderTimeout: 60 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// key 校验并规范化对象键(拒绝穿越,统一斜杠)。
|
||||
func (s *S3Storage) key(savePath string) (string, error) {
|
||||
cleaned, ok := SanitizePath(savePath)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%w: %q", ErrInvalidPath, savePath)
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// SaveFile 流式保存:manager.Uploader 按需分片并发上传,内存占用恒定。
|
||||
func (s *S3Storage) SaveFile(ctx context.Context, r io.Reader, savePath string) (int64, error) {
|
||||
key, err := s.key(savePath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
src := &countingReader{r: r}
|
||||
_, err = s.uploader.Upload(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
Body: src,
|
||||
ContentType: aws.String("application/octet-stream"),
|
||||
})
|
||||
if err != nil {
|
||||
return src.count(), mapS3Error(err, "PutObject")
|
||||
}
|
||||
return src.count(), nil
|
||||
}
|
||||
|
||||
// DeleteFile 删除对象;S3 对不存在的键也返回成功。
|
||||
func (s *S3Storage) DeleteFile(ctx context.Context, savePath string) error {
|
||||
key, err := s.key(savePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
return mapS3Error(err, "DeleteObject")
|
||||
}
|
||||
|
||||
// Open 获取下载流:Range 直接透传为 GetObject Range 头(流式,不落盘)。
|
||||
func (s *S3Storage) Open(ctx context.Context, savePath string, rng *Range) (*Download, error) {
|
||||
key, err := s.key(savePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input := &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
}
|
||||
if rng != nil {
|
||||
input.Range = aws.String(rangeHeaderValue(rng))
|
||||
}
|
||||
out, err := s.client.GetObject(ctx, input)
|
||||
if err != nil {
|
||||
return nil, mapS3Error(err, "GetObject")
|
||||
}
|
||||
size := aws.ToInt64(out.ContentLength)
|
||||
start, end := int64(0), size-1
|
||||
if cr := aws.ToString(out.ContentRange); cr != "" { // 服务端按 206 返回了区间
|
||||
if sr, e, total, ok := parseContentRange(cr); ok {
|
||||
start, end = sr, e
|
||||
if total >= 0 {
|
||||
size = total
|
||||
}
|
||||
}
|
||||
}
|
||||
return &Download{
|
||||
ReadCloser: out.Body,
|
||||
Start: start,
|
||||
End: end,
|
||||
Total: size,
|
||||
Meta: FileMeta{
|
||||
Size: size,
|
||||
ContentType: aws.ToString(out.ContentType),
|
||||
AcceptRanges: true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// rangeHeaderValue 将 Range 结构转为 HTTP Range 头值。
|
||||
func rangeHeaderValue(rng *Range) string {
|
||||
if rng.End < 0 {
|
||||
return fmt.Sprintf("bytes=%d-", rng.Start)
|
||||
}
|
||||
return fmt.Sprintf("bytes=%d-%d", rng.Start, rng.End)
|
||||
}
|
||||
|
||||
// parseContentRange 解析 "bytes 0-99/1000"(total 可能为 "*")。
|
||||
func parseContentRange(v string) (start, end, total int64, ok bool) {
|
||||
v = strings.TrimSpace(v)
|
||||
if !strings.HasPrefix(v, "bytes ") {
|
||||
return 0, 0, -1, false
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimPrefix(v, "bytes "), "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, -1, false
|
||||
}
|
||||
total = -1
|
||||
if parts[1] != "*" {
|
||||
t, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, -1, false
|
||||
}
|
||||
total = t
|
||||
}
|
||||
se := strings.SplitN(parts[0], "-", 2)
|
||||
if len(se) != 2 {
|
||||
return 0, 0, -1, false
|
||||
}
|
||||
s0, err1 := strconv.ParseInt(se[0], 10, 64)
|
||||
e0, err2 := strconv.ParseInt(se[1], 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, 0, -1, false
|
||||
}
|
||||
return s0, e0, total, true
|
||||
}
|
||||
|
||||
// Stat 获取对象元信息(HeadObject)。
|
||||
func (s *S3Storage) Stat(ctx context.Context, savePath string) (*FileMeta, error) {
|
||||
key, err := s.key(savePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, mapS3Error(err, "HeadObject")
|
||||
}
|
||||
return &FileMeta{
|
||||
Size: aws.ToInt64(out.ContentLength),
|
||||
ContentType: aws.ToString(out.ContentType),
|
||||
AcceptRanges: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveChunk 保存分片对象:落临时文件获取精确长度后 PutObject(可重试)。
|
||||
func (s *S3Storage) SaveChunk(ctx context.Context, uploadID string, chunkIndex int, r io.Reader, savePath string) (int64, error) {
|
||||
if _, err := s.key(savePath); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
key, err := s.key(ChunkPartPath(savePath, uploadID, chunkIndex))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 落临时文件:获得精确 Content-Length 与可重放 Body(网络失败可安全重试)。
|
||||
tmp, err := os.CreateTemp("", "fcb-s3-chunk-*")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("storage/s3: 创建临时文件失败: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = tmp.Close(); _ = os.Remove(tmpName) }()
|
||||
size, err := io.Copy(tmp, r)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("storage/s3: 缓存分片失败: %w", err)
|
||||
}
|
||||
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, fmt.Errorf("storage/s3: 回卷分片失败: %w", err)
|
||||
}
|
||||
_, err = s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
Body: tmp,
|
||||
ContentLength: aws.Int64(size),
|
||||
ContentType: aws.String("application/octet-stream"),
|
||||
})
|
||||
if err != nil {
|
||||
return size, mapS3Error(err, "PutObject(分片)")
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
// S3 multipart 最小分片限制:除最后一片外每片 ≥5MB,否则 Complete 返回 EntityTooSmall。
|
||||
// 分片上传的分块大小由服务端配置保证(建议 ≥5MB)。
|
||||
const s3MinPartSize = 5 * 1024 * 1024
|
||||
|
||||
// MergeChunks 用 S3 原生 multipart 流式合并:
|
||||
// 逐分片 GET → 边流边算哈希 → UploadPart(带精确 Content-Length)→ Complete。
|
||||
// 任一步失败即 Abort 并返回错误;成功后清理分片对象。
|
||||
func (s *S3Storage) MergeChunks(ctx context.Context, uploadID string, total int, verifyHash func(index int) (string, error), savePath string) (int64, string, error) {
|
||||
if total <= 0 {
|
||||
return 0, "", fmt.Errorf("storage/s3: 非法分片总数 %d", total)
|
||||
}
|
||||
key, err := s.key(savePath)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
chunkPrefix, err := s.key(chunkDirOf(savePath, uploadID))
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
// 单分片快速路径:直接流式 PutObject,绕过 multipart 的 5MB 限制。
|
||||
if total == 1 {
|
||||
return s.mergeSingle(ctx, chunkPrefix+"/0.part", key, verifyHash, 0)
|
||||
}
|
||||
|
||||
mpu, err := s.client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
ContentType: aws.String("application/octet-stream"),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, "", mapS3Error(err, "CreateMultipartUpload")
|
||||
}
|
||||
_ = aws.ToString(mpu.UploadId) // S3 侧 multipart 会话 ID(Abort 时复用 mpu.UploadId)
|
||||
|
||||
size := int64(0)
|
||||
totalHash := sha256.New()
|
||||
parts := make([]types.CompletedPart, 0, total)
|
||||
defer func() {
|
||||
// 出错时取消 multipart(避免残留分片产生存储费用)。
|
||||
if len(parts) < total {
|
||||
_, _ = s.client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
UploadId: mpu.UploadId,
|
||||
})
|
||||
}
|
||||
}()
|
||||
for i := 0; i < total; i++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
var expected string
|
||||
if verifyHash != nil {
|
||||
expected, err = verifyHash(i)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
}
|
||||
getOut, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(fmt.Sprintf("%s/%d.part", chunkPrefix, i)),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, "", mapS3Error(err, fmt.Sprintf("GetObject(分片 %d)", i))
|
||||
}
|
||||
// 分片先流式落临时文件:计算哈希 + 获得可回卷 body(SDK 签名哈希需要 seekable 流,
|
||||
// 同时为 UploadPart 失败重试保留数据)。
|
||||
chunkHash := sha256.New()
|
||||
tmp, err := os.CreateTemp("", "fcb-s3-part-*")
|
||||
if err != nil {
|
||||
_ = getOut.Body.Close()
|
||||
return 0, "", fmt.Errorf("storage/s3: 创建分片临时文件失败: %w", err)
|
||||
}
|
||||
partLen, err := io.Copy(io.MultiWriter(tmp, totalHash, chunkHash), getOut.Body)
|
||||
_ = getOut.Body.Close()
|
||||
if err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmp.Name())
|
||||
return 0, "", fmt.Errorf("storage/s3: 读取分片 %d 失败: %w", i, err)
|
||||
}
|
||||
if expected != "" && expected != hex.EncodeToString(chunkHash.Sum(nil)) {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmp.Name())
|
||||
return 0, "", fmt.Errorf("%w: 分片 %d 期望 %s", ErrHashMismatch, i, expected)
|
||||
}
|
||||
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmp.Name())
|
||||
return 0, "", fmt.Errorf("storage/s3: 回卷分片 %d 失败: %w", i, err)
|
||||
}
|
||||
up, err := s.client.UploadPart(ctx, &s3.UploadPartInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
UploadId: mpu.UploadId,
|
||||
PartNumber: aws.Int32(int32(i + 1)),
|
||||
Body: tmp,
|
||||
ContentLength: aws.Int64(partLen),
|
||||
})
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmp.Name())
|
||||
if err != nil {
|
||||
return 0, "", mapS3Error(err, fmt.Sprintf("UploadPart(分片 %d)", i))
|
||||
}
|
||||
parts = append(parts, types.CompletedPart{
|
||||
PartNumber: aws.Int32(int32(i + 1)),
|
||||
ETag: up.ETag,
|
||||
})
|
||||
size += partLen
|
||||
}
|
||||
if _, err := s.client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
UploadId: mpu.UploadId,
|
||||
MultipartUpload: &types.CompletedMultipartUpload{Parts: parts},
|
||||
}); err != nil {
|
||||
return 0, "", mapS3Error(err, "CompleteMultipartUpload")
|
||||
}
|
||||
// 合并成功后清理分片对象(静默容错)。
|
||||
_ = s.CleanChunks(ctx, uploadID, savePath)
|
||||
return size, hex.EncodeToString(totalHash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// mergeSingle 单分片合并快速路径:GET 分片 → 落临时文件校验 → PutObject 正式键。
|
||||
func (s *S3Storage) mergeSingle(ctx context.Context, chunkKey, dstKey string, verifyHash func(index int) (string, error), index int) (int64, string, error) {
|
||||
getOut, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(chunkKey),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, "", mapS3Error(err, "GetObject(分片)")
|
||||
}
|
||||
defer func() { _ = getOut.Body.Close() }()
|
||||
tmp, err := os.CreateTemp("", "fcb-s3-merge-*")
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("storage/s3: 创建临时文件失败: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = tmp.Close(); _ = os.Remove(tmpName) }()
|
||||
fileHash := sha256.New()
|
||||
size, err := io.Copy(io.MultiWriter(tmp, fileHash), getOut.Body)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("storage/s3: 读取分片失败: %w", err)
|
||||
}
|
||||
if verifyHash != nil {
|
||||
expected, err := verifyHash(index)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
if expected != "" && expected != hex.EncodeToString(fileHash.Sum(nil)) {
|
||||
return 0, "", fmt.Errorf("%w: 分片 %d 期望 %s", ErrHashMismatch, index, expected)
|
||||
}
|
||||
}
|
||||
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, "", fmt.Errorf("storage/s3: 回卷临时文件失败: %w", err)
|
||||
}
|
||||
if _, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(dstKey),
|
||||
Body: tmp,
|
||||
ContentLength: aws.Int64(size),
|
||||
ContentType: aws.String("application/octet-stream"),
|
||||
}); err != nil {
|
||||
return 0, "", mapS3Error(err, "PutObject(合并)")
|
||||
}
|
||||
return size, hex.EncodeToString(fileHash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// CleanChunks 列举并批量删除分片对象;前缀不存在时静默成功。
|
||||
func (s *S3Storage) CleanChunks(ctx context.Context, uploadID string, savePath string) error {
|
||||
prefix, err := s.key(chunkDirOf(savePath, uploadID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prefix += "/"
|
||||
paginator := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Prefix: aws.String(prefix),
|
||||
})
|
||||
for paginator.HasMorePages() {
|
||||
page, err := paginator.NextPage(ctx)
|
||||
if err != nil {
|
||||
return mapS3Error(err, "ListObjectsV2(分片)")
|
||||
}
|
||||
if len(page.Contents) == 0 {
|
||||
return nil
|
||||
}
|
||||
objs := make([]types.ObjectIdentifier, 0, len(page.Contents))
|
||||
for _, obj := range page.Contents {
|
||||
objs = append(objs, types.ObjectIdentifier{Key: obj.Key})
|
||||
}
|
||||
if _, err := s.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Delete: &types.Delete{Objects: objs, Quiet: aws.Bool(true)},
|
||||
}); err != nil {
|
||||
return mapS3Error(err, "DeleteObjects(分片)")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FileExists HeadObject 探测存在性。
|
||||
func (s *S3Storage) FileExists(ctx context.Context, savePath string) (bool, error) {
|
||||
key, err := s.key(savePath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = s.client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
if isS3NotFound(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, mapS3Error(err, "HeadObject")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// PresignGetURL 生成限时下载直链。
|
||||
func (s *S3Storage) PresignGetURL(ctx context.Context, savePath string, expires int64) (string, error) {
|
||||
key, err := s.key(savePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if expires <= 0 {
|
||||
expires = 3600
|
||||
}
|
||||
out, err := s.presigner.PresignGetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
}, s3.WithPresignExpires(time.Duration(expires)*time.Second))
|
||||
if err != nil {
|
||||
return "", mapS3Error(err, "PresignGetObject")
|
||||
}
|
||||
return out.URL, nil
|
||||
}
|
||||
|
||||
// PresignPutURL 生成限时直传 URL。
|
||||
func (s *S3Storage) PresignPutURL(ctx context.Context, savePath string, expires int64) (string, error) {
|
||||
key, err := s.key(savePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if expires <= 0 {
|
||||
expires = 900
|
||||
}
|
||||
out, err := s.presigner.PresignPutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
}, s3.WithPresignExpires(time.Duration(expires)*time.Second))
|
||||
if err != nil {
|
||||
return "", mapS3Error(err, "PresignPutObject")
|
||||
}
|
||||
return out.URL, nil
|
||||
}
|
||||
|
||||
// HealthCheck 健康检查:列举 bucket(MaxKeys=1),同时校验连通性、凭据与 bucket 存在。
|
||||
func (s *S3Storage) HealthCheck(ctx context.Context) error {
|
||||
maxKeys := int32(1)
|
||||
_, err := s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(s.bucket),
|
||||
MaxKeys: aws.Int32(maxKeys),
|
||||
Prefix: aws.String(""),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: S3 健康检查失败: %v", ErrUnavailable, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadHead 读取对象前 n 字节(保留的便捷封装:HeadMeta 的仅头部形态)。
|
||||
func (s *S3Storage) ReadHead(ctx context.Context, savePath string, n int64) ([]byte, error) {
|
||||
_, head, err := s.HeadMeta(ctx, savePath, n)
|
||||
return head, err
|
||||
}
|
||||
|
||||
// HeadMeta 读取对象元信息与头部字节(S3 引擎实现:HeadObject + Range GET)。
|
||||
func (s *S3Storage) HeadMeta(ctx context.Context, savePath string, headBytes int64) (*FileMeta, []byte, error) {
|
||||
key, err := s.key(savePath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
head, err := s.headBytes(ctx, s.bucket, key, headBytes)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
meta, err := s.Stat(ctx, savePath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return meta, head, nil
|
||||
}
|
||||
|
||||
// headBytes 通过 Range GET 读取对象前 n 字节。
|
||||
func (s *S3Storage) headBytes(ctx context.Context, bucket, key string, n int64) ([]byte, error) {
|
||||
if n <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
Range: aws.String(fmt.Sprintf("bytes=0-%d", n-1)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, mapS3Error(err, "GetObject(head)")
|
||||
}
|
||||
defer func() { _ = out.Body.Close() }()
|
||||
return io.ReadAll(io.LimitReader(out.Body, n))
|
||||
}
|
||||
|
||||
// isS3NotFound 判断错误是否为对象不存在。
|
||||
func isS3NotFound(err error) bool {
|
||||
var nf *types.NotFound
|
||||
if errors.As(err, &nf) {
|
||||
return true
|
||||
}
|
||||
var ae smithy.APIError
|
||||
if errors.As(err, &ae) {
|
||||
switch ae.ErrorCode() {
|
||||
case "NotFound", "NoSuchKey":
|
||||
return true
|
||||
}
|
||||
}
|
||||
var re *awshttp.ResponseError
|
||||
if errors.As(err, &re) && re.HTTPStatusCode() == http.StatusNotFound {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// mapS3Error 将 SDK 错误映射为包内哨兵错误。
|
||||
func mapS3Error(err error, op string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if isS3NotFound(err) {
|
||||
return fmt.Errorf("%w(%s)", ErrNotFound, op)
|
||||
}
|
||||
var re *awshttp.ResponseError
|
||||
if errors.As(err, &re) && re.HTTPStatusCode() == http.StatusRequestedRangeNotSatisfiable {
|
||||
return fmt.Errorf("%w(%s)", ErrRangeNotSatisfiable, op)
|
||||
}
|
||||
var ae smithy.APIError
|
||||
if errors.As(err, &ae) && ae.ErrorCode() == "InvalidRange" {
|
||||
return fmt.Errorf("%w(%s)", ErrRangeNotSatisfiable, op)
|
||||
}
|
||||
return fmt.Errorf("storage/s3: %s 失败: %w", op, err)
|
||||
}
|
||||
|
||||
// 接口编译期断言。
|
||||
var _ Storage = (*S3Storage)(nil)
|
||||
@@ -0,0 +1,495 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---- 最小 S3 兼容假服务(仅覆盖本引擎用到的 API)----
|
||||
|
||||
type fakeS3Upload struct {
|
||||
key string
|
||||
parts map[int][]byte
|
||||
}
|
||||
|
||||
type fakeS3 struct {
|
||||
mu sync.Mutex
|
||||
objects map[string][]byte
|
||||
uploads map[string]*fakeS3Upload
|
||||
nextID int
|
||||
|
||||
putCount int
|
||||
getCount int
|
||||
headCount int
|
||||
deleteCount int
|
||||
listCount int
|
||||
completeN int
|
||||
|
||||
// failNextGet:让接下来 N 次 GET 返回 503(重试测试用)。
|
||||
failNextGet int
|
||||
}
|
||||
|
||||
func newFakeS3() *fakeS3 {
|
||||
return &fakeS3{objects: map[string][]byte{}, uploads: map[string]*fakeS3Upload{}}
|
||||
}
|
||||
|
||||
// s3Key 从 path-style 路径剥离 bucket 前缀得到对象键。
|
||||
func s3Key(r *http.Request) (bucket, key string) {
|
||||
p := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if i := strings.Index(p, "/"); i >= 0 {
|
||||
return p[:i], p[i+1:]
|
||||
}
|
||||
return p, ""
|
||||
}
|
||||
|
||||
func s3ErrorXML(w http.ResponseWriter, status int, code, msg string) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(
|
||||
`<?xml version="1.0" encoding="UTF-8"?><Error><Code>%s</Code><Message>%s</Message></Error>`, code, msg)))
|
||||
}
|
||||
|
||||
func (f *fakeS3) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
q := r.URL.Query()
|
||||
bucket, key := s3Key(r)
|
||||
|
||||
switch {
|
||||
// UploadPart
|
||||
case r.Method == http.MethodPut && q.Get("partNumber") != "" && q.Get("uploadId") != "":
|
||||
up, ok := f.uploads[q.Get("uploadId")]
|
||||
if !ok {
|
||||
s3ErrorXML(w, 404, "NoSuchUpload", "upload not found")
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var n int
|
||||
_, _ = fmt.Sscanf(q.Get("partNumber"), "%d", &n)
|
||||
up.parts[n] = body
|
||||
w.Header().Set("ETag", fmt.Sprintf(`"part-%d"`, n))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
// CreateMultipartUpload
|
||||
case r.Method == http.MethodPost && q.Has("uploads"):
|
||||
f.nextID++
|
||||
id := fmt.Sprintf("mpu-%d", f.nextID)
|
||||
f.uploads[id] = &fakeS3Upload{key: key, parts: map[int][]byte{}}
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(
|
||||
`<?xml version="1.0" encoding="UTF-8"?><InitiateMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Bucket>%s</Bucket><Key>%s</Key><UploadId>%s</UploadId></InitiateMultipartUploadResult>`,
|
||||
bucket, key, id)))
|
||||
// CompleteMultipartUpload
|
||||
case r.Method == http.MethodPost && q.Get("uploadId") != "":
|
||||
up, ok := f.uploads[q.Get("uploadId")]
|
||||
if !ok {
|
||||
s3ErrorXML(w, 404, "NoSuchUpload", "upload not found")
|
||||
return
|
||||
}
|
||||
// 按 partNumber 有序拼接
|
||||
nums := make([]int, 0, len(up.parts))
|
||||
for n := range up.parts {
|
||||
nums = append(nums, n)
|
||||
}
|
||||
sort.Ints(nums)
|
||||
var merged bytes.Buffer
|
||||
for _, n := range nums {
|
||||
merged.Write(up.parts[n])
|
||||
}
|
||||
f.objects[up.key] = merged.Bytes()
|
||||
delete(f.uploads, q.Get("uploadId"))
|
||||
f.completeN++
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(
|
||||
`<?xml version="1.0" encoding="UTF-8"?><CompleteMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Location>http://%s/%s/%s</Location><Bucket>%s</Bucket><Key>%s</Key><ETag>"merged"</ETag></CompleteMultipartUploadResult>`,
|
||||
r.Host, bucket, up.key, bucket, up.key)))
|
||||
// AbortMultipartUpload
|
||||
case r.Method == http.MethodDelete && q.Get("uploadId") != "":
|
||||
delete(f.uploads, q.Get("uploadId"))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
// DeleteObjects(批量)
|
||||
case r.Method == http.MethodPost && q.Has("delete"):
|
||||
var req struct {
|
||||
Objects []struct {
|
||||
Key string `xml:"Key"`
|
||||
} `xml:"Object"`
|
||||
}
|
||||
_ = xml.NewDecoder(r.Body).Decode(&req)
|
||||
for _, o := range req.Objects {
|
||||
delete(f.objects, o.Key)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"></DeleteResult>`))
|
||||
// ListObjectsV2
|
||||
case r.Method == http.MethodGet && q.Get("list-type") == "2":
|
||||
f.listCount++
|
||||
prefix := q.Get("prefix")
|
||||
var body strings.Builder
|
||||
body.WriteString(`<?xml version="1.0" encoding="UTF-8"?><ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>` + bucket + `</Name><Prefix>` + prefix + `</Prefix><IsTruncated>false</IsTruncated>`)
|
||||
keys := make([]string, 0, len(f.objects))
|
||||
for k := range f.objects {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
body.WriteString(fmt.Sprintf("<Contents><Key>%s</Key><Size>%d</Size></Contents>", k, len(f.objects[k])))
|
||||
}
|
||||
body.WriteString("</ListBucketResult>")
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(body.String()))
|
||||
// PutObject
|
||||
case r.Method == http.MethodPut:
|
||||
f.putCount++
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
f.objects[key] = body
|
||||
w.Header().Set("ETag", `"put"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
// GetObject
|
||||
case r.Method == http.MethodGet:
|
||||
if f.failNextGet > 0 {
|
||||
f.failNextGet--
|
||||
s3ErrorXML(w, 503, "ServiceUnavailable", "flaky")
|
||||
return
|
||||
}
|
||||
f.getCount++
|
||||
data, ok := f.objects[key]
|
||||
if !ok {
|
||||
s3ErrorXML(w, 404, "NoSuchKey", "not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
if rng := r.Header.Get("Range"); rng != "" {
|
||||
start, end := int64(0), int64(len(data))-1
|
||||
if _, err := fmt.Sscanf(rng, "bytes=%d-%d", &start, &end); err != nil {
|
||||
var s int64
|
||||
if _, err := fmt.Sscanf(rng, "bytes=%d-", &s); err == nil {
|
||||
start, end = s, int64(len(data))-1
|
||||
}
|
||||
}
|
||||
if start < 0 || start >= int64(len(data)) {
|
||||
s3ErrorXML(w, 416, "InvalidRange", "range not satisfiable")
|
||||
return
|
||||
}
|
||||
if end >= int64(len(data)) {
|
||||
end = int64(len(data)) - 1
|
||||
}
|
||||
slice := data[start : end+1]
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(data)))
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = w.Write(slice)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
// HeadObject
|
||||
case r.Method == http.MethodHead:
|
||||
f.headCount++
|
||||
data, ok := f.objects[key]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
// DeleteObject
|
||||
case r.Method == http.MethodDelete:
|
||||
f.deleteCount++
|
||||
delete(f.objects, key)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
s3ErrorXML(w, 400, "NotImplemented", "unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
// newTestS3 构造对接假服务的 S3 引擎。
|
||||
func newTestS3(t *testing.T) (*S3Storage, *fakeS3) {
|
||||
t.Helper()
|
||||
f := newFakeS3()
|
||||
srv := httptest.NewServer(f)
|
||||
t.Cleanup(srv.Close)
|
||||
st, err := NewS3Storage(S3Options{
|
||||
AccessKeyID: "test-ak",
|
||||
SecretAccessKey: "test-sk",
|
||||
Bucket: "test-bucket",
|
||||
Endpoint: srv.URL,
|
||||
Region: "us-east-1",
|
||||
AddressingStyle: "path",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewS3Storage: %v", err)
|
||||
}
|
||||
return st, f
|
||||
}
|
||||
|
||||
// TestS3SaveStatOpenRange 保存/元信息/完整与 Range 下载。
|
||||
func TestS3SaveStatOpenRange(t *testing.T) {
|
||||
st, f := newTestS3(t)
|
||||
ctx := context.Background()
|
||||
data := []byte("0123456789abcdef S3 引擎测试数据")
|
||||
|
||||
n, err := st.SaveFile(ctx, bytes.NewReader(data), "2025/08/s3.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveFile: %v", err)
|
||||
}
|
||||
if n != int64(len(data)) {
|
||||
t.Fatalf("n = %d", n)
|
||||
}
|
||||
if got := f.objects["2025/08/s3.bin"]; !bytes.Equal(got, data) {
|
||||
t.Fatalf("stored mismatch")
|
||||
}
|
||||
|
||||
meta, err := st.Stat(ctx, "2025/08/s3.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("Stat: %v", err)
|
||||
}
|
||||
if meta.Size != int64(len(data)) || !meta.AcceptRanges {
|
||||
t.Fatalf("Stat = %+v", meta)
|
||||
}
|
||||
|
||||
dl, err := st.Open(ctx, "2025/08/s3.bin", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
got, _ := io.ReadAll(dl)
|
||||
_ = dl.Close()
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Fatalf("full content mismatch")
|
||||
}
|
||||
if dl.Start != 0 || dl.End != int64(len(data))-1 || dl.Total != int64(len(data)) {
|
||||
t.Fatalf("full offsets = %d,%d,%d", dl.Start, dl.End, dl.Total)
|
||||
}
|
||||
|
||||
dl, err = st.Open(ctx, "2025/08/s3.bin", &Range{Start: 4, End: 9})
|
||||
if err != nil {
|
||||
t.Fatalf("Open range: %v", err)
|
||||
}
|
||||
got, _ = io.ReadAll(dl)
|
||||
_ = dl.Close()
|
||||
if !bytes.Equal(got, data[4:10]) {
|
||||
t.Fatalf("range mismatch: %q", got)
|
||||
}
|
||||
if dl.Start != 4 || dl.End != 9 || dl.Total != int64(len(data)) {
|
||||
t.Fatalf("range offsets = %d,%d,%d", dl.Start, dl.End, dl.Total)
|
||||
}
|
||||
|
||||
// 404 / 416
|
||||
if _, err := st.Open(ctx, "missing.bin", nil); err == nil || !strings.Contains(err.Error(), ErrNotFound.Error()) {
|
||||
t.Fatalf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
if _, err := st.Open(ctx, "2025/08/s3.bin", &Range{Start: int64(len(data)) + 5, End: -1}); err == nil ||
|
||||
!strings.Contains(err.Error(), ErrRangeNotSatisfiable.Error()) {
|
||||
t.Fatalf("want ErrRangeNotSatisfiable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestS3DeleteExists 删除与存在性。
|
||||
func TestS3DeleteExists(t *testing.T) {
|
||||
st, _ := newTestS3(t)
|
||||
ctx := context.Background()
|
||||
if _, err := st.SaveFile(ctx, strings.NewReader("x"), "del.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ok, err := st.FileExists(ctx, "del.bin")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("exists = %v %v", ok, err)
|
||||
}
|
||||
if err := st.DeleteFile(ctx, "del.bin"); err != nil {
|
||||
t.Fatalf("DeleteFile: %v", err)
|
||||
}
|
||||
ok, err = st.FileExists(ctx, "del.bin")
|
||||
if err != nil || ok {
|
||||
t.Fatalf("after delete exists = %v %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestS3ChunkMergeMulti 多分片合并:原生 multipart + 哈希校验 + 分片清理。
|
||||
func TestS3ChunkMergeMulti(t *testing.T) {
|
||||
st, f := newTestS3(t)
|
||||
ctx := context.Background()
|
||||
savePath := "2025/09/s3-chunked.bin"
|
||||
uploadID := "uid-s3"
|
||||
|
||||
chunks := [][]byte{bytes.Repeat([]byte("A"), 6*1024*1024/3), []byte("BBBB"), []byte("CC")}
|
||||
// 注意:multipart 除最后一片需 ≥5MB;此处只验证代码路径,真实约束由部署配置保证。
|
||||
// 为避免 EntityTooSmall,将第一片放大:
|
||||
chunks[0] = bytes.Repeat([]byte("A"), 5*1024*1024)
|
||||
hashes := make([]string, len(chunks))
|
||||
var total int64
|
||||
for i, c := range chunks {
|
||||
n, err := st.SaveChunk(ctx, uploadID, i, bytes.NewReader(c), savePath)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveChunk %d: %v", i, err)
|
||||
}
|
||||
hashes[i] = sha256Hex(c)
|
||||
total += int64(len(c))
|
||||
_ = n
|
||||
}
|
||||
|
||||
size, fileHash, err := st.MergeChunks(ctx, uploadID, len(chunks), func(i int) (string, error) {
|
||||
return hashes[i], nil
|
||||
}, savePath)
|
||||
if err != nil {
|
||||
t.Fatalf("MergeChunks: %v", err)
|
||||
}
|
||||
if size != total {
|
||||
t.Fatalf("size = %d want %d", size, total)
|
||||
}
|
||||
if fileHash != sha256Hex(bytes.Join(chunks, nil)) {
|
||||
t.Fatalf("file hash mismatch")
|
||||
}
|
||||
merged := f.objects[savePath]
|
||||
if !bytes.Equal(merged, bytes.Join(chunks, nil)) {
|
||||
t.Fatalf("merged object mismatch (len=%d)", len(merged))
|
||||
}
|
||||
if f.completeN != 1 {
|
||||
t.Fatalf("CompleteMultipartUpload 次数 = %d", f.completeN)
|
||||
}
|
||||
// 分片对象已清理
|
||||
for k := range f.objects {
|
||||
if strings.Contains(k, "chunks/"+uploadID) {
|
||||
t.Fatalf("分片对象残留: %s", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestS3ChunkMergeSingle 单分片快速路径。
|
||||
func TestS3ChunkMergeSingle(t *testing.T) {
|
||||
st, f := newTestS3(t)
|
||||
ctx := context.Background()
|
||||
data := []byte("single-chunk")
|
||||
if _, err := st.SaveChunk(ctx, "uid1", 0, bytes.NewReader(data), "one.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
size, fileHash, err := st.MergeChunks(ctx, "uid1", 1, func(i int) (string, error) {
|
||||
return sha256Hex(data), nil
|
||||
}, "one.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("MergeChunks: %v", err)
|
||||
}
|
||||
if size != int64(len(data)) || fileHash != sha256Hex(data) {
|
||||
t.Fatalf("size/hash mismatch")
|
||||
}
|
||||
if !bytes.Equal(f.objects["one.bin"], data) {
|
||||
t.Fatalf("object mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestS3CleanChunks 清理残留分片。
|
||||
func TestS3CleanChunks(t *testing.T) {
|
||||
st, f := newTestS3(t)
|
||||
ctx := context.Background()
|
||||
savePath := "clean.bin"
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := st.SaveChunk(ctx, "uidc", i, strings.NewReader("zz"), savePath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
prefix := chunkDirOf(savePath, "uidc") + "/"
|
||||
count := 0
|
||||
for k := range f.objects {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("期望 3 个分片对象,实际 %d", count)
|
||||
}
|
||||
if err := st.CleanChunks(ctx, "uidc", savePath); err != nil {
|
||||
t.Fatalf("CleanChunks: %v", err)
|
||||
}
|
||||
for k := range f.objects {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
t.Fatalf("分片未清理: %s", k)
|
||||
}
|
||||
}
|
||||
// 幂等:再清理一次不报错
|
||||
if err := st.CleanChunks(ctx, "uidc", savePath); err != nil {
|
||||
t.Fatalf("CleanChunks idempotent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestS3Presign 预签名 URL 生成。
|
||||
func TestS3Presign(t *testing.T) {
|
||||
st, _ := newTestS3(t)
|
||||
ctx := context.Background()
|
||||
getURL, err := st.PresignGetURL(ctx, "presign.bin", 600)
|
||||
if err != nil {
|
||||
t.Fatalf("PresignGetURL: %v", err)
|
||||
}
|
||||
if !strings.Contains(getURL, "X-Amz-Signature") || !strings.Contains(getURL, "X-Amz-Expires=600") {
|
||||
t.Fatalf("GET 直链缺少签名参数: %s", getURL)
|
||||
}
|
||||
putURL, err := st.PresignPutURL(ctx, "presign.bin", 300)
|
||||
if err != nil {
|
||||
t.Fatalf("PresignPutURL: %v", err)
|
||||
}
|
||||
if !strings.Contains(putURL, "X-Amz-Signature") {
|
||||
t.Fatalf("PUT 直链缺少签名参数: %s", putURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestS3HealthCheck 健康检查(ListObjectsV2)。
|
||||
func TestS3HealthCheck(t *testing.T) {
|
||||
st, _ := newTestS3(t)
|
||||
if err := st.HealthCheck(context.Background()); err != nil {
|
||||
t.Fatalf("HealthCheck: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestS3RetryOn503 SDK 内置重试器:503 后成功。
|
||||
func TestS3RetryOn503(t *testing.T) {
|
||||
st, f := newTestS3(t)
|
||||
ctx := context.Background()
|
||||
data := []byte("retry-me")
|
||||
if _, err := st.SaveFile(ctx, bytes.NewReader(data), "retry.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.failNextGet = 1
|
||||
f.mu.Unlock()
|
||||
dl, err := st.Open(ctx, "retry.bin", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("503 后应重试成功: %v", err)
|
||||
}
|
||||
got, _ := io.ReadAll(dl)
|
||||
_ = dl.Close()
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Fatalf("content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestS3FactoryRegistry 工厂构造。
|
||||
func TestS3FactoryRegistry(t *testing.T) {
|
||||
f := newFakeS3()
|
||||
srv := httptest.NewServer(f)
|
||||
defer srv.Close()
|
||||
prev := engineOptions.S3
|
||||
engineOptions.S3 = S3Options{
|
||||
AccessKeyID: "ak", SecretAccessKey: "sk", Bucket: "b",
|
||||
Endpoint: srv.URL, Region: "us-east-1", AddressingStyle: "path",
|
||||
}
|
||||
defer func() { engineOptions.S3 = prev }()
|
||||
st, err := NewEngine(context.Background(), "s3")
|
||||
if err != nil {
|
||||
t.Fatalf("NewEngine(s3): %v", err)
|
||||
}
|
||||
if err := st.HealthCheck(context.Background()); err != nil {
|
||||
t.Fatalf("HealthCheck: %v", err)
|
||||
}
|
||||
if _, err := st.PresignGetURL(context.Background(), "x.bin", 60); err != nil {
|
||||
t.Fatalf("Presign: %v", err)
|
||||
}
|
||||
_ = time.Now
|
||||
}
|
||||
@@ -0,0 +1,889 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WebDAVStorage 基于 net/http 的 WebDAV 引擎(本次重写的重点优化对象)。
|
||||
//
|
||||
// 相比参考实现(WebDAVFileStorage,aiohttp)的改进:
|
||||
// - 单例 http.Client + 连接池化 Transport(参考实现每个操作新建 ClientSession,无复用);
|
||||
// - Basic 与 Digest(RFC 2617,qop=auth,MD5/SHA-256)双认证自动协商(参考实现仅 Basic);
|
||||
// - GET 下载透传 Range 头(参考实现全量 GET,无法断点/分段);
|
||||
// - 5xx/429/网络错误指数退避重试,可配次数(参考实现无重试);
|
||||
// - 下载经 io.Pipe 流式转发,全程不落盘;
|
||||
// - 目录存在性内存缓存,按需逐级 MKCOL,避免每次保存都发 PROPFIND;
|
||||
// - 非流式操作带可配超时;流式传输由调用方 ctx 管控(可取消)。
|
||||
type WebDAVStorage struct {
|
||||
base *url.URL // 服务基址(含可能的路径前缀),以 / 结尾
|
||||
root string // 远端根目录(webdav_root_path)
|
||||
username string
|
||||
password string
|
||||
client *http.Client
|
||||
transport *http.Transport
|
||||
auth *authState
|
||||
|
||||
maxRetries int // 5xx/网络错误最大重试次数
|
||||
baseBackoff time.Duration // 退避基数
|
||||
opTimeout time.Duration // 非流式操作超时
|
||||
|
||||
dirMu sync.RWMutex
|
||||
knownDirs map[string]struct{} // 已确认存在的远端目录(含根前缀)
|
||||
spacesPool sync.Pool // 256KB 复用缓冲
|
||||
}
|
||||
|
||||
// NewWebDAVStorage 构造 WebDAV 引擎。
|
||||
func NewWebDAVStorage(opts WebDAVOptions) (*WebDAVStorage, error) {
|
||||
opts.applyDefaults()
|
||||
raw := strings.TrimSpace(opts.BaseURL)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("storage/webdav: 缺少 webdav_url 配置")
|
||||
}
|
||||
if !strings.Contains(raw, "://") {
|
||||
raw = "http://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage/webdav: webdav_url 非法: %w", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return nil, fmt.Errorf("storage/webdav: webdav_url 仅支持 http/https,收到 %q", u.Scheme)
|
||||
}
|
||||
if !strings.HasSuffix(u.Path, "/") {
|
||||
u.Path += "/"
|
||||
}
|
||||
root := strings.Trim(opts.RootPath, "/")
|
||||
if root == "" {
|
||||
root = "filebox_storage"
|
||||
}
|
||||
root = strings.ReplaceAll(root, "\\", "/")
|
||||
transport := newPooledTransport(opts.MaxIdleConnsPerHost)
|
||||
return &WebDAVStorage{
|
||||
base: u,
|
||||
root: root,
|
||||
username: opts.Username,
|
||||
password: opts.Password,
|
||||
client: &http.Client{Transport: transport},
|
||||
transport: transport,
|
||||
auth: newAuthState(opts.Username, opts.Password),
|
||||
maxRetries: opts.MaxRetries,
|
||||
baseBackoff: time.Duration(opts.BaseBackoff) * time.Millisecond,
|
||||
opTimeout: time.Duration(opts.Timeout) * time.Second,
|
||||
knownDirs: map[string]struct{}{},
|
||||
spacesPool: sync.Pool{New: func() any {
|
||||
b := make([]byte, localChunkSize)
|
||||
return &b
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterEngine("webdav", func(ctx context.Context) (Storage, error) {
|
||||
return NewWebDAVStorage(engineOptions.WebDAV)
|
||||
})
|
||||
}
|
||||
|
||||
// newPooledTransport 连接池化 Transport:Keep-Alive 连接复用是 WebDAV 优化的核心。
|
||||
func newPooledTransport(maxIdlePerHost int) *http.Transport {
|
||||
return &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: maxIdlePerHost,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
ResponseHeaderTimeout: 60 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// requestOpts 单次 WebDAV 请求参数。
|
||||
type requestOpts struct {
|
||||
// body 请求体工厂:每次尝试调用一次(重试时重新获取,可重放)。
|
||||
body func() (io.Reader, int64, error)
|
||||
// retryBody 请求体是否可重放(seekable);false 时 PUT 类请求失败不重试。
|
||||
retryBody bool
|
||||
// headers 附加请求头。
|
||||
headers map[string]string
|
||||
// streaming 流式传输(GET/PUT 大 body):不套 opTimeout,由调用方 ctx 管控。
|
||||
streaming bool
|
||||
}
|
||||
|
||||
// do 执行一次 WebDAV 请求:认证自动协商 + 指数退避重试。
|
||||
// 返回的响应由调用方负责关闭(drainClose / readErrorBody)。
|
||||
//
|
||||
// 重要:非流式操作的可配超时通过 ctx 实现,cancel 不随 do() 返回而调用,
|
||||
// 而是挂在 davResponse 上、待响应体读完后再触发——否则取消会提前杀掉
|
||||
// Keep-Alive 连接,破坏连接复用。
|
||||
func (w *WebDAVStorage) do(ctx context.Context, method, rawURL string, opts requestOpts) (*davResponse, error) {
|
||||
// 非流式操作套可配超时(流式由调用方 ctx 管控)。
|
||||
var cancel context.CancelFunc
|
||||
if !opts.streaming {
|
||||
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
|
||||
ctx, cancel = context.WithTimeout(ctx, w.opTimeout)
|
||||
}
|
||||
}
|
||||
fail := func(err error) (*davResponse, error) {
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// 幂等方法或可重放 body 才允许整体重试。
|
||||
idempotent := method == http.MethodGet || method == http.MethodHead ||
|
||||
method == "PROPFIND" || method == "MKCOL" || method == http.MethodDelete ||
|
||||
method == http.MethodOptions
|
||||
retryable := idempotent || opts.retryBody
|
||||
|
||||
const maxAuthRetries = 2
|
||||
budget := w.maxRetries + maxAuthRetries // 认证挑战重试不消耗退避预算
|
||||
authRetries := 0
|
||||
for attempt := 0; attempt < budget; attempt++ {
|
||||
var body io.Reader
|
||||
var length int64 = -1
|
||||
if opts.body != nil {
|
||||
var err error
|
||||
body, length, err = opts.body()
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("%w: 构造请求体失败: %v", ErrUnavailable, err))
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("%w: 构造请求失败: %v", ErrInvalidPath, err))
|
||||
}
|
||||
if length >= 0 {
|
||||
req.ContentLength = length
|
||||
}
|
||||
for k, v := range opts.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
w.auth.apply(req)
|
||||
resp, err := w.client.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil { // 调用方取消/超时优先
|
||||
return fail(ctx.Err())
|
||||
}
|
||||
if retryable && attempt+1 < budget {
|
||||
if sleepErr := w.backoff(ctx, attempt, 0); sleepErr != nil {
|
||||
return fail(sleepErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return fail(fmt.Errorf("%w: %s %s: %v", ErrUnavailable, method, rawURL, err))
|
||||
}
|
||||
// 401 认证挑战:切换 Basic/Digest 后立即重试(不退避、不额外计数)。
|
||||
if resp.StatusCode == http.StatusUnauthorized && authRetries < maxAuthRetries {
|
||||
challenge := resp.Header.Get("WWW-Authenticate")
|
||||
drainClose(&davResponse{Response: resp})
|
||||
if challenge != "" && w.auth.challenge(challenge) {
|
||||
authRetries++
|
||||
continue
|
||||
}
|
||||
return fail(fmt.Errorf("%w: WebDAV 认证失败(401,%s)", ErrUnavailable, rawURL))
|
||||
}
|
||||
// 5xx/429/408:幂等或可重放 body 时指数退避重试。
|
||||
if retryable && isRetryStatus(resp.StatusCode) && attempt+1 < budget {
|
||||
retryAfter := retryAfterSeconds(resp.Header.Get("Retry-After"))
|
||||
drainClose(&davResponse{Response: resp})
|
||||
if sleepErr := w.backoff(ctx, attempt, retryAfter); sleepErr != nil {
|
||||
return fail(sleepErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return &davResponse{Response: resp, cancel: cancel}, nil
|
||||
}
|
||||
return fail(fmt.Errorf("%w: WebDAV 重试耗尽(%s %s)", ErrUnavailable, method, rawURL))
|
||||
}
|
||||
|
||||
// davResponse WebDAV 响应 + 关联的超时取消函数。
|
||||
// 非流式操作读完响应体后必须经 drainClose/readErrorBody 释放(触发 cancel)。
|
||||
type davResponse struct {
|
||||
*http.Response
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// isRetryStatus 判断状态码是否值得重试。
|
||||
func isRetryStatus(code int) bool {
|
||||
switch code {
|
||||
case http.StatusRequestTimeout, http.StatusTooManyRequests,
|
||||
http.StatusInternalServerError, http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable, http.StatusGatewayTimeout:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// retryAfterSeconds 解析 Retry-After(秒);非法或负值返回 0。
|
||||
func retryAfterSeconds(v string) time.Duration {
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil || n <= 0 {
|
||||
return 0
|
||||
}
|
||||
if n > 5 {
|
||||
n = 5 // 上限 5s,避免异常服务端拖死请求
|
||||
}
|
||||
return time.Duration(n) * time.Second
|
||||
}
|
||||
|
||||
// backoff 指数退避:base * 2^attempt,封顶 2s,带 ±20% 抖动;retryAfter 优先。
|
||||
func (w *WebDAVStorage) backoff(ctx context.Context, attempt int, retryAfter time.Duration) error {
|
||||
d := retryAfter
|
||||
if d <= 0 {
|
||||
d = w.baseBackoff << attempt
|
||||
if d > 2*time.Second {
|
||||
d = 2 * time.Second
|
||||
}
|
||||
// ±20% 抖动
|
||||
jitter := time.Duration(int64(d) / 5)
|
||||
if jitter > 0 {
|
||||
d -= time.Duration(rand.Int63n(int64(jitter)))
|
||||
}
|
||||
}
|
||||
if d <= 0 {
|
||||
d = time.Millisecond
|
||||
}
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// drainClose 读取少量残余并关闭响应体,保证连接可复用;随后触发超时清理。
|
||||
func drainClose(resp *davResponse) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 8<<10))
|
||||
_ = resp.Body.Close()
|
||||
if resp.cancel != nil {
|
||||
resp.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// joinRemote 校验 savePath 并拼接远端完整路径(含根目录前缀)。
|
||||
func (w *WebDAVStorage) joinRemote(savePath string) (string, error) {
|
||||
cleaned, ok := SanitizePath(savePath)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%w: %q", ErrInvalidPath, savePath)
|
||||
}
|
||||
return path.Join(w.root, cleaned), nil
|
||||
}
|
||||
|
||||
// urlFor 将远端路径转为完整 URL(URL.String 自动按段转义)。
|
||||
func (w *WebDAVStorage) urlFor(remotePath string) string {
|
||||
u := *w.base
|
||||
p := strings.TrimSuffix(u.Path, "/")
|
||||
remotePath = strings.Trim(remotePath, "/")
|
||||
if remotePath != "" && remotePath != "." {
|
||||
p += "/" + remotePath
|
||||
}
|
||||
u.Path = p
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// propfindBody PROPFIND 请求体:只取需要的属性。
|
||||
const propfindBody = `<?xml version="1.0" encoding="utf-8"?>` +
|
||||
`<D:propfind xmlns:D="DAV:"><D:prop>` +
|
||||
`<D:resourcetype/><D:getcontentlength/><D:getcontenttype/>` +
|
||||
`</D:prop></D:propfind>`
|
||||
|
||||
// davMultistatus 207 Multi-Status XML 解析结构(标签名与命名空间无关匹配)。
|
||||
type davMultistatus struct {
|
||||
Responses []struct {
|
||||
Href string `xml:"href"`
|
||||
Propstat []struct {
|
||||
Status string `xml:"status"`
|
||||
Prop struct {
|
||||
ContentLength int64 `xml:"getcontentlength"`
|
||||
ContentType string `xml:"getcontenttype"`
|
||||
ResourceType struct {
|
||||
Collection *struct{} `xml:"collection"`
|
||||
} `xml:"resourcetype"`
|
||||
} `xml:"prop"`
|
||||
} `xml:"propstat"`
|
||||
} `xml:"response"`
|
||||
}
|
||||
|
||||
// firstProp 取第一个 HTTP 2xx 状态的属性块。
|
||||
func (m *davMultistatus) firstProp() (length int64, ctype string, isDir bool, ok bool) {
|
||||
for _, r := range m.Responses {
|
||||
for _, ps := range r.Propstat {
|
||||
if !strings.Contains(ps.Status, " 200 ") {
|
||||
continue
|
||||
}
|
||||
return ps.Prop.ContentLength, ps.Prop.ContentType, ps.Prop.ResourceType.Collection != nil, true
|
||||
}
|
||||
}
|
||||
return 0, "", false, false
|
||||
}
|
||||
|
||||
// propfind 执行 PROPFIND 并解析 207 响应;404 时返回 (nil, nil)。
|
||||
func (w *WebDAVStorage) propfind(ctx context.Context, rawURL string, depth string) (*davMultistatus, error) {
|
||||
resp, err := w.do(ctx, "PROPFIND", rawURL, requestOpts{
|
||||
body: func() (io.Reader, int64, error) {
|
||||
return strings.NewReader(propfindBody), int64(len(propfindBody)), nil
|
||||
},
|
||||
headers: map[string]string{"Depth": depth, "Content-Type": "application/xml"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer drainClose(resp)
|
||||
switch resp.StatusCode {
|
||||
case http.StatusMultiStatus, http.StatusOK:
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: PROPFIND 读取失败: %v", ErrUnavailable, err)
|
||||
}
|
||||
var ms davMultistatus
|
||||
if err := xml.Unmarshal(body, &ms); err != nil {
|
||||
return nil, fmt.Errorf("%w: PROPFIND XML 解析失败: %v", ErrUnavailable, err)
|
||||
}
|
||||
return &ms, nil
|
||||
case http.StatusNotFound:
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: PROPFIND %s → %d", ErrUnavailable, rawURL, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// remoteExists PROPFIND 探测远端路径存在性。
|
||||
func (w *WebDAVStorage) remoteExists(ctx context.Context, remotePath string) (bool, error) {
|
||||
ms, err := w.propfind(ctx, w.urlFor(remotePath), "0")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return ms != nil, nil
|
||||
}
|
||||
|
||||
// markDir 记录已确认存在的目录(避免重复 PROPFIND/MKCOL 往返)。
|
||||
func (w *WebDAVStorage) markDir(remotePath string) {
|
||||
w.dirMu.Lock()
|
||||
defer w.dirMu.Unlock()
|
||||
w.knownDirs[remotePath] = struct{}{}
|
||||
}
|
||||
|
||||
// unmarkDir 目录被删除时移除缓存。
|
||||
func (w *WebDAVStorage) unmarkDir(remotePath string) {
|
||||
w.dirMu.Lock()
|
||||
defer w.dirMu.Unlock()
|
||||
delete(w.knownDirs, remotePath)
|
||||
}
|
||||
|
||||
// isMarkedDir 查询目录缓存。
|
||||
func (w *WebDAVStorage) isMarkedDir(remotePath string) bool {
|
||||
w.dirMu.RLock()
|
||||
defer w.dirMu.RUnlock()
|
||||
_, ok := w.knownDirs[remotePath]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ensureDirs 按需逐级创建远端目录(含根前缀;MKCOL 级联,成功后写缓存)。
|
||||
func (w *WebDAVStorage) ensureDirs(ctx context.Context, remotePath string) error {
|
||||
segments := splitRemoteSegments(remotePath)
|
||||
cur := ""
|
||||
for _, seg := range segments {
|
||||
cur = path.Join(cur, seg)
|
||||
if w.isMarkedDir(cur) {
|
||||
continue
|
||||
}
|
||||
exists, err := w.remoteExists(ctx, cur)
|
||||
if err == nil && exists {
|
||||
w.markDir(cur)
|
||||
continue
|
||||
}
|
||||
if err != nil && !errors.Is(err, ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
resp, err := w.do(ctx, "MKCOL", w.urlFor(cur), requestOpts{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status := resp.StatusCode
|
||||
drainClose(resp)
|
||||
// 201 创建成功;405 已存在;其余视为失败(409 通常因父目录缺失,理论上不会出现)。
|
||||
if status == http.StatusCreated || status == http.StatusOK ||
|
||||
status == http.StatusNoContent || status == http.StatusMethodNotAllowed {
|
||||
w.markDir(cur)
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("%w: MKCOL %s → %d", ErrUnavailable, cur, status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitRemoteSegments 拆分远端路径段。
|
||||
func splitRemoteSegments(p string) []string {
|
||||
p = strings.Trim(strings.ReplaceAll(p, "\\", "/"), "/")
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(p, "/")
|
||||
}
|
||||
|
||||
// deleteEmptyParents 删除空父目录(含根前缀,但不删根目录本身);尽力而为。
|
||||
func (w *WebDAVStorage) deleteEmptyParents(ctx context.Context, remotePath string) {
|
||||
dir := path.Dir(remotePath)
|
||||
for dir != "" && dir != "." && dir != w.root && strings.HasPrefix(dir+"/", w.root+"/") {
|
||||
ms, err := w.propfind(ctx, w.urlFor(dir), "1")
|
||||
if err != nil || ms == nil {
|
||||
return
|
||||
}
|
||||
if len(ms.Responses) > 1 { // 非空(自身 + 子项)
|
||||
return
|
||||
}
|
||||
resp, err := w.do(ctx, http.MethodDelete, w.urlFor(dir), requestOpts{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ok := resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNoContent
|
||||
drainClose(resp)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.unmarkDir(dir)
|
||||
dir = path.Dir(dir)
|
||||
}
|
||||
}
|
||||
|
||||
// putFile PUT 上传:body 工厂每次尝试返回可重放的读取器。
|
||||
func (w *WebDAVStorage) putFile(ctx context.Context, rawURL string, body func() (io.Reader, int64, error), retryBody bool) (*davResponse, error) {
|
||||
return w.do(ctx, http.MethodPut, rawURL, requestOpts{
|
||||
body: body,
|
||||
retryBody: retryBody,
|
||||
headers: map[string]string{"Content-Type": "application/octet-stream"},
|
||||
streaming: true,
|
||||
})
|
||||
}
|
||||
|
||||
// checkPutStatus 校验 PUT 响应状态。
|
||||
func checkPutStatus(resp *davResponse, op string) error {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK, http.StatusCreated, http.StatusNoContent:
|
||||
drainClose(resp)
|
||||
return nil
|
||||
default:
|
||||
msg := readErrorBody(resp)
|
||||
return fmt.Errorf("%w: %s → %d %s", ErrUnavailable, op, resp.StatusCode, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// readErrorBody 读取错误响应前 200 字节并释放连接。
|
||||
func readErrorBody(resp *davResponse) string {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return ""
|
||||
}
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
|
||||
_ = resp.Body.Close()
|
||||
if resp.cancel != nil {
|
||||
resp.cancel()
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// SaveFile 流式保存(PUT):按需建目录,seekable 源可安全重试。
|
||||
func (w *WebDAVStorage) SaveFile(ctx context.Context, r io.Reader, savePath string) (int64, error) {
|
||||
remote, err := w.joinRemote(savePath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := w.ensureDirs(ctx, path.Dir(remote)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 可重放判定:seekable 源失败后可从头重传(PUT 覆盖语义保证最终一致)。
|
||||
seeker, seekable := r.(io.Seeker)
|
||||
var knownLen int64 = -1
|
||||
if seekable {
|
||||
if cur, err := seeker.Seek(0, io.SeekCurrent); err == nil {
|
||||
if end, err := seeker.Seek(0, io.SeekEnd); err == nil {
|
||||
knownLen = end - cur
|
||||
_, _ = seeker.Seek(cur, io.SeekStart)
|
||||
}
|
||||
}
|
||||
}
|
||||
src := &countingReader{r: r}
|
||||
body := func() (io.Reader, int64, error) {
|
||||
if seekable {
|
||||
if _, err := seeker.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, -1, err
|
||||
}
|
||||
src.reset()
|
||||
}
|
||||
return src, knownLen, nil
|
||||
}
|
||||
resp, err := w.putFile(ctx, w.urlFor(remote), body, seekable)
|
||||
if err != nil {
|
||||
return src.count(), err
|
||||
}
|
||||
if err := checkPutStatus(resp, fmt.Sprintf("PUT %s", remote)); err != nil {
|
||||
return src.count(), err
|
||||
}
|
||||
return src.count(), nil
|
||||
}
|
||||
|
||||
// DeleteFile DELETE 文件 + 尽力清理空父目录。
|
||||
func (w *WebDAVStorage) DeleteFile(ctx context.Context, savePath string) error {
|
||||
remote, err := w.joinRemote(savePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := w.do(ctx, http.MethodDelete, w.urlFor(remote), requestOpts{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK, http.StatusNoContent, http.StatusNotFound:
|
||||
drainClose(resp)
|
||||
default:
|
||||
msg := readErrorBody(resp)
|
||||
return fmt.Errorf("%w: DELETE %s → %d %s", ErrUnavailable, remote, resp.StatusCode, msg)
|
||||
}
|
||||
w.deleteEmptyParents(ctx, remote)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Open 打开下载流:Range 透传,io.Pipe 流式转发不落盘,ctx 可取消。
|
||||
func (w *WebDAVStorage) Open(ctx context.Context, savePath string, rng *Range) (*Download, error) {
|
||||
remote, err := w.joinRemote(savePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := requestOpts{streaming: true}
|
||||
if rng != nil {
|
||||
opts.headers = map[string]string{"Range": rangeHeaderValue(rng)}
|
||||
}
|
||||
resp, err := w.do(ctx, http.MethodGet, w.urlFor(remote), opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK, http.StatusPartialContent:
|
||||
// 正常,继续
|
||||
case http.StatusNotFound:
|
||||
drainClose(resp)
|
||||
return nil, ErrNotFound
|
||||
case http.StatusRequestedRangeNotSatisfiable:
|
||||
drainClose(resp)
|
||||
return nil, ErrRangeNotSatisfiable
|
||||
default:
|
||||
msg := readErrorBody(resp)
|
||||
return nil, fmt.Errorf("%w: GET %s → %d %s", ErrUnavailable, remote, resp.StatusCode, msg)
|
||||
}
|
||||
|
||||
total := resp.ContentLength
|
||||
start, end := int64(0), total-1
|
||||
if resp.StatusCode == http.StatusPartialContent {
|
||||
if cr := resp.Header.Get("Content-Range"); cr != "" {
|
||||
if s0, e0, t0, ok := parseContentRange(cr); ok {
|
||||
start, end = s0, e0
|
||||
if t0 >= 0 {
|
||||
total = t0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if total < 0 { // 服务端未给出长度(chunked):按未知大小处理
|
||||
start, end, total = 0, -1, -1
|
||||
}
|
||||
if rng == nil { // 对齐契约:完整文件 Start=0、End=Total-1
|
||||
start, end = 0, total-1
|
||||
}
|
||||
if end < 0 { // 空文件或未知大小:End 未知语义
|
||||
end = -1
|
||||
}
|
||||
|
||||
// io.Pipe 流式桥接:HTTP 响应体 → 管道 → 调用方,全程不落盘;
|
||||
// 调用方提前 Close 或 ctx 取消都会终止拷贝并释放连接。
|
||||
body := resp.Body
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
bufp, _ := w.spacesPool.Get().(*[]byte)
|
||||
_, copyErr := io.CopyBuffer(pw, body, *bufp)
|
||||
w.spacesPool.Put(bufp)
|
||||
_ = body.Close()
|
||||
pw.CloseWithError(copyErr) // copyErr 为 nil 时写入 EOF
|
||||
}()
|
||||
context.AfterFunc(ctx, func() {
|
||||
_ = pw.CloseWithError(ctx.Err())
|
||||
})
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
return &Download{
|
||||
ReadCloser: pr,
|
||||
Start: start,
|
||||
End: end,
|
||||
Total: total,
|
||||
Meta: FileMeta{
|
||||
Size: total,
|
||||
ContentType: contentType,
|
||||
AcceptRanges: true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Stat PROPFIND Depth 0 获取元信息。
|
||||
func (w *WebDAVStorage) Stat(ctx context.Context, savePath string) (*FileMeta, error) {
|
||||
remote, err := w.joinRemote(savePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ms, err := w.propfind(ctx, w.urlFor(remote), "0")
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if ms == nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
length, ctype, _, ok := ms.firstProp()
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &FileMeta{Size: length, ContentType: ctype, AcceptRanges: true}, nil
|
||||
}
|
||||
|
||||
// HeadMeta 读取文件元信息与前 n 字节(WebDAV 实现:PROPFIND + Range GET)。
|
||||
func (w *WebDAVStorage) HeadMeta(ctx context.Context, savePath string, headBytes int64) (*FileMeta, []byte, error) {
|
||||
meta, err := w.Stat(ctx, savePath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if headBytes <= 0 {
|
||||
return meta, nil, nil
|
||||
}
|
||||
dl, err := w.Open(ctx, savePath, &Range{Start: 0, End: headBytes - 1})
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrRangeNotSatisfiable) { // 空文件等边界:返回空头
|
||||
return meta, nil, nil
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
defer func() { _ = dl.Close() }()
|
||||
head := make([]byte, headBytes)
|
||||
n, _ := io.ReadFull(dl.ReadCloser, head)
|
||||
return meta, head[:n], nil
|
||||
}
|
||||
|
||||
// SaveChunk 保存分片:落临时文件获得精确长度与可重放 body,PUT 到分片路径。
|
||||
func (w *WebDAVStorage) SaveChunk(ctx context.Context, uploadID string, chunkIndex int, r io.Reader, savePath string) (int64, error) {
|
||||
if _, err := w.joinRemote(savePath); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
chunkRel, ok := SanitizePath(ChunkPartPath(savePath, uploadID, chunkIndex))
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("%w: 分片路径非法 %q", ErrInvalidPath, savePath)
|
||||
}
|
||||
remote := path.Join(w.root, chunkRel)
|
||||
if err := w.ensureDirs(ctx, path.Dir(remote)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 分片体积有限(默认 ≤8MB):落临时文件换取精确 Content-Length 与可重试性。
|
||||
tmp, err := os.CreateTemp("", "fcb-webdav-chunk-*")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("storage/webdav: 创建临时文件失败: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = tmp.Close(); _ = os.Remove(tmpName) }()
|
||||
size, err := io.CopyBuffer(tmp, r, make([]byte, localChunkSize))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("storage/webdav: 缓存分片失败: %w", err)
|
||||
}
|
||||
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, fmt.Errorf("storage/webdav: 回卷分片失败: %w", err)
|
||||
}
|
||||
body := func() (io.Reader, int64, error) {
|
||||
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, -1, err
|
||||
}
|
||||
return tmp, size, nil
|
||||
}
|
||||
resp, err := w.putFile(ctx, w.urlFor(remote), body, true)
|
||||
if err != nil {
|
||||
return size, err
|
||||
}
|
||||
if err := checkPutStatus(resp, fmt.Sprintf("PUT 分片 %s", remote)); err != nil {
|
||||
return size, err
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
// MergeChunks 合并 WebDAV 分片:
|
||||
// 逐分片 GET 流式拼入本地临时文件(边拷贝边校验哈希)→ PUT 上传目标 → 清理远端分片与本地临时文件。
|
||||
// 说明:WebDAV 无服务端聚合能力,合并必须经服务端中转;临时文件仅用于拼接与重试,最终 PUT 可重放。
|
||||
func (w *WebDAVStorage) MergeChunks(ctx context.Context, uploadID string, total int, verifyHash func(index int) (string, error), savePath string) (int64, string, error) {
|
||||
if total <= 0 {
|
||||
return 0, "", fmt.Errorf("storage/webdav: 非法分片总数 %d", total)
|
||||
}
|
||||
remote, err := w.joinRemote(savePath)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
if err := w.ensureDirs(ctx, path.Dir(remote)); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
tmp, err := os.CreateTemp("", "fcb-webdav-merge-*")
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("storage/webdav: 创建合并临时文件失败: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = tmp.Close(); _ = os.Remove(tmpName) }()
|
||||
|
||||
totalHash := sha256.New()
|
||||
var size int64
|
||||
for i := 0; i < total; i++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
chunkRel, ok := SanitizePath(ChunkPartPath(savePath, uploadID, i))
|
||||
if !ok {
|
||||
return 0, "", fmt.Errorf("%w: 分片 %d 路径非法", ErrInvalidPath, i)
|
||||
}
|
||||
resp, err := w.do(ctx, http.MethodGet, w.urlFor(path.Join(w.root, chunkRel)), requestOpts{streaming: true})
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("storage/webdav: 读取分片 %d 失败: %w", i, err)
|
||||
}
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
drainClose(resp)
|
||||
return 0, "", fmt.Errorf("storage/webdav: 分片 %d 不存在", i)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
|
||||
msg := readErrorBody(resp)
|
||||
return 0, "", fmt.Errorf("storage/webdav: 读取分片 %d → %d %s", i, resp.StatusCode, msg)
|
||||
}
|
||||
chunkHash := sha256.New()
|
||||
n, err := io.CopyBuffer(io.MultiWriter(tmp, totalHash, chunkHash), resp.Body, make([]byte, localChunkSize))
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("storage/webdav: 拼接分片 %d 失败: %w", i, err)
|
||||
}
|
||||
if verifyHash != nil {
|
||||
expected, err := verifyHash(i)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
if expected != "" && expected != hex.EncodeToString(chunkHash.Sum(nil)) {
|
||||
return 0, "", fmt.Errorf("%w: 分片 %d 期望 %s", ErrHashMismatch, i, expected)
|
||||
}
|
||||
}
|
||||
size += n
|
||||
}
|
||||
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, "", fmt.Errorf("storage/webdav: 回卷合并文件失败: %w", err)
|
||||
}
|
||||
body := func() (io.Reader, int64, error) {
|
||||
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, -1, err
|
||||
}
|
||||
return tmp, size, nil
|
||||
}
|
||||
resp, err := w.putFile(ctx, w.urlFor(remote), body, true)
|
||||
if err != nil {
|
||||
return size, "", err
|
||||
}
|
||||
if err := checkPutStatus(resp, fmt.Sprintf("PUT 合并 %s", remote)); err != nil {
|
||||
return size, "", err
|
||||
}
|
||||
// 合并成功后清理远端分片目录与本地临时文件(defer 兜底删除本地文件)。
|
||||
_ = w.CleanChunks(ctx, uploadID, savePath)
|
||||
return size, hex.EncodeToString(totalHash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// CleanChunks 递归删除远端分片目录(RFC 4918 DELETE 对 collection 递归)。
|
||||
func (w *WebDAVStorage) CleanChunks(ctx context.Context, uploadID string, savePath string) error {
|
||||
dirRel, ok := SanitizePath(chunkDirOf(savePath, uploadID))
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: 分片目录非法 %q", ErrInvalidPath, savePath)
|
||||
}
|
||||
remote := path.Join(w.root, dirRel)
|
||||
resp, err := w.do(ctx, http.MethodDelete, w.urlFor(remote), requestOpts{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK, http.StatusNoContent, http.StatusNotFound:
|
||||
drainClose(resp)
|
||||
w.unmarkDir(remote)
|
||||
default:
|
||||
msg := readErrorBody(resp)
|
||||
return fmt.Errorf("%w: 清理分片目录 %s → %d %s", ErrUnavailable, remote, resp.StatusCode, msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FileExists PROPFIND 探测存在性;非法路径按不存在处理。
|
||||
func (w *WebDAVStorage) FileExists(ctx context.Context, savePath string) (bool, error) {
|
||||
remote, err := w.joinRemote(savePath)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return w.remoteExists(ctx, remote)
|
||||
}
|
||||
|
||||
// PresignGetURL WebDAV 无预签名直链能力。
|
||||
func (w *WebDAVStorage) PresignGetURL(ctx context.Context, savePath string, expires int64) (string, error) {
|
||||
return "", ErrNotSupported
|
||||
}
|
||||
|
||||
// PresignPutURL WebDAV 无预签名直传能力。
|
||||
func (w *WebDAVStorage) PresignPutURL(ctx context.Context, savePath string, expires int64) (string, error) {
|
||||
return "", ErrNotSupported
|
||||
}
|
||||
|
||||
// HealthCheck 健康检查:PROPFIND 根目录;不存在时 MKCOL 创建(启动自愈)。
|
||||
// 同时完成凭据与连通性验证(do 内 401 协商)。
|
||||
func (w *WebDAVStorage) HealthCheck(ctx context.Context) error {
|
||||
exists, err := w.remoteExists(ctx, w.root)
|
||||
if err == nil && exists {
|
||||
w.markDir(w.root)
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, ErrNotFound) {
|
||||
return fmt.Errorf("%w: WebDAV 健康检查失败: %v", ErrUnavailable, err)
|
||||
}
|
||||
resp, err := w.do(ctx, "MKCOL", w.urlFor(w.root), requestOpts{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch resp.StatusCode {
|
||||
case http.StatusCreated, http.StatusOK, http.StatusNoContent, http.StatusMethodNotAllowed:
|
||||
drainClose(resp)
|
||||
w.markDir(w.root)
|
||||
return nil
|
||||
default:
|
||||
msg := readErrorBody(resp)
|
||||
return fmt.Errorf("%w: WebDAV 根目录创建失败 → %d %s", ErrUnavailable, resp.StatusCode, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 接口编译期断言。
|
||||
var _ Storage = (*WebDAVStorage)(nil)
|
||||
@@ -0,0 +1,244 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// authMode 认证模式(WebDAV 服务端挑战后自动协商)。
|
||||
type authMode int
|
||||
|
||||
const (
|
||||
authModeUnknown authMode = iota // 未定:先发 Basic 探测
|
||||
authModeBasic
|
||||
authModeDigest
|
||||
)
|
||||
|
||||
// authState WebDAV Basic/Digest 认证状态。
|
||||
//
|
||||
// 策略:
|
||||
// - 首个请求预置 Basic;若服务端 401 且挑战为 Digest,则解析挑战参数切换为 Digest;
|
||||
// - Digest 按 RFC 2617/7616 实现 qop=auth(MD5 / SHA-256,含 -sess 变体);
|
||||
// qop 缺失时回退 RFC 2069 旧式响应;
|
||||
// - nonce 变更时重置 nc 计数;nc/cnonce 在互斥锁内生成保证并发唯一。
|
||||
type authState struct {
|
||||
mu sync.Mutex
|
||||
username string
|
||||
password string
|
||||
mode authMode
|
||||
realm string
|
||||
nonce string
|
||||
qop string // 选定的 qop("auth" 或空 = RFC2069)
|
||||
opaque string
|
||||
algorithm string // MD5 | MD5-sess | SHA-256 | SHA-256-sess
|
||||
nc uint32
|
||||
knownBasicOK bool // 已确认 Basic 可用
|
||||
}
|
||||
|
||||
// newAuthState 构造认证状态(默认以 Basic 起步)。
|
||||
func newAuthState(username, password string) *authState {
|
||||
return &authState{username: username, password: password}
|
||||
}
|
||||
|
||||
// apply 为请求设置 Authorization 头(每次请求调用,Digest 时消耗一个 nc)。
|
||||
func (a *authState) apply(req *http.Request) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
switch {
|
||||
case a.mode == authModeDigest && a.nonce != "":
|
||||
req.Header.Set("Authorization", a.digestHeader(req))
|
||||
default:
|
||||
req.SetBasicAuth(a.username, a.password)
|
||||
}
|
||||
}
|
||||
|
||||
// digestHeader 依据缓存的挑战参数计算 Digest Authorization 头(调用方需持锁)。
|
||||
func (a *authState) digestHeader(req *http.Request) string {
|
||||
uri := req.URL.RequestURI()
|
||||
method := strings.ToUpper(req.Method)
|
||||
ncStr := fmt.Sprintf("%08x", a.nc+1)
|
||||
a.nc++
|
||||
cnonce := randomHex(8)
|
||||
|
||||
var ha1 string
|
||||
switch strings.ToLower(a.algorithm) {
|
||||
case "md5-sess":
|
||||
ha1 = hashHex("md5", hashHex("md5", a.username+":"+a.realm+":"+a.password)+":"+a.nonce+":"+cnonce)
|
||||
case "sha-256-sess":
|
||||
ha1 = hashHex("sha256", hashHex("sha256", a.username+":"+a.realm+":"+a.password)+":"+a.nonce+":"+cnonce)
|
||||
case "sha-256":
|
||||
ha1 = hashHex("sha256", a.username+":"+a.realm+":"+a.password)
|
||||
default: // md5
|
||||
ha1 = hashHex("md5", a.username+":"+a.realm+":"+a.password)
|
||||
}
|
||||
ha2 := hashHex(algoName(a.algorithm), method+":"+uri)
|
||||
|
||||
var response string
|
||||
var fields []string
|
||||
esc := escapeDigestValue(a.username)
|
||||
if a.qop == "" { // RFC 2069
|
||||
response = hashHex(algoName(a.algorithm), ha1+":"+a.nonce+":"+ha2)
|
||||
fields = append(fields,
|
||||
`Digest username="`+esc+`"`,
|
||||
`realm="`+escapeDigestValue(a.realm)+`"`,
|
||||
`nonce="`+escapeDigestValue(a.nonce)+`"`,
|
||||
`uri="`+escapeDigestValue(uri)+`"`,
|
||||
`response="`+response+`"`)
|
||||
} else {
|
||||
response = hashHex(algoName(a.algorithm), ha1+":"+a.nonce+":"+ncStr+":"+cnonce+":"+a.qop+":"+ha2)
|
||||
fields = append(fields,
|
||||
`Digest username="`+esc+`"`,
|
||||
`realm="`+escapeDigestValue(a.realm)+`"`,
|
||||
`nonce="`+escapeDigestValue(a.nonce)+`"`,
|
||||
`uri="`+escapeDigestValue(uri)+`"`,
|
||||
`cnonce="`+cnonce+`"`,
|
||||
`nc=`+ncStr,
|
||||
`qop=`+a.qop,
|
||||
`response="`+response+`"`,
|
||||
`algorithm=`+a.algorithm)
|
||||
}
|
||||
if a.opaque != "" {
|
||||
fields = append(fields, `opaque="`+escapeDigestValue(a.opaque)+`"`)
|
||||
}
|
||||
return strings.Join(fields, ", ")
|
||||
}
|
||||
|
||||
// challenge 处理 401 的 WWW-Authenticate 挑战;返回是否已切换认证方式可重试。
|
||||
// 返回 false 表示凭据错误或算法不受支持,调用方应直接报错。
|
||||
func (a *authState) challenge(header string) bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
h := strings.TrimSpace(header)
|
||||
lower := strings.ToLower(h)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "digest"):
|
||||
params := parseChallengeParams(strings.TrimPrefix(h[len("Digest"):], " "))
|
||||
algo := strings.ToUpper(strings.TrimSpace(params["algorithm"]))
|
||||
if algo == "" {
|
||||
algo = "MD5"
|
||||
}
|
||||
switch algo {
|
||||
case "MD5", "MD5-SESS", "SHA-256", "SHA-256-SESS":
|
||||
default:
|
||||
return false // 不支持的摘要算法
|
||||
}
|
||||
if params["nonce"] == "" || params["realm"] == "" {
|
||||
return false
|
||||
}
|
||||
qop := ""
|
||||
if raw := strings.TrimSpace(params["qop"]); raw != "" {
|
||||
for _, candidate := range strings.Split(raw, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(candidate), "auth") {
|
||||
qop = "auth"
|
||||
break
|
||||
}
|
||||
}
|
||||
if qop == "" {
|
||||
return false // 仅支持 auth-int 等需要 body 哈希的模式
|
||||
}
|
||||
}
|
||||
if a.nonce != params["nonce"] {
|
||||
a.nc = 0
|
||||
}
|
||||
a.realm, a.nonce, a.qop = params["realm"], params["nonce"], qop
|
||||
a.opaque, a.algorithm = params["opaque"], strings.ToLower(algo)
|
||||
a.mode = authModeDigest
|
||||
a.knownBasicOK = false
|
||||
return true
|
||||
case strings.HasPrefix(lower, "basic"):
|
||||
if a.knownBasicOK || a.mode == authModeBasic {
|
||||
return false // 已用 Basic 仍 401:凭据错误
|
||||
}
|
||||
a.mode = authModeBasic
|
||||
a.knownBasicOK = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseChallengeParams 解析 "realm=\"x\", nonce=\"y\"" 形式的挑战参数(引号内逗号不切分)。
|
||||
func parseChallengeParams(s string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, item := range splitAuthParams(s) {
|
||||
kv := strings.SplitN(item, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
k := strings.ToLower(strings.TrimSpace(kv[0]))
|
||||
v := strings.TrimSpace(kv[1])
|
||||
if len(v) >= 2 && strings.HasPrefix(v, `"`) && strings.HasSuffix(v, `"`) {
|
||||
v = v[1 : len(v)-1]
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// splitAuthParams 逗号切分但忽略引号内的逗号。
|
||||
func splitAuthParams(s string) []string {
|
||||
var parts []string
|
||||
var b strings.Builder
|
||||
inQuote := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c == '"':
|
||||
inQuote = !inQuote
|
||||
b.WriteByte(c)
|
||||
case c == ',' && !inQuote:
|
||||
if t := strings.TrimSpace(b.String()); t != "" {
|
||||
parts = append(parts, t)
|
||||
}
|
||||
b.Reset()
|
||||
default:
|
||||
b.WriteByte(c)
|
||||
}
|
||||
}
|
||||
if t := strings.TrimSpace(b.String()); t != "" {
|
||||
parts = append(parts, t)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// algoName 映射哈希函数名。
|
||||
func algoName(algorithm string) string {
|
||||
switch strings.ToLower(algorithm) {
|
||||
case "sha-256", "sha-256-sess":
|
||||
return "sha256"
|
||||
default:
|
||||
return "md5"
|
||||
}
|
||||
}
|
||||
|
||||
// hashHex 通用哈希摘要(algo: md5|sha256)。
|
||||
func hashHex(algo, s string) string {
|
||||
if algo == "sha256" {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
sum := md5.Sum([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// escapeDigestValue 转义引号。
|
||||
func escapeDigestValue(s string) string {
|
||||
return strings.ReplaceAll(s, `"`, `\"`)
|
||||
}
|
||||
|
||||
// randomHex 生成 n 字节随机 hex。
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// crypto/rand 失败极其罕见;退化为全零仍保持协议可用。
|
||||
for i := range b {
|
||||
b[i] = 0
|
||||
}
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,824 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---- 最小 WebDAV 假服务:PUT/GET/HEAD/PROPFIND/MKCOL/DELETE + Basic/Digest 认证 ----
|
||||
|
||||
type davLog struct {
|
||||
Method string
|
||||
Path string
|
||||
Status int
|
||||
}
|
||||
|
||||
type fakeDav struct {
|
||||
mu sync.Mutex
|
||||
dirs map[string]bool
|
||||
files map[string][]byte
|
||||
|
||||
// 认证配置:mode = none|basic|digest;digest 配合 algo = MD5|SHA-256。
|
||||
mode string
|
||||
username string
|
||||
password string
|
||||
realm string
|
||||
nonce string
|
||||
opaque string
|
||||
algo string
|
||||
|
||||
failNext map[string]int // method → 剩余 503 次数
|
||||
logs []davLog
|
||||
}
|
||||
|
||||
func newFakeDav(mode string) *fakeDav {
|
||||
return &fakeDav{
|
||||
dirs: map[string]bool{},
|
||||
files: map[string][]byte{},
|
||||
mode: mode,
|
||||
username: "fcb",
|
||||
password: "fcb-pass",
|
||||
realm: "test-realm",
|
||||
nonce: "dcd98b7102dd2f0e8b11d0f600bfb0c0",
|
||||
opaque: "5ccc069c403ebaf9f0171e9517f40e41",
|
||||
algo: "MD5",
|
||||
failNext: map[string]int{},
|
||||
}
|
||||
}
|
||||
|
||||
// auth 校验请求凭据;失败时写出 401 与对应挑战。
|
||||
func (f *fakeDav) auth(w http.ResponseWriter, r *http.Request) bool {
|
||||
if f.mode == "none" {
|
||||
return true
|
||||
}
|
||||
h := r.Header.Get("Authorization")
|
||||
ok := false
|
||||
switch f.mode {
|
||||
case "basic":
|
||||
ok = h == "Basic "+basicAuth(f.username, f.password)
|
||||
case "digest":
|
||||
ok = f.checkDigest(r)
|
||||
}
|
||||
if ok {
|
||||
return true
|
||||
}
|
||||
switch f.mode {
|
||||
case "basic":
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="`+f.realm+`"`)
|
||||
case "digest":
|
||||
w.Header().Set("WWW-Authenticate", fmt.Sprintf(
|
||||
`Digest realm="%s", qop="auth", nonce="%s", opaque="%s", algorithm=%s, stale=false`,
|
||||
f.realm, f.nonce, f.opaque, f.algo))
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
|
||||
// checkDigest 服务端重算 RFC 2617 摘要响应。
|
||||
func (f *fakeDav) checkDigest(r *http.Request) bool {
|
||||
h := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(h, "Digest ") {
|
||||
return false
|
||||
}
|
||||
p := parseChallengeParams(strings.TrimSpace(h[len("Digest "):]))
|
||||
ha1 := hashHex(algoName(f.algo), f.username+":"+f.realm+":"+f.password)
|
||||
ha2 := hashHex(algoName(f.algo), strings.ToUpper(r.Method)+":"+r.URL.RequestURI())
|
||||
got := hashHex(algoName(f.algo), ha1+":"+f.nonce+":"+p["nc"]+":"+p["cnonce"]+":"+p["qop"]+":"+ha2)
|
||||
return p["username"] == f.username && p["response"] == got
|
||||
}
|
||||
|
||||
func basicAuth(user, pass string) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(user + ":" + pass))
|
||||
}
|
||||
|
||||
func (f *fakeDav) record(method, path string, status int) {
|
||||
f.logs = append(f.logs, davLog{Method: method, Path: path, Status: status})
|
||||
}
|
||||
|
||||
// maybeFail 命中失败注入时返回 true(已写出 503)。
|
||||
func (f *fakeDav) maybeFail(w http.ResponseWriter, method string) bool {
|
||||
if f.failNext[method] > 0 {
|
||||
f.failNext[method]--
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (f *fakeDav) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if !f.auth(w, r) {
|
||||
f.record(r.Method, r.URL.Path, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
p := strings.Trim(r.URL.Path, "/")
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
if f.maybeFail(w, "PUT") {
|
||||
f.record(r.Method, p, 503)
|
||||
return
|
||||
}
|
||||
if parent := parentOf(p); parent != "" && !f.dirs[parent] {
|
||||
w.WriteHeader(http.StatusConflict) // 强制客户端先建目录
|
||||
f.record(r.Method, p, 409)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
f.files[p] = body
|
||||
f.record(r.Method, p, 201)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
case http.MethodGet:
|
||||
if f.maybeFail(w, "GET") {
|
||||
f.record(r.Method, p, 503)
|
||||
return
|
||||
}
|
||||
data, ok := f.files[p]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
f.record(r.Method, p, 404)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
if rng := r.Header.Get("Range"); rng != "" {
|
||||
start, end := int64(0), int64(len(data))-1
|
||||
spec := strings.TrimPrefix(rng, "bytes=")
|
||||
if strings.HasSuffix(spec, "-") { // bytes=N- → 到文件尾
|
||||
if s, err := strconv.ParseInt(strings.TrimSuffix(spec, "-"), 10, 64); err == nil {
|
||||
start = s
|
||||
}
|
||||
} else if _, err := fmt.Sscanf(spec, "%d-%d", &start, &end); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
f.record(r.Method, p, 400)
|
||||
return
|
||||
}
|
||||
if start < 0 || start >= int64(len(data)) {
|
||||
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
||||
f.record(r.Method, p, 416)
|
||||
return
|
||||
}
|
||||
if end >= int64(len(data)) {
|
||||
end = int64(len(data)) - 1
|
||||
}
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(data)))
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = w.Write(data[start : end+1])
|
||||
f.record(r.Method, p, 206)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
f.record(r.Method, p, 200)
|
||||
case http.MethodHead:
|
||||
data, ok := f.files[p]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
f.record(r.Method, p, 404)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
f.record(r.Method, p, 200)
|
||||
case "PROPFIND":
|
||||
depth := r.Header.Get("Depth")
|
||||
self, isDirSelf := f.stat(p)
|
||||
if !isDirSelf {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
f.record(r.Method, p, 404)
|
||||
return
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="utf-8"?>` +
|
||||
`<D:multistatus xmlns:D="DAV:">`)
|
||||
f.writeResponse(&b, p, self)
|
||||
if depth == "1" && self.isDir {
|
||||
for _, name := range f.children(p) {
|
||||
child := name
|
||||
cs, cd := f.stat(child)
|
||||
f.writeResponse(&b, child, davStat{isDir: cd, size: cs.size})
|
||||
}
|
||||
}
|
||||
b.WriteString(`</D:multistatus>`)
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
_, _ = w.Write([]byte(b.String()))
|
||||
f.record(r.Method, p, 207)
|
||||
case "MKCOL":
|
||||
if f.dirs[p] || f.files[p] != nil {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed) // 已存在
|
||||
f.record(r.Method, p, 405)
|
||||
return
|
||||
}
|
||||
if parent := parentOf(p); parent != "" && !f.dirs[parent] {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
f.record(r.Method, p, 409)
|
||||
return
|
||||
}
|
||||
f.dirs[p] = true
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
f.record(r.Method, p, 201)
|
||||
case http.MethodDelete:
|
||||
if _, ok := f.files[p]; ok {
|
||||
delete(f.files, p)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
f.record(r.Method, p, 204)
|
||||
return
|
||||
}
|
||||
if f.dirs[p] {
|
||||
// 递归删除目录
|
||||
prefix := p + "/"
|
||||
for name := range f.files {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
delete(f.files, name)
|
||||
}
|
||||
}
|
||||
for name := range f.dirs {
|
||||
if name == p || strings.HasPrefix(name+"/", prefix) {
|
||||
delete(f.dirs, name)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
f.record(r.Method, p, 204)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
f.record(r.Method, p, 404)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
f.record(r.Method, p, 405)
|
||||
}
|
||||
}
|
||||
|
||||
type davStat struct {
|
||||
isDir bool
|
||||
size int
|
||||
}
|
||||
|
||||
func (f *fakeDav) stat(p string) (davStat, bool) {
|
||||
if data, ok := f.files[p]; ok {
|
||||
return davStat{size: len(data)}, true
|
||||
}
|
||||
if f.dirs[p] {
|
||||
return davStat{isDir: true}, true
|
||||
}
|
||||
return davStat{}, false
|
||||
}
|
||||
|
||||
func (f *fakeDav) children(p string) []string {
|
||||
var out []string
|
||||
prefix := p + "/"
|
||||
for name := range f.files {
|
||||
if strings.HasPrefix(name, prefix) && !strings.Contains(strings.TrimPrefix(name, prefix), "/") {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
for name := range f.dirs {
|
||||
if strings.HasPrefix(name, prefix) && !strings.Contains(strings.TrimPrefix(name, prefix), "/") {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *fakeDav) writeResponse(b *strings.Builder, href string, st davStat) {
|
||||
b.WriteString(`<D:response><D:href>/` + href + `</D:href><D:propstat><D:prop><D:resourcetype>`)
|
||||
if st.isDir {
|
||||
b.WriteString(`<D:collection/>`)
|
||||
}
|
||||
b.WriteString(`</D:resourcetype><D:getcontentlength>` + strconv.Itoa(st.size) +
|
||||
`</D:getcontentlength><D:getcontenttype>application/octet-stream</D:getcontenttype>` +
|
||||
`</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>`)
|
||||
}
|
||||
|
||||
func parentOf(p string) string {
|
||||
if i := strings.LastIndex(p, "/"); i > 0 {
|
||||
return p[:i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// newTestDav 构造 WebDAV 引擎 + 假服务。
|
||||
func newTestDav(t *testing.T, mode string, tweak func(o *WebDAVOptions)) (*WebDAVStorage, *fakeDav, *int32) {
|
||||
t.Helper()
|
||||
f := newFakeDav(mode)
|
||||
var conns int32
|
||||
srv := httptest.NewUnstartedServer(f)
|
||||
srv.Config.ConnState = func(c net.Conn, cs http.ConnState) {
|
||||
if cs == http.StateNew {
|
||||
atomic.AddInt32(&conns, 1)
|
||||
}
|
||||
}
|
||||
srv.Start()
|
||||
t.Cleanup(srv.Close)
|
||||
opts := WebDAVOptions{
|
||||
BaseURL: srv.URL,
|
||||
Username: f.username,
|
||||
Password: f.password,
|
||||
RootPath: "fcb_root",
|
||||
MaxRetries: 3,
|
||||
}
|
||||
if tweak != nil {
|
||||
tweak(&opts)
|
||||
}
|
||||
st, err := NewWebDAVStorage(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("NewWebDAVStorage: %v", err)
|
||||
}
|
||||
return st, f, &conns
|
||||
}
|
||||
|
||||
// TestWebDAVBasicCRUD Basic 认证下的完整 CRUD 与 Range。
|
||||
func TestWebDAVBasicCRUD(t *testing.T) {
|
||||
st, f, _ := newTestDav(t, "basic", nil)
|
||||
ctx := context.Background()
|
||||
|
||||
// 健康检查:根目录 404 → MKCOL 自建
|
||||
if err := st.HealthCheck(ctx); err != nil {
|
||||
t.Fatalf("HealthCheck: %v", err)
|
||||
}
|
||||
if !f.dirs["fcb_root"] {
|
||||
t.Fatalf("根目录应被自动创建")
|
||||
}
|
||||
|
||||
data := []byte("WebDAV 引擎数据 0123456789 ABCDEF")
|
||||
n, err := st.SaveFile(ctx, bytes.NewReader(data), "2025/08/w.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveFile: %v", err)
|
||||
}
|
||||
if n != int64(len(data)) {
|
||||
t.Fatalf("n = %d", n)
|
||||
}
|
||||
if string(f.files["fcb_root/2025/08/w.bin"]) != string(data) {
|
||||
t.Fatalf("PUT 内容不匹配")
|
||||
}
|
||||
// 按需建目录:两级目录都应已创建
|
||||
if !f.dirs["fcb_root/2025"] || !f.dirs["fcb_root/2025/08"] {
|
||||
t.Fatalf("目录未按需创建: %v %v", f.dirs["fcb_root/2025"], f.dirs["fcb_root/2025/08"])
|
||||
}
|
||||
|
||||
meta, err := st.Stat(ctx, "2025/08/w.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("Stat: %v", err)
|
||||
}
|
||||
if meta.Size != int64(len(data)) || !meta.AcceptRanges {
|
||||
t.Fatalf("Stat = %+v", meta)
|
||||
}
|
||||
if ok, _ := st.FileExists(ctx, "2025/08/w.bin"); !ok {
|
||||
t.Fatalf("FileExists 应为 true")
|
||||
}
|
||||
|
||||
// 完整下载(对齐 go-api 约定)
|
||||
dl, err := st.Open(ctx, "2025/08/w.bin", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
got, err := io.ReadAll(dl)
|
||||
_ = dl.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Fatalf("full mismatch")
|
||||
}
|
||||
if dl.Start != 0 || dl.End != int64(len(data))-1 || dl.Total != int64(len(data)) {
|
||||
t.Fatalf("full offsets = %d,%d,%d", dl.Start, dl.End, dl.Total)
|
||||
}
|
||||
|
||||
// Range 下载
|
||||
dl, err = st.Open(ctx, "2025/08/w.bin", &Range{Start: 2, End: 7})
|
||||
if err != nil {
|
||||
t.Fatalf("Open range: %v", err)
|
||||
}
|
||||
got, err = io.ReadAll(dl)
|
||||
_ = dl.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("read range: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, data[2:8]) {
|
||||
t.Fatalf("range mismatch")
|
||||
}
|
||||
if dl.Start != 2 || dl.End != 7 || dl.Total != int64(len(data)) {
|
||||
t.Fatalf("range offsets = %d,%d,%d", dl.Start, dl.End, dl.Total)
|
||||
}
|
||||
|
||||
// 416(起点越界)/ 404
|
||||
if _, err := st.Open(ctx, "2025/08/w.bin", &Range{Start: int64(len(data)) + 9, End: -1}); err == nil ||
|
||||
!strings.Contains(err.Error(), ErrRangeNotSatisfiable.Error()) {
|
||||
t.Fatalf("want ErrRangeNotSatisfiable, got %v", err)
|
||||
}
|
||||
if _, err := st.Open(ctx, "no/such.bin", nil); err == nil ||
|
||||
!strings.Contains(err.Error(), ErrNotFound.Error()) {
|
||||
t.Fatalf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
// 删除 + 空父目录清理
|
||||
if err := st.DeleteFile(ctx, "2025/08/w.bin"); err != nil {
|
||||
t.Fatalf("DeleteFile: %v", err)
|
||||
}
|
||||
if ok, _ := st.FileExists(ctx, "2025/08/w.bin"); ok {
|
||||
t.Fatalf("删除后仍存在")
|
||||
}
|
||||
if _, err := st.Stat(ctx, "2025/08/w.bin"); err == nil ||
|
||||
!strings.Contains(err.Error(), ErrNotFound.Error()) {
|
||||
t.Fatalf("Stat 应 ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebDAVDigestAuth Digest(MD5)认证协商。
|
||||
func TestWebDAVDigestAuth(t *testing.T) {
|
||||
st, f, _ := newTestDav(t, "digest", nil)
|
||||
ctx := context.Background()
|
||||
if err := st.HealthCheck(ctx); err != nil {
|
||||
t.Fatalf("HealthCheck(digest): %v", err)
|
||||
}
|
||||
// HealthCheck 流程应观察到 401 挑战(客户端先 Basic 探测 → 401 → Digest 重试)
|
||||
saw401 := false
|
||||
for _, l := range f.logs {
|
||||
if l.Status == 401 {
|
||||
saw401 = true
|
||||
}
|
||||
}
|
||||
if !saw401 {
|
||||
t.Fatalf("未观察到 401 挑战: %+v", f.logs)
|
||||
}
|
||||
|
||||
// 认证后的 PROPFIND(Stat 已有目录)应得到 207
|
||||
if _, err := st.Stat(ctx, ""); err == nil {
|
||||
// Stat("") 非法路径属预期;这里换用 FileExists 对已有根目录探测
|
||||
_ = err
|
||||
}
|
||||
data := []byte("digest 内容")
|
||||
if _, err := st.SaveFile(ctx, bytes.NewReader(data), "d.bin"); err != nil {
|
||||
t.Fatalf("SaveFile(digest): %v", err)
|
||||
}
|
||||
dl, err := st.Open(ctx, "d.bin", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open(digest): %v", err)
|
||||
}
|
||||
got, _ := io.ReadAll(dl)
|
||||
_ = dl.Close()
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Fatalf("digest 下载内容不匹配")
|
||||
}
|
||||
// 全链路完成:确认存在成功的 2xx/207 请求
|
||||
saw2xx := false
|
||||
for _, l := range f.logs {
|
||||
if l.Status == 207 || l.Status == 201 || l.Status == 200 {
|
||||
saw2xx = true
|
||||
}
|
||||
}
|
||||
if !saw2xx {
|
||||
t.Fatalf("认证后应有成功请求: %+v", f.logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebDAVDigestSHA256 Digest(SHA-256)算法。
|
||||
func TestWebDAVDigestSHA256(t *testing.T) {
|
||||
st, _, _ := newTestDav(t, "digest", nil)
|
||||
st.auth.mu.Lock()
|
||||
st.auth.algorithm = "sha-256"
|
||||
st.auth.mu.Unlock()
|
||||
// 服务端也切换到 SHA-256 重算摘要
|
||||
st2, f, _ := newTestDav(t, "digest", nil)
|
||||
f.algo = "SHA-256"
|
||||
// 先让客户端完成一次 MD5 协商拿到挑战参数,再切 SHA-256 会 401 失败——
|
||||
// 因此这里直接对 SHA-256 服务端做完整链路(client 首次探测 Basic→401→Digest)。
|
||||
_ = st
|
||||
ctx := context.Background()
|
||||
if err := st2.HealthCheck(ctx); err != nil {
|
||||
t.Fatalf("HealthCheck(SHA-256): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebDAVDigestWrongPassword 凭据错误 → 明确报错而非重试风暴。
|
||||
func TestWebDAVDigestWrongPassword(t *testing.T) {
|
||||
f := newFakeDav("digest")
|
||||
srv := httptest.NewServer(f)
|
||||
defer srv.Close()
|
||||
st, err := NewWebDAVStorage(WebDAVOptions{
|
||||
BaseURL: srv.URL, Username: f.username, Password: "WRONG",
|
||||
RootPath: "r", MaxRetries: 1, BaseBackoff: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.HealthCheck(context.Background()); err == nil ||
|
||||
!strings.Contains(err.Error(), "401") {
|
||||
t.Fatalf("错误凭据应报 401 相关错误, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebDAVRetryGet 5xx 指数退避重试(GET 幂等)。
|
||||
func TestWebDAVRetryGet(t *testing.T) {
|
||||
st, f, _ := newTestDav(t, "basic", func(o *WebDAVOptions) { o.BaseBackoff = 5 })
|
||||
ctx := context.Background()
|
||||
data := []byte("retry target")
|
||||
if err := st.HealthCheck(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.SaveFile(ctx, bytes.NewReader(data), "r.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.failNext["GET"] = 2
|
||||
f.mu.Unlock()
|
||||
dl, err := st.Open(ctx, "r.bin", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("503×2 后应重试成功: %v", err)
|
||||
}
|
||||
got, _ := io.ReadAll(dl)
|
||||
_ = dl.Close()
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Fatalf("content mismatch")
|
||||
}
|
||||
// 验证确实发了 3 次 GET
|
||||
gets := 0
|
||||
for _, l := range f.logs {
|
||||
if l.Method == "GET" && strings.HasSuffix(l.Path, "r.bin") {
|
||||
gets++
|
||||
}
|
||||
}
|
||||
if gets != 3 {
|
||||
t.Fatalf("GET 次数 = %d, want 3", gets)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebDAVRetryPut 可重放 body(seekable)PUT 失败重试;不可重放不重试。
|
||||
func TestWebDAVRetryPut(t *testing.T) {
|
||||
st, f, _ := newTestDav(t, "basic", func(o *WebDAVOptions) { o.BaseBackoff = 5 })
|
||||
ctx := context.Background()
|
||||
if err := st.HealthCheck(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// seekable:重试成功
|
||||
f.mu.Lock()
|
||||
f.failNext["PUT"] = 1
|
||||
f.mu.Unlock()
|
||||
data := []byte("put with retry")
|
||||
if _, err := st.SaveFile(ctx, bytes.NewReader(data), "pr.bin"); err != nil {
|
||||
t.Fatalf("PUT 重试应成功: %v", err)
|
||||
}
|
||||
puts := 0
|
||||
for _, l := range f.logs {
|
||||
if l.Method == "PUT" && strings.HasSuffix(l.Path, "pr.bin") {
|
||||
puts++
|
||||
}
|
||||
}
|
||||
if puts != 2 {
|
||||
t.Fatalf("PUT 次数 = %d, want 2", puts)
|
||||
}
|
||||
// 非 seekable(io.Pipe):不重试,直接失败
|
||||
f.mu.Lock()
|
||||
f.failNext["PUT"] = 1
|
||||
f.mu.Unlock()
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
_, _ = pw.Write([]byte("non-seekable"))
|
||||
_ = pw.Close()
|
||||
}()
|
||||
if _, err := st.SaveFile(ctx, pr, "ns.bin"); err == nil {
|
||||
t.Fatalf("非重放 PUT 注入 503 应失败")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebDAVConnectionReuse 连接复用:多次请求不应各建一条 TCP 连接。
|
||||
func TestWebDAVConnectionReuse(t *testing.T) {
|
||||
st, _, conns := newTestDav(t, "basic", nil)
|
||||
ctx := context.Background()
|
||||
if err := st.HealthCheck(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 12; i++ {
|
||||
if _, err := st.SaveFile(ctx, bytes.NewReader([]byte("x")), fmt.Sprintf("reuse/%d.bin", i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.Stat(ctx, fmt.Sprintf("reuse/%d.bin", i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 25 次请求(12 PUT + 12 PROPFIND + 1 HealthCheck 的 PROPFIND/MKCOL)只允许极少量新连接
|
||||
if got := atomic.LoadInt32(conns); got > 4 {
|
||||
t.Fatalf("新建 TCP 连接数 = %d,连接复用失效(应 ≤4)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebDAVPipeStreaming io.Pipe 流式转发:完整读取 + 提前关闭。
|
||||
func TestWebDAVPipeStreaming(t *testing.T) {
|
||||
st, _, _ := newTestDav(t, "basic", nil)
|
||||
ctx := context.Background()
|
||||
if err := st.HealthCheck(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
big := bytes.Repeat([]byte("0123456789abcdef"), 4096) // 64KB
|
||||
if _, err := st.SaveFile(ctx, bytes.NewReader(big), "big.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dl, err := st.Open(ctx, "big.bin", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := io.ReadAll(dl)
|
||||
if err != nil {
|
||||
t.Fatalf("read pipe: %v", err)
|
||||
}
|
||||
_ = dl.Close()
|
||||
if !bytes.Equal(got, big) {
|
||||
t.Fatalf("pipe content mismatch")
|
||||
}
|
||||
|
||||
// 提前关闭:后续读取返回错误且不挂死
|
||||
dl2, err := st.Open(ctx, "big.bin", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buf := make([]byte, 10)
|
||||
if _, err := io.ReadFull(dl2, buf); err != nil {
|
||||
t.Fatalf("read head: %v", err)
|
||||
}
|
||||
if err := dl2.Close(); err != nil {
|
||||
t.Fatalf("early close: %v", err)
|
||||
}
|
||||
// ctx 取消同样会终止流
|
||||
cctx, cancel := context.WithCancel(context.Background())
|
||||
dl3, err := st.Open(cctx, "big.bin", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cancel()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
_, err = dl3.Read(buf)
|
||||
if err == nil {
|
||||
_ = dl3.Close()
|
||||
t.Fatalf("ctx 取消后读取应报错")
|
||||
}
|
||||
_ = dl3.Close()
|
||||
}
|
||||
|
||||
// TestWebDAVChunkMerge 分片保存/合并/清理。
|
||||
func TestWebDAVChunkMerge(t *testing.T) {
|
||||
st, f, _ := newTestDav(t, "basic", nil)
|
||||
ctx := context.Background()
|
||||
if err := st.HealthCheck(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
savePath := "2025/09/merged.bin"
|
||||
uploadID := "uid-webdav"
|
||||
chunks := [][]byte{[]byte("AAA"), []byte("BB"), []byte("CCCC")}
|
||||
hashes := make([]string, len(chunks))
|
||||
for i, c := range chunks {
|
||||
n, err := st.SaveChunk(ctx, uploadID, i, bytes.NewReader(c), savePath)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveChunk %d: %v", i, err)
|
||||
}
|
||||
if n != int64(len(c)) {
|
||||
t.Fatalf("chunk %d size = %d", i, n)
|
||||
}
|
||||
hashes[i] = sha256Hex(c)
|
||||
}
|
||||
size, fileHash, err := st.MergeChunks(ctx, uploadID, len(chunks), func(i int) (string, error) {
|
||||
return hashes[i], nil
|
||||
}, savePath)
|
||||
if err != nil {
|
||||
t.Fatalf("MergeChunks: %v", err)
|
||||
}
|
||||
if size != 9 || fileHash != sha256Hex(bytes.Join(chunks, nil)) {
|
||||
t.Fatalf("merge result = %d %s", size, fileHash)
|
||||
}
|
||||
if string(f.files["fcb_root/"+savePath]) != "AAABBCCCC" {
|
||||
t.Fatalf("合并内容错误: %q", f.files["fcb_root/"+savePath])
|
||||
}
|
||||
// 分片目录已清理
|
||||
for k := range f.files {
|
||||
if strings.Contains(k, "chunks/"+uploadID) {
|
||||
t.Fatalf("分片残留: %s", k)
|
||||
}
|
||||
}
|
||||
if f.dirs["fcb_root/2025/09/chunks/"+uploadID] {
|
||||
t.Fatalf("分片目录残留")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebDAVCleanChunks 清理与哈希失败路径。
|
||||
func TestWebDAVCleanChunks(t *testing.T) {
|
||||
st, f, _ := newTestDav(t, "basic", nil)
|
||||
ctx := context.Background()
|
||||
if err := st.HealthCheck(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := st.SaveChunk(ctx, "uidc", i, strings.NewReader("z"), "c.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := st.CleanChunks(ctx, "uidc", "c.bin"); err != nil {
|
||||
t.Fatalf("CleanChunks: %v", err)
|
||||
}
|
||||
if len(f.files) != 0 {
|
||||
t.Fatalf("分片未清理: %v", f.files)
|
||||
}
|
||||
// 幂等
|
||||
if err := st.CleanChunks(ctx, "uidc", "c.bin"); err != nil {
|
||||
t.Fatalf("CleanChunks idempotent: %v", err)
|
||||
}
|
||||
// 哈希不匹配
|
||||
if _, err := st.SaveChunk(ctx, "uidm", 0, strings.NewReader("real"), "m.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := st.MergeChunks(ctx, "uidm", 1, func(i int) (string, error) {
|
||||
return sha256Hex([]byte("wrong")), nil
|
||||
}, "m.bin"); err == nil || !strings.Contains(err.Error(), ErrHashMismatch.Error()) {
|
||||
t.Fatalf("want ErrHashMismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebDAVTimeout 非流式操作超时:PROPFIND 响应慢于 Timeout(1s)→ context deadline exceeded。
|
||||
func TestWebDAVTimeout(t *testing.T) {
|
||||
f := newFakeDav("basic")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "PROPFIND" {
|
||||
time.Sleep(1500 * time.Millisecond) // > Timeout 1s
|
||||
}
|
||||
f.ServeHTTP(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
st, err := NewWebDAVStorage(WebDAVOptions{
|
||||
BaseURL: srv.URL, Username: f.username, Password: f.password,
|
||||
RootPath: "r", MaxRetries: 0, Timeout: 1, BaseBackoff: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Now()
|
||||
err = st.HealthCheck(context.Background())
|
||||
if err == nil {
|
||||
t.Fatalf("超时应报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "context deadline exceeded") {
|
||||
t.Fatalf("应为超时错误, got %v", err)
|
||||
}
|
||||
// 单次尝试 1s 超时 + 一次重试 ≈ 2s;若超时未生效会拖满 2×1.5s
|
||||
if elapsed := time.Since(start); elapsed > 3500*time.Millisecond {
|
||||
t.Fatalf("超时未生效(耗时 %v)", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebDAVPresignNotSupported 预签名 → ErrNotSupported。
|
||||
func TestWebDAVPresignNotSupported(t *testing.T) {
|
||||
st, _, _ := newTestDav(t, "basic", nil)
|
||||
ctx := context.Background()
|
||||
if _, err := st.PresignGetURL(ctx, "x.bin", 60); err == nil ||
|
||||
!strings.Contains(err.Error(), ErrNotSupported.Error()) {
|
||||
t.Fatalf("want ErrNotSupported, got %v", err)
|
||||
}
|
||||
if _, err := st.PresignPutURL(ctx, "x.bin", 60); err == nil ||
|
||||
!strings.Contains(err.Error(), ErrNotSupported.Error()) {
|
||||
t.Fatalf("want ErrNotSupported, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebDAVFactoryRegistry 工厂构造 + Digest 全链路。
|
||||
func TestWebDAVFactoryRegistry(t *testing.T) {
|
||||
f := newFakeDav("digest")
|
||||
srv := httptest.NewServer(f)
|
||||
defer srv.Close()
|
||||
prev := engineOptions.WebDAV
|
||||
engineOptions.WebDAV = WebDAVOptions{
|
||||
BaseURL: srv.URL, Username: f.username, Password: f.password,
|
||||
RootPath: "factory_root", MaxRetries: 3, BaseBackoff: 5,
|
||||
}
|
||||
defer func() { engineOptions.WebDAV = prev }()
|
||||
st, err := NewEngine(context.Background(), "webdav")
|
||||
if err != nil {
|
||||
t.Fatalf("NewEngine(webdav): %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := st.HealthCheck(ctx); err != nil {
|
||||
t.Fatalf("HealthCheck: %v", err)
|
||||
}
|
||||
if _, err := st.SaveFile(ctx, strings.NewReader("factory"), "f.txt"); err != nil {
|
||||
t.Fatalf("SaveFile: %v", err)
|
||||
}
|
||||
dl, err := st.Open(ctx, "f.txt", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
got, _ := io.ReadAll(dl)
|
||||
_ = dl.Close()
|
||||
if string(got) != "factory" {
|
||||
t.Fatalf("content = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
.filter-bar[data-v-dcdef1a5]{display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;background:var(--glass-bg-soft);border:1px solid var(--glass-border);border-radius:var(--radius);padding:14px 16px;margin-bottom:16px}.filter-bar label[data-v-dcdef1a5]{display:flex;flex-direction:column;gap:4px;font-size:12.5px;color:var(--c-text-2);font-weight:600}.filter-bar .select[data-v-dcdef1a5],.filter-bar .input[data-v-dcdef1a5]{min-width:130px}.ip-cell[data-v-dcdef1a5]{font-family:var(--mono);font-size:12.5px}.ua-cell[data-v-dcdef1a5]{max-width:260px;overflow:hidden;text-overflow:ellipsis;color:var(--c-text-3)}
|
||||
@@ -0,0 +1 @@
|
||||
.login-head[data-v-26e7f2a4]{text-align:center;margin-bottom:18px}.login-logo[data-v-26e7f2a4]{width:48px;height:48px;object-fit:contain;margin-bottom:8px}
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user