Compare commits
14
Commits
b5f06d09b3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a9015aad0 | ||
|
|
84df9996cb | ||
|
|
27432218c4 | ||
|
|
00a8c16ab7 | ||
|
|
113c514531 | ||
|
|
2196ee20a2 | ||
|
|
f050944866 | ||
|
|
5bc7d5ee20 | ||
|
|
2b37d46e83 | ||
|
|
9931db2670 | ||
|
|
46319cc99e | ||
|
|
15b2bdf9da | ||
|
|
27b8b75136 | ||
|
|
6f1a925833 |
@@ -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
|
||||
@@ -1,102 +0,0 @@
|
||||
name: Release 镜像
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: registry.cn-hangzhou.aliyuncs.com
|
||||
IMAGE: registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: 测试(推送前置门禁)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: golang:1.27.1-alpine
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: 安装工具并检出
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
apk add --no-cache git curl bash >/dev/null
|
||||
git clone --depth=1 --branch "$GITHUB_REF_NAME" \
|
||||
"https://oauth2:${GITHUB_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" .
|
||||
|
||||
- name: go vet + go test
|
||||
working-directory: server
|
||||
env:
|
||||
GOCACHE: /tmp/.gocache
|
||||
GOMODCACHE: /tmp/.gomodcache
|
||||
CGO_ENABLED: "0"
|
||||
run: |
|
||||
go vet ./...
|
||||
go test ./... -count=1
|
||||
|
||||
build-push:
|
||||
name: 多架构构建并推送 ACR
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: 安装工具并检出
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
apk add --no-cache git curl bash >/dev/null
|
||||
git clone --depth=1 --branch "$GITHUB_REF_NAME" \
|
||||
"https://oauth2:${GITHUB_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" .
|
||||
# 嵌入产物同步(保证二进制内前端与仓库一致)
|
||||
if [ -f web-embed/index.html ]; then :; fi
|
||||
|
||||
- name: 安装 Docker CLI(挂宿主 daemon)
|
||||
run: apk add --no-cache docker-cli buildx >/dev/null
|
||||
|
||||
- name: 计算 tag 与平台
|
||||
id: meta
|
||||
env:
|
||||
REF: ${{ gitea.ref }}
|
||||
run: |
|
||||
case "$REF" in
|
||||
refs/tags/v*)
|
||||
VER="${REF#refs/tags/v}"
|
||||
echo "tags=${IMAGE}:${VER} ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "发布 tag: ${VER} + latest" ;;
|
||||
*)
|
||||
echo "tags=${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "main 构建: latest" ;;
|
||||
esac
|
||||
|
||||
- name: 登录阿里云 ACR
|
||||
env:
|
||||
ACR_USER: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASS: ${{ secrets.ACR_PASSWORD }}
|
||||
run: |
|
||||
PASS_LEN=${#ACR_PASS}
|
||||
echo "ACR 用户: $ACR_USER (密码 ${PASS_LEN} 位)"
|
||||
printf '%s' "$ACR_PASS" | docker login "$REGISTRY" -u "$ACR_USER" --password-stdin
|
||||
|
||||
- name: 多架构构建并推送
|
||||
env:
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
# provenance/sbom 必须关:阿里云 ACR 不识别 OCI empty manifest(attestation),
|
||||
# 否则报 "denied: unknown manifest class for application/vnd.oci.empty.v1+json"
|
||||
run: |
|
||||
ARGS=""
|
||||
for t in $TAGS; do ARGS="$ARGS -t $t"; done
|
||||
docker buildx build \
|
||||
--builder default \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--provenance=false --sbom=false \
|
||||
--push \
|
||||
-f deploy/Dockerfile \
|
||||
$ARGS \
|
||||
.
|
||||
|
||||
- name: 校验远程 manifest(双架构)
|
||||
run: docker buildx imagetools inspect "${IMAGE}:latest" | grep -E "linux/amd64|linux/arm64"
|
||||
@@ -35,3 +35,4 @@ deploy/.env
|
||||
|
||||
# 会话/协作目录(工具私有,非项目源码)
|
||||
.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
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
文件快传
|
||||
|
||||
数据库**默认 SQLite 零依赖**(modernc.org/sqlite 纯 Go 驱动,数据文件 `./data/filecodebox.db`),
|
||||
数据库**默认 SQLite 零依赖**(modernc.org/sqlite 纯 Go 驱动,数据文件 `./data/fileshare.db`),
|
||||
可选切换 Postgres(`FCB_DB_DRIVER=postgres` + DSN);Redis 为**可选**增强(未配置时自动降级为进程内存缓存)。
|
||||
存储引擎支持 **本地 / S3 / WebDAV**(运行时热切换,健康检查通过才生效;WebDAV 重点优化:流式、Range、重试、连接复用)。
|
||||
26.9 起支持**上下行带宽限速**(`upload_rate` / `download_rate` 字节/秒,0=不限速,管理端改后立即生效)。
|
||||
|
||||
**26.9 新增**:上下行带宽限速(见 [专题](docs/api/13-bandwidth.md))· 站点对外域名、自定义提取码(26.9)·
|
||||
本地/S3/WebDAV 存储引擎 26.9 运行时热切换。
|
||||
|
||||
| 目录 | 说明 |
|
||||
|---|---|
|
||||
@@ -17,7 +21,7 @@
|
||||
## 默认 Logo 与自定义
|
||||
|
||||
- 页面导航 Logo:本地资源 `web/src/assets/brand/logo.svg`
|
||||
- favicon / 备用 Logo:本地资源 `web/src/assets/brand/favicon.png`(v2 起不再使用远程 URL 默认值)
|
||||
- favicon / 备用 Logo:本地资源 `web/src/assets/brand/favicon.png`(26.9 起不再使用远程 URL 默认值)
|
||||
|
||||
管理端自定义 Logo 三步:
|
||||
|
||||
@@ -37,7 +41,7 @@ docker compose up -d --build
|
||||
```
|
||||
|
||||
- **数据库双路径**:默认 SQLite 零依赖——`docker compose up -d --build` 即可(无需 postgres profile,
|
||||
数据落 `serverdata` 卷 `/app/data/filecodebox.db`);Postgres 模式——`.env` 设
|
||||
数据落 `serverdata` 卷 `/app/data/fileshare.db`);Postgres 模式——`.env` 设
|
||||
`FCB_DB_DRIVER=postgres` 与 `FCB_DB_DSN` 后 `docker compose --profile postgres up -d --build`。
|
||||
- Redis 可选:`--profile redis` 并在 `.env` 设 `FCB_REDIS_ADDR=redis:6379`;未配置时自动降级为内存缓存。
|
||||
- 存储引擎切换:`.env` 改 `FCB_STORAGE_ENGINE=s3|webdav` 并带对应 profile 启动:
|
||||
@@ -51,7 +55,7 @@ docker compose up -d --build
|
||||
```bash
|
||||
# 1) 后端(:8466)——默认 SQLite 零依赖,无需任何数据库
|
||||
cd server
|
||||
go run ./cmd/server # 数据落 ./data/filecodebox.db;go test ./... 运行单测
|
||||
go run ./cmd/server # 数据落 ./data/fileshare.db;go test ./... 运行单测
|
||||
|
||||
# 2) 后端 Postgres 模式(可选)
|
||||
docker run -d --name fcb-pg -p 5432:5432 \
|
||||
@@ -76,7 +80,9 @@ npm run build # 产出 web/dist/,构建时按 deploy/Dockerfil
|
||||
|
||||
| 内容 | 入口 |
|
||||
|---|---|
|
||||
| API 操作文档(概述/认证/分享/分片/预签名/管理后台/审计/存储/配置/错误码/Logo) | 站内 `/docs`,源文件 `docs/api/*.md` |
|
||||
| API 操作文档(概述/认证/分享/分片/预签名/管理后台/审计/存储/配置/错误码/Logo/
|
||||
**带宽限速**) | 站内 `/docs`,源文件 [docs/api/](docs/api/) |
|
||||
| ↳ **26.9 带宽限速**(`upload_rate` / `download_rate`,管理端改后立即生效) | [docs/api/13-bandwidth.md](docs/api/13-bandwidth.md) |
|
||||
| OpenAPI 3.0 规范 + Swagger UI | 站内 `/openapi`,源文件 `docs/openapi.yaml` |
|
||||
| 后端设计契约 | [server/README.md](server/README.md) |
|
||||
| 前端说明与渲染约定 | [web/README.md](web/README.md) |
|
||||
@@ -88,8 +94,8 @@ npm run build # 产出 web/dist/,构建时按 deploy/Dockerfil
|
||||
|
||||
| 变量 | 必需 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `FCB_DB_DRIVER` | ❌ | `sqlite` | 数据库驱动:`sqlite` \| `postgres`(v2 需求 ⑧) |
|
||||
| `FCB_DB_DSN` | 视驱动 | - | postgres:连接串(**必需**);sqlite:文件路径(可空,默认 `./data/filecodebox.db`) |
|
||||
| `FCB_DB_DRIVER` | ❌ | `sqlite` | 数据库驱动:`sqlite` \| `postgres`(26.9 需求 ⑧) |
|
||||
| `FCB_DB_DSN` | 视驱动 | - | postgres:连接串(**必需**);sqlite:文件路径(可空,默认 `./data/fileshare.db`) |
|
||||
| `FCB_REDIS_ADDR` | ❌ | 空 | 为空时缓存降级为内存实现;支持 `redis://[:password@]host:port[/db]` / `rediss://` URL 形式 |
|
||||
| `FCB_REDIS_DB` | ❌ | `0` | Redis 逻辑库号 0-15(URL 显式 `/N` 时以 URL 为准) |
|
||||
| `FCB_LISTEN` | ❌ | `:8466` | 监听地址 |
|
||||
@@ -109,14 +115,16 @@ npm run build # 产出 web/dist/,构建时按 deploy/Dockerfil
|
||||
|
||||
### 运行时配置(settings KV,管理端可改)
|
||||
|
||||
站点信息(`site_name`、`logo_url`、`favicon_url`、`page_explain` 等)、v2 展示与通知
|
||||
站点信息(`site_name`、`logo_url`、`favicon_url`、`page_explain` 等)、26.9 展示与通知
|
||||
(`background_url`、`footer_text`、`footer_beian`、`notify_enabled`、`notify_title/content`)、
|
||||
上传策略(`openUpload`、`enableChunk`、`uploadSize`、`allowed_file_types`、`expireStyle`、
|
||||
`code_generate_type`、`max_save_seconds`、`storageLimit`,及 v2 上限键 `max_save_count`、
|
||||
`code_generate_type`、`max_save_seconds`、`storageLimit`,及 26.9 上限键 `max_save_count`、
|
||||
`max_file_size`)、限流(`uploadCount/uploadMinute`、`errorCount/errorMinute`、
|
||||
`loginCount/loginMinute`)、安全(`adminSessionExpire`;`admin_token`/`jwt_secret` 由系统管理)。
|
||||
26.9 带宽(`upload_rate` / `download_rate`,字节/秒,0=不限速)。
|
||||
|
||||
完整键表与默认值见《[环境变量与配置项](docs/api/10-config.md)》;
|
||||
26.9 带宽限速专题《[带宽限速](docs/api/13-bandwidth.md)》;
|
||||
修改接口见《[管理后台 API](docs/api/07-admin.md)》(`PATCH /admin/config/update`,改密自动轮换 jwt_secret)。
|
||||
|
||||
## 审计日志
|
||||
|
||||
+6
-6
@@ -5,17 +5,17 @@
|
||||
WEB_PORT=8466
|
||||
|
||||
# ---- 数据库(需求 ⑧)----
|
||||
# 默认 SQLite:零依赖,无需任何下方 Postgres 变量(数据落 serverdata 卷 /app/data/filecodebox.db)
|
||||
# 默认 SQLite:零依赖,无需任何下方 Postgres 变量(数据落 serverdata 卷 /app/data/fileshare.db)
|
||||
FCB_DB_DRIVER=sqlite
|
||||
FCB_DB_DSN=
|
||||
# Postgres 模式(可选):先 `docker compose --profile postgres up -d --build`,再改为:
|
||||
# FCB_DB_DRIVER=postgres
|
||||
# FCB_DB_DSN=postgres://filecodebox:filecodebox@postgres:5432/filecodebox?sslmode=disable
|
||||
# FCB_DB_DSN=postgres://fileshare:fileshare@postgres:5432/fileshare?sslmode=disable
|
||||
|
||||
# ---- Postgres(仅 --profile postgres 时使用;生产务必修改默认口令并启用 TLS)----
|
||||
POSTGRES_USER=filecodebox
|
||||
POSTGRES_PASSWORD=filecodebox
|
||||
POSTGRES_DB=filecodebox
|
||||
POSTGRES_USER=fileshare
|
||||
POSTGRES_PASSWORD=fileshare
|
||||
POSTGRES_DB=fileshare
|
||||
|
||||
# ---- 存储引擎切换:local | s3 | webdav ----
|
||||
FCB_STORAGE_ENGINE=local
|
||||
@@ -47,7 +47,7 @@ FCB_TRUSTED_PROXIES=
|
||||
# 注意:minioadmin/minioadmin 仅为本机冒烟默认值,对外部署必须修改
|
||||
# (并在 compose 中删除 minio 的 ports 发布或仅绑定 127.0.0.1)
|
||||
FCB_S3_ENDPOINT_URL=http://minio:9000
|
||||
FCB_S3_BUCKET_NAME=filecodebox
|
||||
FCB_S3_BUCKET_NAME=fileshare
|
||||
FCB_S3_ACCESS_KEY_ID=minioadmin
|
||||
FCB_S3_SECRET_ACCESS_KEY=minioadmin
|
||||
FCB_S3_REGION_NAME=us-east-1
|
||||
|
||||
+16
-5
@@ -2,14 +2,23 @@
|
||||
# 1) node:20-alpine 构建 Vue 前端产物(web/dist)
|
||||
# 2) golang:1.27.1-alpine 编译 server(go:embed 前端产物,缓存指向 /tmp 避免写权限问题)
|
||||
# 3) 最小运行时镜像:server 二进制 + web/dist 静态产物
|
||||
FROM node:20-alpine AS frontend
|
||||
# 基础镜像可通过 build-arg 覆盖:CI/受限网络下指向 ACR 命名空间镜像
|
||||
# (--build-arg NODE_IMAGE=… GO_IMAGE=… RUNTIME_IMAGE=… APK_MIRROR=mirrors.aliyun.com)
|
||||
ARG NODE_IMAGE=node:20-alpine
|
||||
ARG GO_IMAGE=golang:1.27.1-alpine
|
||||
ARG RUNTIME_IMAGE=alpine:3.20
|
||||
# NPM_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 golang:1.27.1-alpine AS backend
|
||||
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
|
||||
@@ -30,9 +39,11 @@ RUN if [ -f web-embed/index.html ]; then \
|
||||
&& rm -rf web-embed frontend-dist
|
||||
RUN go build -trimpath -ldflags "-s -w" -o /out/server ./cmd/server
|
||||
|
||||
FROM alpine:3.20
|
||||
# curl 供容器健康检查使用
|
||||
RUN apk add --no-cache ca-certificates tzdata curl && adduser -D -u 10001 fcb
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
# APK_MIRROR:受限网络下注入 alpine 镜像源(空=默认源)
|
||||
ARG APK_MIRROR=""
|
||||
RUN if [ -n "$APK_MIRROR" ]; then sed -i "s|https://dl-cdn.alpinelinux.org|$APK_MIRROR|g" /etc/apk/repositories; fi \
|
||||
&& apk add --no-cache ca-certificates tzdata curl && adduser -D -u 10001 fcb
|
||||
WORKDIR /app
|
||||
COPY --from=backend /out/server /app/server
|
||||
# dist 随镜像分发(服务端已 go:embed,磁盘副本供运维检查/替换)
|
||||
|
||||
+9
-7
@@ -1,11 +1,13 @@
|
||||
# 文件快传 部署编排(deploy/)
|
||||
|
||||
Go 1.27.1(Gin + GORM)+ Vue 3 重写版(v3.1)的容器化部署。数据库**默认 SQLite,零外部依赖**
|
||||
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` 时服务端自动降级为进程内存缓存。
|
||||
|
||||
> v3.1 功能提示:管理后台可设「站点对外域名」(内网部署生成公网分享链接)、
|
||||
> 26.9 功能提示:管理后台可设「站点对外域名」(内网部署生成公网分享链接)、
|
||||
> 分享时支持自定义提取码(4-8 位字母数字);这些均为运行时配置,无需改部署。
|
||||
> 26.9 新增:管理后台可设「上行/下行带宽」限速(字节/秒,0=不限速),立即生效。
|
||||
> S3 预签名直传(客户端→S3)服务端无法介入限速,其余下载/上传路径均覆盖。详见《[带宽限速](../docs/api/13-bandwidth.md)》。
|
||||
|
||||
## compose 服务与 profile 一览
|
||||
|
||||
@@ -30,7 +32,7 @@ docker compose up -d --build
|
||||
# 打开 http://localhost:8466(.env 可用 WEB_PORT 改端口)→ 自动跳转 /setup 完成初始化
|
||||
```
|
||||
|
||||
- 数据落 **`serverdata` 卷**:容器内 `/app/data/filecodebox.db`(WAL 模式,父目录自动创建)。
|
||||
- 数据落 **`serverdata` 卷**:容器内 `/app/data/fileshare.db`(WAL 模式,父目录自动创建)。
|
||||
- `.env` 中 Postgres 段变量(`POSTGRES_USER/PASSWORD/DB`)在此模式下不生效,无需修改。
|
||||
|
||||
### 路径 ②:Postgres 模式(可选)
|
||||
@@ -39,7 +41,7 @@ docker compose up -d --build
|
||||
|
||||
```dotenv
|
||||
FCB_DB_DRIVER=postgres
|
||||
FCB_DB_DSN=postgres://filecodebox:filecodebox@postgres:5432/filecodebox?sslmode=disable
|
||||
FCB_DB_DSN=postgres://fileshare:fileshare@postgres:5432/fileshare?sslmode=disable
|
||||
```
|
||||
|
||||
2. 带 postgres profile 启动(server 会等 postgres 健康检查通过后再启动):
|
||||
@@ -65,13 +67,13 @@ docker compose up -d --build
|
||||
```bash
|
||||
# S3 引擎(MinIO 冒烟)
|
||||
docker compose --profile minio up -d --build # .env: FCB_STORAGE_ENGINE=s3
|
||||
# minio-init 一次性任务自动建桶(mc mb filecodebox)
|
||||
# minio-init 一次性任务自动建桶(mc mb fileshare)
|
||||
|
||||
# WebDAV 引擎(dufs 冒烟,Basic 认证 admin/admin123@/:rw)
|
||||
docker compose --profile webdav up -d --build # .env: FCB_STORAGE_ENGINE=webdav
|
||||
```
|
||||
|
||||
> **v3 起支持运行时热切换**:也可不改 `.env`,直接在管理后台「系统设置 → 存储引擎」
|
||||
> **26.9 起支持运行时热切换**:也可不改 `.env`,直接在管理后台「系统设置 → 存储引擎」
|
||||
> 三选并保存引擎参数后点切换(健康检查通过才生效,失败保持原引擎),或调用
|
||||
> `POST /admin/storage/switch`——均无需重启容器。`.env` 的 `FCB_STORAGE_ENGINE`
|
||||
> 仅作为首次启动(KV 为空时)的默认引擎。
|
||||
@@ -125,7 +127,7 @@ curl -I http://localhost:8466/docs # 200(SPA 回退)
|
||||
或在 `.env` 设 `FCB_ADMIN_PASSWORD`(≥8 位)让服务启动即自动初始化,
|
||||
消除「公网上被抢先访问 /setup 接管」的窗口。初始化完成后建议从 `.env` 移除该变量。
|
||||
3. **修改组件默认凭据**:minio(`minioadmin/minioadmin`)、webdav(`admin/admin123`)、
|
||||
Postgres(`filecodebox/filecodebox`)默认凭据仅限本机冒烟;对外部署必须修改,
|
||||
Postgres(`fileshare/fileshare`)默认凭据仅限本机冒烟;对外部署必须修改,
|
||||
且不建议把 9000/9001/5005 端口发布到公网(compose 中删除对应 `ports` 或仅绑 127.0.0.1)。
|
||||
4. **Postgres 建议启用 TLS**:默认示例 DSN 为 `sslmode=disable`,生产请改为
|
||||
`sslmode=require` 及以上。
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
# docker compose --profile minio up -d # 可选:S3 引擎冒烟(FCB_STORAGE_ENGINE=s3)
|
||||
# docker compose --profile webdav up -d # 可选:WebDAV 引擎冒烟(FCB_STORAGE_ENGINE=webdav)
|
||||
#
|
||||
# 数据库默认 SQLite(modernc.org/sqlite 纯 Go 驱动,数据落 serverdata 卷 /app/data/filecodebox.db);
|
||||
# 数据库默认 SQLite(modernc.org/sqlite 纯 Go 驱动,数据落 serverdata 卷 /app/data/fileshare.db);
|
||||
# postgres 为可选 profile 服务,启用时 server 经 FCB_DB_DRIVER/FCB_DB_DSN 自动接入;
|
||||
# Redis 同为可选,未配置时服务端内存降级。
|
||||
name: filecodebox
|
||||
name: fileshare
|
||||
|
||||
services:
|
||||
postgres:
|
||||
@@ -18,13 +18,13 @@ services:
|
||||
# 需要时 docker compose --profile postgres up -d --build
|
||||
profiles: ["postgres"]
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-filecodebox}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-filecodebox}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-filecodebox}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-fileshare}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fileshare}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-fileshare}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-filecodebox}"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fileshare}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
@@ -52,7 +52,7 @@ services:
|
||||
start_period: 5s
|
||||
environment:
|
||||
# 需求 ⑧:数据库驱动 sqlite(默认,零依赖)| postgres(需 --profile postgres)
|
||||
# 默认 SQLite:DSN 留空 → 数据库文件落 /app/data/filecodebox.db(serverdata 卷)
|
||||
# 默认 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:-}
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
FCB_LOCAL_STORAGE_PATH: /app/data
|
||||
# S3 引擎(MinIO profile)
|
||||
FCB_S3_ENDPOINT_URL: ${FCB_S3_ENDPOINT_URL:-}
|
||||
FCB_S3_BUCKET_NAME: ${FCB_S3_BUCKET_NAME:-filecodebox}
|
||||
FCB_S3_BUCKET_NAME: ${FCB_S3_BUCKET_NAME:-fileshare}
|
||||
FCB_S3_ACCESS_KEY_ID: ${FCB_S3_ACCESS_KEY_ID:-minioadmin}
|
||||
FCB_S3_SECRET_ACCESS_KEY: ${FCB_S3_SECRET_ACCESS_KEY:-minioadmin}
|
||||
FCB_S3_REGION_NAME: ${FCB_S3_REGION_NAME:-us-east-1}
|
||||
@@ -122,7 +122,7 @@ services:
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio:9000 ${FCB_S3_ACCESS_KEY_ID:-minioadmin} ${FCB_S3_SECRET_ACCESS_KEY:-minioadmin} &&
|
||||
mc mb --ignore-existing local/${FCB_S3_BUCKET_NAME:-filecodebox} &&
|
||||
mc mb --ignore-existing local/${FCB_S3_BUCKET_NAME:-fileshare} &&
|
||||
echo 'MinIO 桶已就绪'"
|
||||
|
||||
# WebDAV 冒烟服务器(dufs,简单读写 + Basic 认证)
|
||||
|
||||
+18
-2
@@ -1,7 +1,14 @@
|
||||
# API 概述
|
||||
|
||||
文件快传 Go 版(v2.5.6)对外提供一套 REST API,覆盖文本/文件分享、分片上传、
|
||||
预签名直传、管理后台与审计日志查询。本文档与 `server/internal/api/` 实际实现逐一对齐,
|
||||
文件快传 Go 版(26.9)对外提供一套 REST API,覆盖文本/文件分享、分片上传、
|
||||
预签名直传、管理后台与审计日志查询。
|
||||
|
||||
## 更新日志
|
||||
|
||||
- **26.9**:上下行带宽限速(`upload_rate` / `download_rate`,详见《[带宽限速](13-bandwidth.md)》)、
|
||||
站点对外域名(`site_domain`)、自定义提取码(5~8 位)
|
||||
|
||||
本文档与 `server/internal/api/` 实际实现逐一对齐,
|
||||
交互式规范见站内 `/openapi`(源文件 `docs/openapi.yaml`)。
|
||||
|
||||
## Base URL
|
||||
@@ -55,6 +62,7 @@
|
||||
| `error` | 取件失败(404/过期)时计数 | 10 次 / 1 分钟 | `errorCount` / `errorMinute` |
|
||||
| `login` | 登录失败时计数 | 5 次 / 15 分钟 | `loginCount` / `loginMinute` |
|
||||
| `metadata` | 每次访问即计数 | 同 `error` | `errorCount` / `errorMinute` |
|
||||
| `bandwidth` | 每 Read 块按 `rate` 字节/秒对齐 sleep,0=不限速 | 0 / 0(不限) | `upload_rate` / `download_rate` |
|
||||
|
||||
## 初始化守卫
|
||||
|
||||
@@ -86,6 +94,14 @@
|
||||
| 管理后台 | `POST /admin/login` · `GET /admin/verify` · `POST /admin/logout` · `GET /admin/dashboard` · 文件管理 `/admin/file/*` · 配置 `/admin/config/*` · 密码 `/admin/settings/password` |
|
||||
| 审计日志 | `GET /admin/audit/list`(别名 `/admin/audit/logs`) |
|
||||
|
||||
## 专题文档
|
||||
|
||||
| 主题 | 文档 |
|
||||
|---|---|
|
||||
| 站点 Logo 自定义 | [12-logo.md](12-logo.md) |
|
||||
| 错误码 | [11-errors.md](11-errors.md) |
|
||||
| 带宽限速(26.9) | [13-bandwidth.md](13-bandwidth.md) |
|
||||
|
||||
## 时间与编码
|
||||
|
||||
- 时间字段一律 RFC 3339(如 `2025-06-01T12:00:00+08:00`);管理员会话过期时间为 Unix 秒。
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
|
||||
## 管理员令牌
|
||||
|
||||
- 由 `POST /admin/login` 用管理员密码换取,HS256 JWT,默认有效期 **7 天**(`adminSessionExpire`,1~365 整天,v2.5.6 起由 30 天缩短)。
|
||||
- 由 `POST /admin/login` 用管理员密码换取,HS256 JWT,默认有效期 **7 天**(`adminSessionExpire`,1~365 整天,26.9 起由 30 天缩短)。
|
||||
- 请求头格式:`Authorization: Bearer <token>`。
|
||||
- **改密/重置管理员密码会轮换 `jwt_secret`,所有已签发令牌立即失效**(401)。
|
||||
- 密码存储为 bcrypt(cost 12);历史 `sha256$`/明文格式在登录成功后自动升级重哈希,无需手动迁移。
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
- `day`/`hour`/`minute`:按时间过期,`expired_count = -1`。
|
||||
- `count`:按次数过期,取件 `expire_value` 次后失效(`expired_count = expire_value`);
|
||||
**v2 需求 ④**:`max_save_count>0` 时 `expire_value` 不得超出该上限,超限 403。
|
||||
**26.9 需求 ④**:`max_save_count>0` 时 `expire_value` 不得超出该上限,超限 403。
|
||||
- `forever`:永久(需站点允许;`max_save_seconds>0` 时其他方式受最长保存上限约束,超限 403)。
|
||||
|
||||
> 可选值与上限来自公开配置 `GET /api/v1/config`(`expireStyle`、`max_save_seconds`、
|
||||
@@ -88,4 +88,4 @@ curl -s "http://localhost:8466/share/select?code=8XQ2M"
|
||||
你好,文件快传
|
||||
```
|
||||
|
||||
**v3.1 变更**:① 支持 JSON 提交(`Content-Type: application/json`,字段同名);② 空文本 400「分享内容不能为空」;③ 可选 `code` 自定义提取码(5-8 位字母数字,占用 400)。
|
||||
**26.9 变更**:① 支持 JSON 提交(`Content-Type: application/json`,字段同名);② 空文本 400「分享内容不能为空」;③ 可选 `code` 自定义提取码(5-8 位字母数字,占用 400)。
|
||||
|
||||
@@ -39,7 +39,7 @@ curl -s -X POST http://localhost:8466/share/file \
|
||||
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
|
||||
```
|
||||
|
||||
> 大小上限为动态策略(v2 需求 ④⑩):管理端改 `max_file_size`(0=回落 `uploadSize`)后
|
||||
> 大小上限为动态策略(26.9 需求 ④⑩):管理端改 `max_file_size`(0=回落 `uploadSize`)后
|
||||
> **下一次上传立即按新上限执行**,无需重启;上限值可经 `GET /api/v1/config` 的
|
||||
> `max_file_size`/`maxFileSize` 字段读取。
|
||||
|
||||
@@ -101,3 +101,31 @@ curl -s -H 'Range: bytes=0-1023' -o part.bin \
|
||||
```json
|
||||
{ "code": 416, "msg": "请求范围超出文件大小" }
|
||||
```
|
||||
|
||||
## 直链下载(26.9)
|
||||
|
||||
存储引擎为对象存储(S3)且 `direct_download=1` 时,`GET /share/select` 与
|
||||
`GET /share/download` 不再代理文件流,而是 `302` 重定向到限时预签名 URL——
|
||||
文件字节不经过本服务器,带宽成本转嫁对象存储。
|
||||
|
||||
- 签名有效期 = `direct_link_expire`(默认 900 秒)与分享剩余时效的较小值;
|
||||
- 引擎不支持直链(如 local/WebDAV)时自动回落代理下载,取件不中断;
|
||||
- 审计照常记录(`transferred_bytes` 记为文件大小)。
|
||||
|
||||
## 下载防盗链(26.9)
|
||||
|
||||
`hotlink_enabled=1` 时,`/share/download` 校验 `Referer`:
|
||||
|
||||
| Referer | 行为 |
|
||||
|---|---|
|
||||
| 空(直接访问 / curl / 地址栏) | 放行 |
|
||||
| 与请求 Host 同源 | 放行 |
|
||||
| 命中 `hotlink_whitelist`(逗号分隔域名,支持 `*.example.com` 通配) | 放行 |
|
||||
| 其余 | `403`(JSON 错误体) |
|
||||
|
||||
白名单为空时仅同源放行。开关与白名单均为管理端 KV,修改后立即生效。
|
||||
|
||||
## 文件夹上传(26.9)
|
||||
|
||||
不支持文件夹上传(前端已移除目录选择;拖拽目录会提示"建议压缩后上传")。
|
||||
后端 `SanitizeFileName` 会剥离文件名中的路径分隔符,多级路径无法成体保存。
|
||||
|
||||
@@ -130,7 +130,7 @@ curl -s -X POST http://localhost:8466/chunk/upload/3f6b8c2a4d5e6f708192a3b4c5d6e
|
||||
|
||||
> 约束:单分片 ≤ `chunk_size`(init 声明值)且 ≤ **32MiB 硬上限**(init 时 `chunk_size>33554432` 直接 400「chunk_size 过大」);
|
||||
> 总大小(init 按分片数上限、上传/合并按累计)受**动态策略上限**约束——`max_file_size>0` 时为其,
|
||||
> 否则回落 `uploadSize`(v2 需求 ④⑩,管理端改后立即生效,超限清理会话);首个分片做 magic bytes
|
||||
> 否则回落 `uploadSize`(26.9 需求 ④⑩,管理端改后立即生效,超限清理会话);首个分片做 magic bytes
|
||||
> 防伪校验(403「文件内容校验失败:…」)。分片哈希由服务端计算;合并时与分片记录交叉校验,不一致报 400。
|
||||
>
|
||||
> **enableChunk 开关**:管理端关闭分片上传后,`/chunk/upload/*` 全部端点返回 403「分片上传未启用」(后端强制,前端仅隐藏入口)。
|
||||
|
||||
@@ -52,7 +52,7 @@ curl -s -X POST http://localhost:8466/presign/upload/init \
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"upload_id": "6a1e…",
|
||||
"upload_url": "https://minio:9000/filecodebox/share/data/2025/06/01/6a1e…/backup.zip?X-Amz-…",
|
||||
"upload_url": "https://minio:9000/fileshare/share/data/2025/06/01/6a1e…/backup.zip?X-Amz-…",
|
||||
"mode": "direct",
|
||||
"expires_in": 900,
|
||||
"file_path": "share/data/2025/06/01/6a1e…"
|
||||
@@ -82,7 +82,7 @@ curl -s -X POST http://localhost:8466/presign/upload/init \
|
||||
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
|
||||
```
|
||||
|
||||
> 大小上限为动态策略(v2 需求 ④⑩):管理端改 `max_file_size`(0=回落 `uploadSize`)后
|
||||
> 大小上限为动态策略(26.9 需求 ④⑩):管理端改 `max_file_size`(0=回落 `uploadSize`)后
|
||||
> 立即按新上限校验 init 声明的 `file_size`。
|
||||
|
||||
```json
|
||||
|
||||
+23
-6
@@ -318,7 +318,7 @@ curl -s "http://localhost:8466/admin/file/preview?id=41&maxChars=100" -H "Author
|
||||
|
||||
返回运行时配置 KV(含默认值与管理端修改)。`admin_token` 恒返回空串(屏蔽);
|
||||
`jwt_secret` 不下发;存储引擎为进程级单例,`_engine_hint` 提示引擎配置修改需重启。
|
||||
v2 新增键(需求 ①②③④⑩)一并返回:`background_url`、`footer_text`、`footer_beian`、
|
||||
26.9 新增键(需求 ①②③④⑩)一并返回:`background_url`、`footer_text`、`footer_beian`、
|
||||
`notify_enabled`、`max_save_count`、`max_file_size` 等。
|
||||
|
||||
```json
|
||||
@@ -351,7 +351,7 @@ v2 新增键(需求 ①②③④⑩)一并返回:`background_url`、`foote
|
||||
}
|
||||
```
|
||||
|
||||
## 存储引擎热切换:POST /admin/storage/switch(v3)
|
||||
## 存储引擎热切换:POST /admin/storage/switch(26.9)
|
||||
|
||||
运行时切换存储引擎,**无需重启**:
|
||||
|
||||
@@ -374,14 +374,14 @@ curl -s -X POST http://localhost:8466/admin/storage/switch \
|
||||
部分更新(JSON 对象,未提供的键不变;表单亦可)。**PATCH 为主名,POST 为兼容别名**。
|
||||
|
||||
- 仅接受管理端可见键(见上响应键集合),未知键忽略。
|
||||
- 数值型键自动转型:`openUpload`、`enableChunk`、`uploadSize`、`storageLimit`、限流四组、`max_save_seconds`、`adminSessionExpire`、`showAdminAddr`;v2 新增 `max_save_count`、`max_file_size`、`notify_enabled`;`opacity` 为浮点。
|
||||
- **v3.1**:`site_domain`(站点对外域名)可经本端点设置,非法格式 400(仅 http/https、主机+端口、不带路径)。
|
||||
- **v3 引擎键**:`storage_engine` 不经本端点修改(走 `POST /admin/storage/switch`);引擎参数键
|
||||
- 数值型键自动转型:`openUpload`、`enableChunk`、`uploadSize`、`storageLimit`、限流四组、`max_save_seconds`、`adminSessionExpire`、`showAdminAddr`;26.9 新增 `max_save_count`、`max_file_size`、`notify_enabled`;`opacity` 为浮点。
|
||||
- **26.9**:`site_domain`(站点对外域名)可经本端点设置,非法格式 400(仅 http/https、主机+端口、不带路径)。
|
||||
- **26.9 引擎键**:`storage_engine` 不经本端点修改(走 `POST /admin/storage/switch`);引擎参数键
|
||||
(`local_storage_path`、`webdav_url`、`webdav_root_path`、`webdav_username`、`webdav_password`、
|
||||
`s3_endpoint_url`、`s3_region_name`、`s3_bucket_name`、`s3_access_key_id`、`s3_secret_access_key`、
|
||||
`aws_session_token`、`s3_addressing_style`)可经本端点保存——保存后对应引擎实例缓存失效,
|
||||
下次切换/构建生效;敏感键空串或 `******` 表示不修改。
|
||||
- **v2 schema 校验**(`settings.KVSchema`,越界一律 400,中文错误信息):
|
||||
- **26.9 schema 校验**(`settings.KVSchema`,越界一律 400,中文错误信息):
|
||||
- 整型边界:`max_file_size` ≤ 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`:须为字符串数组(或逗号分隔串)且至少保留一项;
|
||||
@@ -432,3 +432,20 @@ curl -s -X PATCH http://localhost:8466/admin/settings/password \
|
||||
```json
|
||||
{ "code": 401, "msg": "旧密码错误" }
|
||||
```
|
||||
|
||||
## 手动回收:POST /admin/recycle/run
|
||||
|
||||
**26.9**:手动触发一轮过期分享回收(定时循环之外的管理端入口)。回收范围:
|
||||
时间已过期、次数已耗尽、创建时间超过 `retention_days` 的分享——删除记录并
|
||||
连带删除存储对象(SHA512 去重开启时做引用计数,仍有其他分享引用的对象保留)。
|
||||
|
||||
```bash
|
||||
curl -s -X POST "http://localhost:8466/admin/recycle/run" -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "ok", "data": { "removed": 3 } }
|
||||
```
|
||||
|
||||
相关配置键:`recycle_enabled`(定时开关)、`recycle_interval`(扫描间隔)、
|
||||
`retention_days`(最长存储时长)、`dedup_enabled`(引用计数开关)。
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
| 客户端 | `ip` | 可信代理场景解析 XFF 后的真实 IP |
|
||||
| 终端信息 | `user_agent` | 原始 UA |
|
||||
| 设备解析 | `device_os` / `device_browser` / `device_type` | 由 UA 解析(如 Windows/Chrome/desktop) |
|
||||
| 动作 | `action` | `upload`(上传类) / `download`(取件/下载类) / `admin`(管理端敏感操作,v2.5.6 新增) |
|
||||
| 动作 | `action` | `upload`(上传类) / `download`(取件/下载类) / `admin`(管理端敏感操作,26.9 新增) |
|
||||
| 结果 | `result` | `success` / `denied`(拒绝:401/403/423/428)/ `failed`(失败:其余 4xx/5xx 或业务报错) |
|
||||
| 字节数 | `size_bytes` | 文件总大小;`transferred_bytes` 实际传输(**Range 下载只计实际区间字节**;下载由中间件自动统计,上传由各 handler 填充) |
|
||||
| 耗时 | `duration_ms` | 毫秒 |
|
||||
|
||||
@@ -14,11 +14,11 @@ FCB_STORAGE_ENGINE=webdav
|
||||
|
||||
非法值直接启动失败:`FCB_STORAGE_ENGINE 无效值 "xxx",仅支持 local|s3|webdav`。
|
||||
|
||||
**v3 运行时热切换**:管理端 `POST /admin/storage/switch`(或后台设置页「存储引擎」卡)可在不重启的情况下切换引擎——
|
||||
**26.9 运行时热切换**:管理端 `POST /admin/storage/switch`(或后台设置页「存储引擎」卡)可在不重启的情况下切换引擎——
|
||||
先构建新引擎并健康检查,通过才生效;失败 503 保持原引擎。当前引擎持久化在 settings KV `storage_engine`(空=回落启动值)。
|
||||
各引擎参数(存储目录/服务地址/存储桶/密钥)同样在后台设置页运行时可改;保存后对应引擎实例缓存失效,下次切换/构建生效。
|
||||
|
||||
## 文件归属引擎(v3)
|
||||
## 文件归属引擎(26.9)
|
||||
|
||||
每条分享记录(`file_codes.engine`)与上传会话(`upload_chunks.engine` / `presign_upload_sessions.engine`)
|
||||
在创建时戳记当时的引擎名。下载、分片合并、删除按**归属引擎**操作——切换引擎后,旧引擎里的文件仍可正常下载与删除
|
||||
|
||||
+29
-20
@@ -1,15 +1,15 @@
|
||||
# 环境变量与配置项
|
||||
|
||||
配置分三层:**默认值 → `FCB_*` 环境变量 → 数据库 settings KV(管理端运行时修改)**。
|
||||
v2 起进程必需的环境变量为空集:数据库默认 **SQLite**(modernc.org/sqlite 纯 Go 驱动,零外部依赖,
|
||||
DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_DSN` 必需(需求 ⑧)。
|
||||
26.9 起进程必需的环境变量为空集:数据库默认 **SQLite**(modernc.org/sqlite 纯 Go 驱动,零外部依赖,
|
||||
DSN 缺省落 `./data/fileshare.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_DSN` 必需(需求 ⑧)。
|
||||
|
||||
## 环境变量(进程级)
|
||||
|
||||
| 变量 | 必需 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `FCB_DB_DRIVER` | ❌ | `sqlite` | 数据库驱动:`sqlite` / `postgres`(需求 ⑧) |
|
||||
| `FCB_DB_DSN` | 视驱动 | `./data/filecodebox.db` | postgres:连接串(**必需**,如 `postgres://user:pass@host:5432/filecodebox?sslmode=disable`);sqlite:数据库文件路径(可空,父目录自动创建) |
|
||||
| `FCB_DB_DSN` | 视驱动 | `./data/fileshare.db` | postgres:连接串(**必需**,如 `postgres://user:pass@host:5432/filecodebox?sslmode=disable`);sqlite:数据库文件路径(可空,父目录自动创建) |
|
||||
| `FCB_REDIS_ADDR` | ❌ | 空 | Redis 地址(如 `redis:6379`),也支持 `redis://[:password@]host:port[/db]` / `rediss://`(TLS)URL 形式;**空则缓存降级为进程内存实现**(缓存故障时自动降级为进程内限流计数) |
|
||||
| `FCB_REDIS_DB` | ❌ | `0` | Redis 逻辑库号 0-15;URL 形式地址显式携带 `/N` 时以 URL 为准 |
|
||||
| `FCB_ADMIN_PASSWORD` | ❌ | 空 | 设置后服务首次启动即自动初始化管理员(≥8 位,不足告警跳过),消除 `/setup` 被抢占窗口;初始化完成后建议移除 |
|
||||
@@ -26,19 +26,19 @@ DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_
|
||||
`FCB_WEBDAV_URL`、`FCB_WEBDAV_USERNAME`、`FCB_WEBDAV_PASSWORD`、`FCB_WEBDAV_ROOT_PATH`。
|
||||
|
||||
部署用编排变量(`deploy/.env.example`):`WEB_PORT`(默认 8466)、
|
||||
`POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB`(默认 filecodebox,仅 `--profile postgres` 时使用)。
|
||||
`POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB`(默认 fileshare,仅 `--profile postgres` 时使用)。
|
||||
|
||||
## 配置项(settings KV,默认值对齐参考实现)
|
||||
|
||||
> 键名/类型/默认值/边界以 `server/internal/config/schema.go` 的 `KVSchema()` 为单一事实来源
|
||||
> (schema 同步测试保证与 defaults() 逐键一致);v2 新增键统一 snake_case。
|
||||
> (schema 同步测试保证与 defaults() 逐键一致);26.9 新增键统一 snake_case。
|
||||
|
||||
### 站点信息与展示(需求 ①②③)
|
||||
|
||||
| 键 | 类型/边界 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `site_name` / `name` | string | 文件快传 | 站点名称(`site_name` 优先) |
|
||||
| `site_domain` | string,≤256 | 空 | **v3.1**:站点对外域名(`http(s)://host[:port]`,不带路径;裸主机自动补 `http://`)。配置后分享链接(结果卡/管理端复制)用该域名生成——内网部署也能把公网链接发出去;留空=用当前访问地址 |
|
||||
| `site_domain` | string,≤256 | 空 | **26.9**:站点对外域名(`http(s)://host[:port]`,不带路径;裸主机自动补 `http://`)。配置后分享链接(结果卡/管理端复制)用该域名生成——内网部署也能把公网链接发出去;留空=用当前访问地址 |
|
||||
| `description` | string | 开箱即用的文件快传系统 | 站点描述 |
|
||||
| `page_explain` | string | (合规声明) | 页面说明文案 |
|
||||
| `keywords` | string | 文件快传, 文件分享… | SEO 关键词 |
|
||||
@@ -46,10 +46,10 @@ DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_
|
||||
| `favicon_url` | string | 空(前端回落本地打包 `/assets/favicon-*.png`,需求 ⑤) | favicon / 备用 Logo |
|
||||
| `opacity` | float | 0.9 | 界面不透明度 |
|
||||
| `background` | string | 空 | 背景图 URL(参考实现既有键,v1 兼容保留) |
|
||||
| `background_url` | string,≤2048 字符 | 空 | **v2 需求 ①**:背景图 URL 或上传后地址(空=主题默认;取值时 legacy `background` 键兜底)。管理端保存时校验协议白名单:仅 `http(s)`、`data:image/*` 与站内相对路径(防 `javascript:` 注入,非法 400) |
|
||||
| `footer_text` | string,≤2000 字符 | 空 | **v2 需求 ②**:页脚自定义内容(纯文本或受控 HTML 片段) |
|
||||
| `footer_beian` | string,≤128 字符 | 空 | **v2 需求 ②**:备案号(如 `京ICP备2024xxxxxx号-1`),展示于页脚 |
|
||||
| `notify_enabled` | int(0/1) | 1 | **v2 需求 ③**:通知开关(1=前台右上角悬浮窗展示 / 0=关闭) |
|
||||
| `background_url` | string,≤2048 字符 | 空 | **26.9 需求 ①**:背景图 URL 或上传后地址(空=主题默认;取值时 legacy `background` 键兜底)。管理端保存时校验协议白名单:仅 `http(s)`、`data:image/*` 与站内相对路径(防 `javascript:` 注入,非法 400) |
|
||||
| `footer_text` | string,≤2000 字符 | 空 | **26.9 需求 ②**:页脚自定义内容(纯文本或受控 HTML 片段) |
|
||||
| `footer_beian` | string,≤128 字符 | 空 | **26.9 需求 ②**:备案号(如 `京ICP备2024xxxxxx号-1`),展示于页脚 |
|
||||
| `notify_enabled` | 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 | 是否展示后台入口 |
|
||||
@@ -59,8 +59,8 @@ DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_
|
||||
|
||||
| 键 | 类型/边界 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `max_save_seconds` | int64,0~31536000 | 0 | 最长保存秒数上限(0=仅默认 7 天兜底;>0 时按时间过期超限 403「限制最长时间为 X,可换用其他方式」)。**v3**:管理界面以「小时/天」下拉单位编辑(≥1 天自动显示天),提交时前端换算为秒——canonical 单位保持秒,接口语义不变 |
|
||||
| `max_save_count` | int,0~100000 | 0 | **v2 新增**:单次分享最大可取(保存)次数上限(0=不限制;`expire_style=count` 且 `expire_value` 超上限时 403「限制次数最多为 N 次」) |
|
||||
| `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「过期时间类型错误」) |
|
||||
|
||||
### 存储策略(需求 ④⑩)
|
||||
@@ -68,7 +68,7 @@ DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_
|
||||
| 键 | 类型/边界 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `uploadSize` | int64,1024~10GiB | 10485760(10MB) | 单文件大小上限(字节),参考实现语义;`max_file_size=0` 时作为生效上限 |
|
||||
| `max_file_size` | int64,0~10GiB | 0 | **v2 新增**:存储策略-单文件上限(字节),0=回落 `uploadSize`;超出 403(文案 humanSize 自适应 B/KB/MB/GB)。**v3**:管理界面以「MB/GB」下拉单位编辑(≥1 GiB 自动显示 GB),提交时前端换算为字节 |
|
||||
| `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) |
|
||||
@@ -79,6 +79,15 @@ DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_
|
||||
| 键 | 类型/边界 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `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 | 登录失败计数 |
|
||||
|
||||
@@ -90,9 +99,9 @@ DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_
|
||||
| `jwt_secret` | 空 | JWT 签名密钥(初始化/改密时自动生成轮换;不下发;`settings.SensitiveKeys` 双模式下一致屏蔽) |
|
||||
| `adminSessionExpire` | 604800(7 天) | 管理员会话秒数(须 1~365 整天) |
|
||||
|
||||
### 存储引擎(v3 运行时可配 + 热切换)
|
||||
### 存储引擎(26.9 运行时可配 + 热切换)
|
||||
|
||||
**`storage_engine`**(v3 新增键):string,`local|s3|webdav`,默认空=回落启动值 `FCB_STORAGE_ENGINE`。
|
||||
**`storage_engine`**(26.9 新增键):string,`local|s3|webdav`,默认空=回落启动值 `FCB_STORAGE_ENGINE`。
|
||||
运行时切换走 **`POST /admin/storage/switch`**(JWT 保护):构建新引擎 → 健康检查通过才生效;
|
||||
失败返回 503「存储引擎切换失败,已保持原引擎: …」且不改 KV。成功后持久化 `storage_engine`,重启沿用。
|
||||
`GET /api/v1/config` 公开下发 `storage_engine` 当前名(仅名称,任何引擎参数/凭据不下发)。
|
||||
@@ -114,10 +123,10 @@ DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_
|
||||
| `webdav_root_path` | `filebox_storage` |
|
||||
| `webdav_proxy` | 0 |
|
||||
|
||||
> 敏感键 `webdav_password` / `s3_secret_access_key` / `aws_session_token`(v3 加入 `settings.SensitiveKeys`):
|
||||
> 敏感键 `webdav_password` / `s3_secret_access_key` / `aws_session_token`(26.9 加入 `settings.SensitiveKeys`):
|
||||
> 管理端 GET 返回掩码 `******`;PATCH 时空串或 `******` 表示不修改。直接写库 settings KV 后重启同样生效。
|
||||
|
||||
## 策略动态生效机制(v2 需求 ④⑩)
|
||||
## 策略动态生效机制(26.9 需求 ④⑩)
|
||||
|
||||
上传页通过 `GET /api/v1/config` 的 `config` 字段读取**当前策略快照**并在范围内渲染选项;
|
||||
上传链路(`/share/file`、`/chunk/*`、`/presign/*`)**每次请求实时读取** settings KV 同一组值校验:
|
||||
@@ -128,7 +137,7 @@ DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_
|
||||
|
||||
## 公共配置接口
|
||||
|
||||
前端启动时经 `GET /api/v1/config` 获取站点公开配置(无需认证;v2 扩展需求 ①②③④⑩):
|
||||
前端启动时经 `GET /api/v1/config` 获取站点公开配置(无需认证;26.9 扩展需求 ①②③④⑩):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -161,7 +170,7 @@ DSN 缺省落 `./data/filecodebox.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_
|
||||
"openUpload": true
|
||||
},
|
||||
"meta": {
|
||||
"version": "2.5.6",
|
||||
"version": "26.9",
|
||||
"features": { "chunkUpload": false, "guestUpload": true }
|
||||
}
|
||||
}
|
||||
@@ -182,7 +191,7 @@ curl -s http://localhost:8466/api/v1/health
|
||||
"code": 200, "msg": "ok",
|
||||
"data": {
|
||||
"status": "ok",
|
||||
"version": "2.5.6",
|
||||
"version": "26.9",
|
||||
"storage": "local",
|
||||
"time": "2025-06-01T12:00:00+08:00"
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,13 +1,13 @@
|
||||
# Logo 自定义
|
||||
|
||||
## 默认 Logo(内置,v2 需求 ⑤)
|
||||
## 默认 Logo(内置,26.9 需求 ⑤)
|
||||
|
||||
| 项 | 默认值 | 用途 |
|
||||
|---|---|---|
|
||||
| 页面导航 Logo | 前端打包本地资源 `/assets/logo-*.svg`(源:`web/src/assets/brand/logo.svg`) | 导航栏 `<img>`;`config.logo_url` 为空时回落使用 |
|
||||
| favicon / 备用 Logo | 前端打包本地资源 `/assets/favicon-*.png`(源:`web/src/assets/brand/favicon.png`) | `index.html` `<link rel="icon">` + 动态 favicon 回落 |
|
||||
|
||||
v2 起默认不再引用远程 URL:`GET /api/v1/config` 中 `logo_url`/`favicon_url` 默认下发空串,
|
||||
26.9 起默认不再引用远程 URL:`GET /api/v1/config` 中 `logo_url`/`favicon_url` 默认下发空串,
|
||||
前端 `displayLogoUrl`/`displayFaviconUrl` 判空后回落到打包的本地资源。
|
||||
管理端仍可设置任意 URL 全站替换(三步如下)。
|
||||
|
||||
|
||||
@@ -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
|
||||
+49
-2
@@ -93,7 +93,7 @@ paths:
|
||||
footer_text: { type: string, description: '需求 ② 页脚自定义内容' }
|
||||
footer_beian: { type: string, description: '需求 ② 备案号' }
|
||||
storage_engine: { type: string, description: 当前存储引擎名(仅名称,凭据不下发) }
|
||||
site_domain: { type: string, description: 'v3.1 站点对外域名(空=用当前访问地址)' }
|
||||
site_domain: { type: string, description: '26.9 站点对外域名(空=用当前访问地址)' }
|
||||
notify_enabled: { type: integer, enum: [0, 1], description: '需求 ③ 通知开关(1=右上角悬浮窗)' }
|
||||
notify_title: { type: string }
|
||||
notify_content: { type: string }
|
||||
@@ -166,6 +166,16 @@ paths:
|
||||
loginMinute: { type: integer, default: 15 }
|
||||
uploadCount: { type: integer, default: 10 }
|
||||
uploadMinute: { type: integer, default: 1 }
|
||||
upload_rate: { type: integer, format: int64, default: 0, description: '26.9 上行带宽字节/秒;0=不限速' }
|
||||
download_rate: { type: integer, format: int64, default: 0, description: '26.9 下行带宽字节/秒;0=不限速' }
|
||||
recycle_enabled: { type: integer, enum: [0, 1], default: 1, description: '26.9 过期分享自动回收开关' }
|
||||
recycle_interval: { type: integer, format: int64, default: 1800, description: '26.9 回收扫描间隔秒(60~86400)' }
|
||||
retention_days: { type: integer, format: int64, default: 0, description: '26.9 最长存储时长天(0~3650;0=不限制)' }
|
||||
dedup_enabled: { type: integer, enum: [0, 1], default: 1, description: '26.9 SHA512 内容去重开关' }
|
||||
direct_download: { type: integer, enum: [0, 1], default: 1, description: '26.9 对象存储直链下载开关' }
|
||||
direct_link_expire: { type: integer, format: int64, default: 900, description: '26.9 直链签名有效期秒(60~3600)' }
|
||||
hotlink_enabled: { type: integer, enum: [0, 1], default: 0, description: '26.9 下载防盗链开关' }
|
||||
hotlink_whitelist: { type: string, maxLength: 2048, default: '', description: '26.9 防盗链白名单(逗号分隔域名,支持 *.example.com)' }
|
||||
allowed_file_types: { type: string, default: '*' }
|
||||
openUpload: { type: boolean, default: true }
|
||||
enableChunk: { type: boolean, default: false }
|
||||
@@ -205,7 +215,7 @@ paths:
|
||||
文本 ≤222KB(请求体全局上限 1MiB,Content-Length>441KB 直接 403);上传类接口(成功计数 upload 限流,423 超限),经审计中间件落库。
|
||||
保存策略(需求 ④):expire_style 须在 expireStyle 白名单(400);count 型受
|
||||
max_save_count 约束(403「限制次数最多为 N 次」);时间型受 max_save_seconds 约束(403)。
|
||||
v3.1:支持 JSON 提交(字段同名);空文本 400「分享内容不能为空」;
|
||||
26.9:支持 JSON 提交(字段同名);空文本 400「分享内容不能为空」;
|
||||
可选 code 自定义提取码(5-8 位字母或数字,占用 400「该提取码已被占用」)。
|
||||
requestBody:
|
||||
content:
|
||||
@@ -1375,6 +1385,16 @@ paths:
|
||||
storageLimit: { type: integer, format: int64 }
|
||||
uploadCount: { type: integer }
|
||||
uploadMinute: { type: integer }
|
||||
upload_rate: { type: integer, format: int64, description: '26.9 上行带宽字节/秒;0=不限速' }
|
||||
download_rate: { type: integer, format: int64, description: '26.9 下行带宽字节/秒;0=不限速' }
|
||||
recycle_enabled: { type: integer, enum: [0, 1], description: '26.9 过期分享自动回收开关' }
|
||||
recycle_interval: { type: integer, format: int64, description: '26.9 回收扫描间隔秒(60~86400)' }
|
||||
retention_days: { type: integer, format: int64, description: '26.9 最长存储时长天(0~3650)' }
|
||||
dedup_enabled: { type: integer, enum: [0, 1], description: '26.9 SHA512 内容去重开关' }
|
||||
direct_download: { type: integer, enum: [0, 1], description: '26.9 对象存储直链下载开关' }
|
||||
direct_link_expire: { type: integer, format: int64, description: '26.9 直链签名有效期秒(60~3600)' }
|
||||
hotlink_enabled: { type: integer, enum: [0, 1], description: '26.9 下载防盗链开关' }
|
||||
hotlink_whitelist: { type: string, maxLength: 2048, description: '26.9 防盗链白名单' }
|
||||
admin_token: { type: string, example: '' }
|
||||
_engine_hint:
|
||||
type: object
|
||||
@@ -1525,6 +1545,33 @@ paths:
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
"503": { $ref: "#/components/responses/ServiceUnavailable" }
|
||||
|
||||
/admin/recycle/run:
|
||||
post:
|
||||
tags: [admin]
|
||||
summary: 手动触发一轮过期回收(26.9)
|
||||
description: |
|
||||
回收时间已过期、次数已耗尽、创建时间超过 retention_days 的分享:
|
||||
删除记录并连带删除存储对象(dedup_enabled 开启时做引用计数,
|
||||
仍有其他分享引用的对象保留)。返回本轮回收条数。
|
||||
operationId: adminRecycleRun
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: 回收完成
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Envelope'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
removed: { type: integer, description: 本轮回收条数 }
|
||||
"401": { $ref: '#/components/responses/Unauthorized' }
|
||||
|
||||
/admin/settings/password:
|
||||
patch:
|
||||
tags: [管理后台]
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
- 位置:`server/internal/api/helpers.go:114-125`、`validatePickupCode`(4 位下限)
|
||||
- 原状:`code_generate_type=number` 时仅 9 万空间(5 位数字),默认 `errorCount=10/分/IP` 下单 IP 需约 6 天扫完,分布式多 IP 可显著缩短;自定义码允许 4 位(36⁴≈168 万)。
|
||||
- ✅ 修复:自定义提码最小长度 4 → **5 位**(`pickupCodeMinLen=5`,36⁵≈6000 万空间);测试 `v31_test.go` 与 `TestPickupCodeMinLen` 同步更新。
|
||||
- ✅ 修复:自定义提码最小长度 4 → **5 位**(`pickupCodeMinLen=5`,36⁵≈6000 万空间);测试 `custom_code_test.go` 与 `TestPickupCodeMinLen` 同步更新。
|
||||
|
||||
### L4 `enableChunk` 开关后端不强制 ✅ 已修复
|
||||
|
||||
|
||||
+7
-6
@@ -11,7 +11,7 @@ server/
|
||||
├── cmd/server/main.go # 入口:装配 配置→DB→缓存→设置→审计→中间件→路由
|
||||
└── internal/
|
||||
├── config/ # 配置:FCB_* 环境变量基线 + DB settings KV 运行时覆盖
|
||||
│ └── schema.go # v2 新配置键 schema(键名/类型/默认值/边界)
|
||||
│ └── schema.go # 26.9 新配置键 schema(键名/类型/默认值/边界)
|
||||
├── model/ # GORM 模型 + 双方言 AutoMigrate
|
||||
├── cache/ # 缓存统一接口:redis.go / memory.go 双实现
|
||||
├── database/ # 双方言连接与迁移(sqlite 默认 / postgres 可选)
|
||||
@@ -27,7 +27,7 @@ server/
|
||||
| 变量 | 必需 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `FCB_DB_DRIVER` | ❌ | `sqlite` | 数据库驱动:`sqlite` \| `postgres`(需求 ⑧) |
|
||||
| `FCB_DB_DSN` | 视驱动 | `./data/filecodebox.db` | postgres:连接串(**必需**),如 `postgres://user:pass@host:5432/filecodebox?sslmode=disable`;sqlite:数据库文件路径(可空,自动创建 `data/` 目录) |
|
||||
| `FCB_DB_DSN` | 视驱动 | `./data/fileshare.db` | postgres:连接串(**必需**),如 `postgres://user:pass@host:5432/fileshare?sslmode=disable`;sqlite:数据库文件路径(可空,自动创建 `data/` 目录) |
|
||||
| `FCB_REDIS_ADDR` | ❌ | 空 | 为空时缓存降级为内存实现 |
|
||||
| `FCB_LISTEN` | ❌ | `:8466` | 监听地址 |
|
||||
| `FCB_STORAGE_ENGINE` | ❌ | `local` | `local` \| `s3` \| `webdav` |
|
||||
@@ -36,14 +36,14 @@ server/
|
||||
### 数据库模式(需求 ⑧)
|
||||
|
||||
- **SQLite(默认)**:`FCB_DB_DRIVER=sqlite`(或缺省)。零 DSN 零依赖启动,数据库文件
|
||||
默认 `./data/filecodebox.db`(`FCB_DB_DSN` 可覆盖路径;父目录自动创建)。
|
||||
默认 `./data/fileshare.db`(`FCB_DB_DSN` 可覆盖路径;父目录自动创建)。
|
||||
连接参数:`busy_timeout=10s` + `WAL` 日志模式 + `foreign_keys=1`(通过 DSN pragma 注入)。
|
||||
- **Postgres(可选)**:`FCB_DB_DRIVER=postgres` 且必须提供 `FCB_DB_DSN`,否则启动报错。
|
||||
连接池沿用 v1 参数(32/8、1h 轮换)。
|
||||
- 双方言共用 GORM 抽象:AutoMigrate、settings KV、全部业务查询方言无关;
|
||||
唯一原生 DDL(migrates 台账表)在 `database.createMigratesTable` 内部分支处理。
|
||||
|
||||
## v2 新增配置键(internal/config/schema.go 为单一事实来源)
|
||||
## 26.9 新增配置键(internal/config/schema.go 为单一事实来源)
|
||||
|
||||
| 键 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
@@ -57,6 +57,7 @@ server/
|
||||
| `max_file_size` | int64 | `0` | 需求 ⑩:存储策略-单文件上限字节(0=回落 `uploadSize`,≤10GiB) |
|
||||
| `uploadSize` / `allowed_file_types` / `storageLimit` / `openUpload` | 既有 | - | 存储策略既有键(语义不变) |
|
||||
| `uploadCount` / `uploadMinute` | 既有 | `10` / `1` | 上传频率限制(对齐参考 `ip_limit["upload"]`) |
|
||||
| `upload_rate` / `download_rate` | int64 | `0` / `0` | **26.9 带宽限速**:上下行字节/秒(0=不限速,≤1 GiB/s)。管理端改后立即生效(每请求动态读 KV);上传侧 middleware 包裹 `Request.Body`,下载侧在 `serveFile` 包裹 `storage.ReadCloser`;S3 预签名直传(客户端→S3)服务端无法限速。算法为时间窗对齐 sleep(`middleware/bandwidth.go`) |
|
||||
|
||||
管理端与文档(t2/t4)以 `config.KVSchema()`(`settings.KVSchema()` re-export)为元数据源;
|
||||
schema 同步测试保证 `KVSchema()` 与 `defaults()` 逐键一致。
|
||||
@@ -144,13 +145,13 @@ middleware.AuditRecordRequest(c, auditSvc, model.AuditResultSuccess, "")
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
# 默认 SQLite 模式:零依赖,数据库落 ./data/filecodebox.db
|
||||
# 默认 SQLite 模式:零依赖,数据库落 ./data/fileshare.db
|
||||
go run ./cmd/server # 启动于 :8466
|
||||
curl localhost:8466/api/v1/health
|
||||
|
||||
# Postgres 模式(可选)
|
||||
export FCB_DB_DRIVER=postgres
|
||||
export FCB_DB_DSN='postgres://postgres:postgres@localhost:5432/filecodebox?sslmode=disable'
|
||||
export FCB_DB_DSN='postgres://postgres:postgres@localhost:5432/fileshare?sslmode=disable'
|
||||
go run ./cmd/server
|
||||
|
||||
# 双方言单测:sqlite 始终执行;postgres 需真实实例(FCB_TEST_PG_DSN 指向测试库)
|
||||
|
||||
+26
-15
@@ -15,19 +15,26 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"filecodebox/internal/api"
|
||||
"filecodebox/internal/audit"
|
||||
"filecodebox/internal/cache"
|
||||
"filecodebox/internal/config"
|
||||
"filecodebox/internal/database"
|
||||
"filecodebox/internal/janitor"
|
||||
"filecodebox/internal/middleware"
|
||||
"filecodebox/internal/settings"
|
||||
"filecodebox/internal/storage"
|
||||
"fileshare/internal/api"
|
||||
"fileshare/internal/audit"
|
||||
"fileshare/internal/cache"
|
||||
"fileshare/internal/config"
|
||||
"fileshare/internal/database"
|
||||
"fileshare/internal/janitor"
|
||||
"fileshare/internal/middleware"
|
||||
"fileshare/internal/settings"
|
||||
"fileshare/internal/storage"
|
||||
)
|
||||
|
||||
// APP_VERSION 版本号,对齐参考仓库 VERSION。
|
||||
const APP_VERSION = "2.5.6"
|
||||
// APP_VERSION 版本号,对齐参考仓库 VERSION。26.9 起改为 var 以便
|
||||
// goreleaser 通过 -ldflags -X main.APP_VERSION=… 注入发布版本。
|
||||
//
|
||||
// 编译时可选注入(goreleaser 触发),不注入则保持默认 26.9。
|
||||
var (
|
||||
APP_VERSION = "26.9"
|
||||
BuildCommit = "dev"
|
||||
BuildDate = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
@@ -98,7 +105,7 @@ func main() {
|
||||
middleware.LimitMeta: {Count: cfg.GetInt("errorCount"), Window: minutes(cfg.GetInt("errorMinute"))},
|
||||
})
|
||||
|
||||
// 7. 存储引擎:注入配置 → 构造 → 健康预检(需求 ④;v3 包装为可热切换 Manager)
|
||||
// 7. 存储引擎:注入配置 → 构造 → 健康预检(需求 ④;26.9 包装为可热切换 Manager)
|
||||
storage.SetEngineOptions(buildEngineOptions(cfg))
|
||||
bootStore, err := storage.NewEngine(ctx, cfg.Engine())
|
||||
if err != nil {
|
||||
@@ -110,7 +117,7 @@ func main() {
|
||||
} else {
|
||||
log.Printf("[boot] 存储引擎 %s 健康检查通过", cfg.Engine())
|
||||
}
|
||||
// v3:Manager 包装——保存/读取委托当前引擎;管理端可热切换
|
||||
// 26.9:Manager 包装——保存/读取委托当前引擎;管理端可热切换
|
||||
//(构建闭包在每次切换前用最新 KV 刷新 EngineOptions,参数改动即时生效)
|
||||
store := storage.NewManager(cfg.Engine(), bootStore, func(name string) (storage.Storage, error) {
|
||||
storage.SetEngineOptions(buildEngineOptions(cfg))
|
||||
@@ -142,8 +149,12 @@ func main() {
|
||||
})
|
||||
|
||||
// 9. HTTP 服务
|
||||
// M5:后台清理循环(过期预留/超时会话/直传残留对象),启动后 10 分钟首跑
|
||||
janitor.Start(ctx, db, store, 10*time.Minute)
|
||||
// M5:后台清理循环(过期预留/超时会话/直传残留对象),启动后 10 分钟首跑。
|
||||
// 26.9:过期分享回收(recycle_enabled/recycle_interval/retention_days 动态读取)
|
||||
janitor.Start(ctx, db, store, 10*time.Minute, &janitor.Recycler{
|
||||
Enabled: cfg.RecycleEnabled,
|
||||
RetentionDays: cfg.RetentionDays,
|
||||
})
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Env.Listen,
|
||||
Handler: r,
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
module filecodebox
|
||||
module fileshare
|
||||
|
||||
go 1.27.1
|
||||
|
||||
@@ -13,6 +13,7 @@ require (
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/redis/go-redis/v9 v9.17.0
|
||||
golang.org/x/crypto v0.56.0
|
||||
gorm.io/driver/postgres v1.6.2
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
@@ -66,7 +67,6 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.56.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -12,12 +13,13 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"filecodebox/internal/config"
|
||||
"filecodebox/internal/middleware"
|
||||
"filecodebox/internal/model"
|
||||
"filecodebox/internal/response"
|
||||
"filecodebox/internal/settings"
|
||||
"filecodebox/internal/storage"
|
||||
"fileshare/internal/config"
|
||||
"fileshare/internal/janitor"
|
||||
"fileshare/internal/middleware"
|
||||
"fileshare/internal/model"
|
||||
"fileshare/internal/response"
|
||||
"fileshare/internal/settings"
|
||||
"fileshare/internal/storage"
|
||||
)
|
||||
|
||||
// minutesDuration 分钟数转 Duration(0 回退 1 分钟,对齐 main.go 语义)。
|
||||
@@ -78,9 +80,12 @@ func registerAdmin(r *gin.Engine, d *Deps) {
|
||||
authed.PATCH("/settings/password", d.adminChangePassword)
|
||||
authed.POST("/settings/password", d.adminChangePassword)
|
||||
|
||||
// v3 存储引擎:运行时热切换(健康检查通过才生效,失败保持原引擎)
|
||||
// 26.9 存储引擎:运行时热切换(健康检查通过才生效,失败保持原引擎)
|
||||
authed.POST("/storage/switch", d.adminStorageSwitch)
|
||||
|
||||
// 26.9 过期回收:手动触发一轮(定时循环之外的管理端入口)
|
||||
authed.POST("/recycle/run", d.adminRecycleRun)
|
||||
|
||||
// 审计日志查询(需求 ③;logs 为 list 的别名)
|
||||
authed.GET("/audit/list", d.adminAuditList)
|
||||
authed.GET("/audit/logs", d.adminAuditList)
|
||||
@@ -404,7 +409,7 @@ func buildAdminFileItem(fc *model.FileCodes, now time.Time) gin.H {
|
||||
"hasDownloadLimit": fc.ExpiredCount >= 0, "has_download_limit": fc.ExpiredCount >= 0,
|
||||
"isPermanent": fc.ExpiredAt == nil && fc.ExpiredCount < 0, "is_permanent": fc.ExpiredAt == nil && fc.ExpiredCount < 0,
|
||||
"remainingDownloads": remaining, "remaining_downloads": remaining,
|
||||
"engine": fc.Engine, // v3:归属引擎(管理端展示/排查用)
|
||||
"engine": fc.Engine, // 26.9:归属引擎(管理端展示/排查用)
|
||||
}
|
||||
if fc.FileHash != nil {
|
||||
item["fileHash"] = *fc.FileHash
|
||||
@@ -784,7 +789,7 @@ func (d *Deps) adminFilePreview(c *gin.Context) {
|
||||
// ============ 配置 ============
|
||||
|
||||
// configKeys 管理端可见/可改的配置键(不含 jwt_secret;admin_token 屏蔽展示)。
|
||||
// v2 新增键(需求 ①②③④⑩):背景图、页脚、通知开关、保存/存储策略、频率限制。
|
||||
// 26.9 新增键(需求 ①②③④⑩):背景图、页脚、通知开关、保存/存储策略、频率限制。
|
||||
var configKeys = []string{
|
||||
"site_name", "name", "description", "page_explain", "keywords",
|
||||
"notify_title", "notify_content", "notify_enabled", "logo_url", "favicon_url",
|
||||
@@ -794,10 +799,14 @@ var configKeys = []string{
|
||||
"code_generate_type", "enableChunk",
|
||||
"uploadMinute", "uploadCount", "errorMinute", "errorCount",
|
||||
"loginCount", "loginMinute",
|
||||
"opacity", "background", "showAdminAddr", "robotsText", "site_domain", // v3.1:站点对外域名
|
||||
"opacity", "background", "showAdminAddr", "robotsText", "site_domain", // 26.9:站点对外域名
|
||||
"upload_rate", "download_rate", // 26.9:上下行带宽字节/秒(0=不限速)
|
||||
// 26.9 回收与下载安全
|
||||
"recycle_enabled", "recycle_interval", "retention_days", "dedup_enabled",
|
||||
"hotlink_enabled", "hotlink_whitelist", "direct_download", "direct_link_expire",
|
||||
"adminSessionExpire", "storage_path", "local_storage_path",
|
||||
"file_storage",
|
||||
// v3 存储引擎与引擎参数(热切换;凭据为敏感键,get 掩码/update 空跳过)
|
||||
// 26.9 存储引擎与引擎参数(热切换;凭据为敏感键,get 掩码/update 空跳过)
|
||||
"storage_engine",
|
||||
"local_storage_path",
|
||||
"webdav_url", "webdav_root_path", "webdav_username", "webdav_password",
|
||||
@@ -806,7 +815,7 @@ var configKeys = []string{
|
||||
}
|
||||
|
||||
// intConfigKeys 需按 schema 边界校验的整型键(adminConfigUpdate 归一化用)。
|
||||
// v1 既有键保留原语义;v2 新增键(max_file_size/max_save_count/notify_enabled)
|
||||
// v1 既有键保留原语义;26.9 新增键(max_file_size/max_save_count/notify_enabled)
|
||||
// 的边界来自 settings.KVSchema(单一事实来源在 config/schema.go)。
|
||||
var intConfigKeys = []string{
|
||||
"openUpload", "enableChunk", "showAdminAddr", "storageLimit",
|
||||
@@ -814,6 +823,9 @@ var intConfigKeys = []string{
|
||||
"loginCount", "loginMinute", "max_save_seconds", "uploadSize",
|
||||
"adminSessionExpire",
|
||||
"max_save_count", "max_file_size", "notify_enabled",
|
||||
"upload_rate", "download_rate", // 26.9
|
||||
"recycle_enabled", "recycle_interval", "retention_days", "dedup_enabled",
|
||||
"hotlink_enabled", "direct_download", "direct_link_expire", // 26.9(hotlink_whitelist 为字符串键)
|
||||
}
|
||||
|
||||
// validateConfigValue 按 settings.KVSchema 校验单个配置值:
|
||||
@@ -903,7 +915,7 @@ func toStrSlice(v any) []string {
|
||||
}
|
||||
|
||||
// adminConfigGet 读取配置(对齐参考 get_config:admin_token 屏蔽、jwt_secret 不下发)。
|
||||
// v3:引擎凭据类敏感键返回掩码占位(前端表单"留空=不修改");storage_engine 为当前热切换后的引擎。
|
||||
// 26.9:引擎凭据类敏感键返回掩码占位(前端表单"留空=不修改");storage_engine 为当前热切换后的引擎。
|
||||
func (d *Deps) adminConfigGet(c *gin.Context) {
|
||||
cfg := d.Cfg
|
||||
out := gin.H{}
|
||||
@@ -923,7 +935,7 @@ func (d *Deps) adminConfigGet(c *gin.Context) {
|
||||
}
|
||||
// jwt_secret 永不下发
|
||||
delete(out, "jwt_secret")
|
||||
// 引擎运行时状态(v3:热切换即时生效,无需重启)
|
||||
// 引擎运行时状态(26.9:热切换即时生效,无需重启)
|
||||
out["_engine_hint"] = gin.H{
|
||||
"storage_backend": d.Store.CurrentName(),
|
||||
"engines": gin.H{"local": true, "s3": true, "webdav": true},
|
||||
@@ -971,7 +983,7 @@ func (d *Deps) adminConfigUpdate(c *gin.Context) {
|
||||
if !known {
|
||||
continue
|
||||
}
|
||||
// 类型归一 + schema 边界校验(v2:数值/字符串长度/列表键统一走 KVSchema)
|
||||
// 类型归一 + schema 边界校验(26.9:数值/字符串长度/列表键统一走 KVSchema)
|
||||
isInt := false
|
||||
for _, key := range intConfigKeys {
|
||||
if key == k {
|
||||
@@ -1065,7 +1077,7 @@ func (d *Deps) adminConfigUpdate(c *gin.Context) {
|
||||
dbPatch["jwt_secret"] = settings.GenerateJWTSecret()
|
||||
}
|
||||
|
||||
// v3.1:site_domain 规范化(http(s)://host[:port];空=用当前地址)
|
||||
// 26.9:site_domain 规范化(http(s)://host[:port];空=用当前地址)
|
||||
if raw, ok := dbPatch["site_domain"]; ok {
|
||||
sv, _ := raw.(string)
|
||||
normalized, err := normalizeSiteDomain(sv)
|
||||
@@ -1076,7 +1088,7 @@ func (d *Deps) adminConfigUpdate(c *gin.Context) {
|
||||
dbPatch["site_domain"] = normalized
|
||||
}
|
||||
|
||||
// v3 引擎键处理:参数键与 storage_engine 分离。
|
||||
// 26.9 引擎键处理:参数键与 storage_engine 分离。
|
||||
// 1) storage_engine 只接受合法枚举;
|
||||
// 2) 敏感凭据键空串/掩码=不修改(避免管理端表单回显把密钥抹掉);
|
||||
// 3) 先持久化普通键+参数键 → Invalidate 对应引擎缓存 → 再尝试 Switch 新引擎;
|
||||
@@ -1150,7 +1162,7 @@ func engineOfParamKeys(patch map[string]any) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// adminStorageSwitch v3 存储引擎热切换:{engine:"local"|"s3"|"webdav"}。
|
||||
// adminStorageSwitch 26.9 存储引擎热切换:{engine:"local"|"s3"|"webdav"}。
|
||||
// 成功:持久化 storage_engine KV 并返回当前引擎;失败:503 且原引擎不变。
|
||||
func (d *Deps) adminStorageSwitch(c *gin.Context) {
|
||||
var body struct {
|
||||
@@ -1298,11 +1310,15 @@ func (d *Deps) fileByID(c *gin.Context, id int64) (*model.FileCodes, error) {
|
||||
}
|
||||
|
||||
// deleteFileCode 删除分享记录与存储文件(文本分享无存储文件)。
|
||||
// 26.9:SHA512 去重开启时同一对象可能被多条分享引用——删除前按
|
||||
// ContentHash+Engine+UUIDFileName 引用计数,仍有其他引用则保留对象。
|
||||
func (d *Deps) deleteFileCode(c *gin.Context, fc *model.FileCodes) error {
|
||||
if fc.Text == nil && fc.FilePath != nil && fc.UUIDFileName != nil {
|
||||
// v3:删除走文件归属引擎(旧引擎里的文件也要能删掉)
|
||||
// 26.9:删除走文件归属引擎(旧引擎里的文件也要能删掉)
|
||||
if delStore, dErr := d.storeFor(fc.Engine); dErr == nil {
|
||||
if err := delStore.DeleteFile(c.Request.Context(), fileSavePath(fc)); err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
if d.referencedByOther(c.Request.Context(), fc) {
|
||||
// 还有其他分享引用该对象:仅删记录
|
||||
} else if err := delStore.DeleteFile(c.Request.Context(), fileSavePath(fc)); err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
return errInternal("存储文件删除失败: " + err.Error())
|
||||
}
|
||||
}
|
||||
@@ -1314,6 +1330,31 @@ func (d *Deps) deleteFileCode(c *gin.Context, fc *model.FileCodes) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// referencedByOther 该分享的存储对象是否仍被其他分享引用(SHA512 去重)。
|
||||
// 无 ContentHash(历史数据/去重未开启)时恒 false——按旧语义直接删对象。
|
||||
func (d *Deps) referencedByOther(ctx context.Context, fc *model.FileCodes) bool {
|
||||
if fc.ContentHash == nil || *fc.ContentHash == "" || fc.UUIDFileName == nil {
|
||||
return false
|
||||
}
|
||||
var cnt int64
|
||||
_ = d.DB.WithContext(ctx).Model(&model.FileCodes{}).
|
||||
Where("content_hash = ? AND engine = ? AND uuid_file_name = ? AND id <> ?",
|
||||
*fc.ContentHash, fc.Engine, *fc.UUIDFileName, fc.ID).
|
||||
Count(&cnt).Error
|
||||
return cnt > 0
|
||||
}
|
||||
|
||||
// adminRecycleRun 手动触发一轮过期回收(26.9;定时循环之外的入口)。
|
||||
// 返回 {removed: 本轮回收条数}。
|
||||
func (d *Deps) adminRecycleRun(c *gin.Context) {
|
||||
removed := janitor.RecycleExpired(c.Request.Context(), d.DB, d.Store, &janitor.Recycler{
|
||||
Enabled: d.Cfg.RecycleEnabled,
|
||||
RetentionDays: d.Cfg.RetentionDays,
|
||||
})
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
response.OK(c, gin.H{"removed": removed})
|
||||
}
|
||||
|
||||
// deleteMany 批量删除:返回 (已删除, 不存在, 失败)。
|
||||
func (d *Deps) deleteMany(c *gin.Context, ids []int64) (deleted []int64, missing []int64, failed []gin.H) {
|
||||
deleted, missing = []int64{}, []int64{}
|
||||
|
||||
@@ -16,10 +16,10 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"filecodebox/internal/middleware"
|
||||
"filecodebox/internal/model"
|
||||
"filecodebox/internal/response"
|
||||
"filecodebox/internal/storage"
|
||||
"fileshare/internal/middleware"
|
||||
"fileshare/internal/model"
|
||||
"fileshare/internal/response"
|
||||
"fileshare/internal/storage"
|
||||
)
|
||||
|
||||
// chunkExpireTTL 分片会话保留时长(M5:预留窗口由 24h 缩短为 2h;
|
||||
@@ -99,7 +99,7 @@ func (d *Deps) chunkInit(c *gin.Context) {
|
||||
// 服务端按分片数上限校验总大小(防分片声明绕过)
|
||||
totalChunks := (req.FileSize + chunkSize - 1) / chunkSize
|
||||
maxPossible := totalChunks * chunkSize
|
||||
// v2 需求 ④⑩:动态策略校验(max_file_size,0=回落 uploadSize)
|
||||
// 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, "文件大小超过限制")
|
||||
@@ -165,7 +165,7 @@ func (d *Deps) chunkInit(c *gin.Context) {
|
||||
ChunkHash: req.FileHash,
|
||||
FileName: safeName,
|
||||
SavePath: savePath,
|
||||
Engine: d.Store.CurrentName(), // v3:会话归属引擎(分片/合并全程走同一引擎)
|
||||
Engine: d.Store.CurrentName(), // 26.9:会话归属引擎(分片/合并全程走同一引擎)
|
||||
}
|
||||
if err := d.DB.WithContext(ctx).Create(&session).Error; err != nil {
|
||||
releaseStorage(ctx, d.DB, resToken)
|
||||
@@ -389,7 +389,7 @@ func (d *Deps) saveOneChunk(c *gin.Context, ctx context.Context, session *model.
|
||||
ChunkSize: session.ChunkSize,
|
||||
FileName: session.FileName,
|
||||
SavePath: session.SavePath,
|
||||
Engine: session.Engine, // v3:继承会话引擎
|
||||
Engine: session.Engine, // 26.9:继承会话引擎
|
||||
}
|
||||
if err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ? AND chunk_index = ?", session.UploadID, idx).
|
||||
@@ -444,7 +444,7 @@ func (d *Deps) chunkStatus(c *gin.Context) {
|
||||
type chunkCompleteRequest struct {
|
||||
ExpireValue int `json:"expire_value" form:"expire_value"`
|
||||
ExpireStyle string `json:"expire_style" form:"expire_style"`
|
||||
Code string `json:"code" form:"code"` // v3.1:自定义提取码(4-8 位字母数字,空=随机)
|
||||
Code string `json:"code" form:"code"` // 26.9:自定义提取码(4-8 位字母数字,空=随机)
|
||||
}
|
||||
|
||||
// chunkComplete 合并分片并创建分享(对齐参考 complete_upload)。
|
||||
@@ -489,7 +489,7 @@ func (d *Deps) chunkComplete(c *gin.Context) {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// v3.1:自定义提取码(合并前校验,失败快速返回)
|
||||
// 26.9:自定义提取码(合并前校验,失败快速返回)
|
||||
if err := validatePickupCode(req.Code); err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "提取码非法")
|
||||
@@ -539,7 +539,7 @@ func (d *Deps) chunkComplete(c *gin.Context) {
|
||||
}
|
||||
return rec.ChunkHash, nil
|
||||
}
|
||||
// v3:合并走会话归属引擎(会话创建时的引擎,即使中途热切换也不受影响)
|
||||
// 26.9:合并走会话归属引擎(会话创建时的引擎,即使中途热切换也不受影响)
|
||||
mergeStore, sErr := d.storeFor(session.Engine)
|
||||
if sErr != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, session.FileSize)
|
||||
@@ -556,7 +556,7 @@ func (d *Deps) chunkComplete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 创建分享记录(v3.1:支持自定义提取码)
|
||||
// 创建分享记录(26.9:支持自定义提取码)
|
||||
code, err := pickCustomCode(ctx, d.DB, d.Cfg, req.Code)
|
||||
if err == nil {
|
||||
fc := model.FileCodes{
|
||||
@@ -568,7 +568,7 @@ func (d *Deps) chunkComplete(c *gin.Context) {
|
||||
ExpiredAt: exp.ExpiredAt,
|
||||
ExpiredCount: exp.ExpiredCount,
|
||||
UsedCount: exp.UsedCount,
|
||||
Engine: session.Engine, // v3:归属引擎戳
|
||||
Engine: session.Engine, // 26.9:归属引擎戳
|
||||
}
|
||||
// 拆分路径与文件名(对齐参考:path=dirname(save_path), uuid=basename)
|
||||
dir, name := splitDirBase(session.SavePath)
|
||||
@@ -578,7 +578,11 @@ func (d *Deps) chunkComplete(c *gin.Context) {
|
||||
fc.Prefix = trimExt(name)
|
||||
fc.Suffix = ext
|
||||
err = d.DB.WithContext(ctx).Create(&fc).Error
|
||||
err = mapCodeConflict(err) // v3.1
|
||||
err = mapCodeConflict(err) // 26.9
|
||||
if err == nil {
|
||||
// 26.9:SHA512 内容去重(命中则复用旧对象并删除本次副本)
|
||||
d.applyDedup(ctx, mergeStore, session.SavePath, &fc)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
// 成功:清理分片与记录(走归属引擎)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package api
|
||||
|
||||
// v3.1:自定义提取码与站点域名单元测试。
|
||||
// 26.9:自定义提取码与站点域名单元测试。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// postForm 以 urlencoded 表单调用 handler(v3.1 测试辅助)。
|
||||
// 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 {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -30,12 +30,12 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"filecodebox/internal/audit"
|
||||
"filecodebox/internal/config"
|
||||
"filecodebox/internal/middleware"
|
||||
"filecodebox/internal/model"
|
||||
"filecodebox/internal/response"
|
||||
"filecodebox/internal/storage"
|
||||
"fileshare/internal/audit"
|
||||
"fileshare/internal/config"
|
||||
"fileshare/internal/middleware"
|
||||
"fileshare/internal/model"
|
||||
"fileshare/internal/response"
|
||||
"fileshare/internal/storage"
|
||||
)
|
||||
|
||||
// apiError 统一的业务错误:handler 返回该错误并由 respondError 映射 HTTP 状态。
|
||||
@@ -125,7 +125,7 @@ func generateCode(style string) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ============ 自定义提取码(v3.1,防撞库)============
|
||||
// ============ 自定义提取码(26.9,防撞库)============
|
||||
|
||||
const (
|
||||
// pickupCodeMinLen 最小长度:L3 由 4 提升至 5(4 位码空间仅 168 万,
|
||||
@@ -172,7 +172,7 @@ func pickCustomCode(ctx context.Context, db *gorm.DB, cfg *config.Config, custom
|
||||
return custom, nil
|
||||
}
|
||||
|
||||
// mapCodeConflict v3.1:分享记录创建失败时,若是自定义码唯一索引冲突(并发兜底,
|
||||
// mapCodeConflict 26.9:分享记录创建失败时,若是自定义码唯一索引冲突(并发兜底,
|
||||
// pickCustomCode 的预查重未覆盖竞态),转为友好 400;其余错误原样返回。
|
||||
func mapCodeConflict(err error) error {
|
||||
if err == nil {
|
||||
@@ -188,9 +188,9 @@ func mapCodeConflict(err error) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ============ 站点对外域名(v3.1)============
|
||||
// ============ 站点对外域名(26.9)============
|
||||
|
||||
// SiteDomain 站点对外域名规范化(v3.1):空串合法(分享链接用当前访问地址)。
|
||||
// SiteDomain 站点对外域名规范化(26.9):空串合法(分享链接用当前访问地址)。
|
||||
// 接受 http(s)://host[:port] 或裸 host[:port](自动补 http://,内网场景)。
|
||||
// 拒绝路径/查询/片段/用户信息/非 http(s) 协议/非法主机字符(防 javascript: 注入分享链接)。
|
||||
var siteDomainHostRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$`)
|
||||
@@ -275,7 +275,7 @@ type expireResult struct {
|
||||
|
||||
// resolveExpire 校验 expire_style 白名单并计算过期信息。
|
||||
// 对齐参考:max_save_seconds>0 时为最长保存上限(超限 403),否则默认 7 天上限;
|
||||
// v2 需求 ④:style=count 时 expire_value 不得超出 max_save_count(0=不限制,超限 403)。
|
||||
// 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
|
||||
@@ -358,7 +358,7 @@ func formatDurationCN(d time.Duration) string {
|
||||
|
||||
// ============ 存储路径 / 容量预留 ============
|
||||
|
||||
// storeFor v3:按归属引擎取存储实例(空戳/未知名回落当前引擎,兼容历史数据)。
|
||||
// storeFor 26.9:按归属引擎取存储实例(空戳/未知名回落当前引擎,兼容历史数据)。
|
||||
func (d *Deps) storeFor(engine string) (storage.Storage, error) {
|
||||
if engine == "" || !storage.ValidEngine(engine) {
|
||||
return d.Store, nil
|
||||
@@ -656,7 +656,7 @@ func readMultipartHeader(f multipart.File, n int64) []byte {
|
||||
// ============ 杂项 ============
|
||||
|
||||
// humanSize 把字节数转成人类可读描述(B/KB/MB/GB 自适应;
|
||||
// v2 需求④:max_file_size 支持子 MB 上限,固定 MB 格式会显示 0.00 MB)。
|
||||
// 26.9 需求④:max_file_size 支持子 MB 上限,固定 MB 格式会显示 0.00 MB)。
|
||||
func humanSize(n int64) string {
|
||||
const kb, mb, gb = int64(1024), int64(1024 * 1024), int64(1024 * 1024 * 1024)
|
||||
switch {
|
||||
@@ -728,7 +728,7 @@ func bindJSONOrForm(c *gin.Context, obj any) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// v3.1.1 兼容归一化:旧前端 bundle(fetch 字符串 body 默认 text/plain)发的
|
||||
// 26.9 兼容归一化:旧前端 bundle(fetch 字符串 body 默认 text/plain)发的
|
||||
// 是 text/plain + urlencoded 格式。此类请求改写 Content-Type 后走表单绑定,
|
||||
// 否则 ShouldBind 对 text/plain 不解析,非空字段全部丢失。
|
||||
base := ct
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"filecodebox/internal/config"
|
||||
"filecodebox/internal/storage"
|
||||
"fileshare/internal/config"
|
||||
"fileshare/internal/storage"
|
||||
)
|
||||
|
||||
// newTestConfig 构造测试配置(defaults 基线,无 KV 覆盖;需求 ⑧ 默认 sqlite,无需真实数据库)。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// policy.go — v2 上传策略统一读取与校验(需求 ④⑩)。
|
||||
// policy.go — 26.9 上传策略统一读取与校验(需求 ④⑩)。
|
||||
//
|
||||
// 管理端在后台设置页修改策略(settings KV,t1 schema)后,上传链路
|
||||
// (share/file、chunk、presign)每次请求实时读取当前策略并动态校验:
|
||||
|
||||
@@ -15,13 +15,13 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"filecodebox/internal/audit"
|
||||
"filecodebox/internal/cache"
|
||||
"filecodebox/internal/config"
|
||||
"filecodebox/internal/database"
|
||||
"filecodebox/internal/middleware"
|
||||
"filecodebox/internal/settings"
|
||||
"filecodebox/internal/storage"
|
||||
"fileshare/internal/audit"
|
||||
"fileshare/internal/cache"
|
||||
"fileshare/internal/config"
|
||||
"fileshare/internal/database"
|
||||
"fileshare/internal/middleware"
|
||||
"fileshare/internal/settings"
|
||||
"fileshare/internal/storage"
|
||||
)
|
||||
|
||||
// ============ 测试环境装配(真实 sqlite + 内存缓存 + 本地存储)============
|
||||
@@ -55,7 +55,7 @@ func newPolicyTestDeps(t *testing.T) *Deps {
|
||||
if err != nil {
|
||||
t.Fatalf("storage.NewLocalStorage: %v", err)
|
||||
}
|
||||
// v3:包装为 Manager(build 直接返回 local 实例,测试无需真实多引擎)
|
||||
// 26.9:包装为 Manager(build 直接返回 local 实例,测试无需真实多引擎)
|
||||
storeMgr := storage.NewManager("local", store, func(string) (storage.Storage, error) {
|
||||
return storage.NewLocalStorage(filepath.Join(dir, "storage"))
|
||||
})
|
||||
@@ -141,13 +141,13 @@ func respBody(t *testing.T, w *httptest.ResponseRecorder) (code int, data map[st
|
||||
// pngMagic 最小合法 PNG 头(magic 校验可识别)。
|
||||
var pngMagic = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
|
||||
|
||||
// ============ ① 公开 config:v2 展示与策略字段下发 ============
|
||||
// ============ ① 公开 config:26.9 展示与策略字段下发 ============
|
||||
|
||||
// TestPublicConfigV2Fields 验证 /api/v1/config 下发背景/页脚/备案/通知与策略范围,
|
||||
// 且响应不包含任何敏感键(admin_token/jwt_secret)。
|
||||
func TestPublicConfigV2Fields(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
// 管理端先设置 v2 展示字段
|
||||
// 管理端先设置 26.9 展示字段
|
||||
if w := patchConfig(d, map[string]any{
|
||||
"background_url": "https://cdn.example.com/bg.png",
|
||||
"footer_text": "自定义页脚内容",
|
||||
@@ -204,9 +204,9 @@ func TestPublicConfigV2Fields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ ② 管理端 get/update:v2 键全链路 + 类型范围校验 ============
|
||||
// ============ ② 管理端 get/update:26.9 键全链路 + 类型范围校验 ============
|
||||
|
||||
// TestAdminConfigV2RoundTrip 验证 v2 新键 update → get → public 的往返,
|
||||
// TestAdminConfigV2RoundTrip 验证 26.9 新键 update → get → public 的往返,
|
||||
// 且 admin_token 屏蔽、jwt_secret 不下发。
|
||||
func TestAdminConfigV2RoundTrip(t *testing.T) {
|
||||
d := newPolicyTestDeps(t)
|
||||
@@ -480,7 +480,7 @@ func TestPolicySnapshotMatchesConfig(t *testing.T) {
|
||||
// 编译期保证 fmt 被使用(测试辅助函数中错误路径占位)。
|
||||
var _ = fmt.Sprintf
|
||||
|
||||
// ============ v3 存储引擎热切换 ============
|
||||
// ============ 26.9 存储引擎热切换 ============
|
||||
|
||||
// switchEngine 调用 POST /admin/storage/switch。
|
||||
func switchEngine(d *Deps, engine string) *httptest.ResponseRecorder {
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"filecodebox/internal/middleware"
|
||||
"filecodebox/internal/model"
|
||||
"filecodebox/internal/response"
|
||||
"filecodebox/internal/storage"
|
||||
"fileshare/internal/middleware"
|
||||
"fileshare/internal/model"
|
||||
"fileshare/internal/response"
|
||||
"fileshare/internal/storage"
|
||||
)
|
||||
|
||||
// presignSessionExpires 预签名会话有效期(对齐参考 PRESIGN_SESSION_EXPIRES=900 秒)。
|
||||
@@ -51,7 +51,7 @@ type presignInitRequest struct {
|
||||
FileSize int64 `json:"file_size" form:"file_size"`
|
||||
ExpireValue int `json:"expire_value" form:"expire_value"`
|
||||
ExpireStyle string `json:"expire_style" form:"expire_style"`
|
||||
Code string `json:"code" form:"code"` // v3.1:自定义提取码(init 时校验,完成时落库)
|
||||
Code string `json:"code" form:"code"` // 26.9:自定义提取码(init 时校验,完成时落库)
|
||||
}
|
||||
|
||||
// presignInit 初始化预签名上传(对齐参考 presign_upload_init):
|
||||
@@ -74,7 +74,7 @@ func (d *Deps) presignInit(c *gin.Context) {
|
||||
response.Fail(c, http.StatusBadRequest, "文件名非法")
|
||||
return
|
||||
}
|
||||
// v3.1:自定义提取码提前校验(init 时快速失败;完成请求须再次携带)
|
||||
// 26.9:自定义提取码提前校验(init 时快速失败;完成请求须再次携带)
|
||||
if err := validatePickupCode(req.Code); err != nil {
|
||||
auditRecordFailed(c, d.AuditSvc, "提取码非法")
|
||||
respondError(c, err)
|
||||
@@ -86,7 +86,7 @@ func (d *Deps) presignInit(c *gin.Context) {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// v2 需求 ④⑩:动态策略校验(max_file_size,0=回落 uploadSize)
|
||||
// 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, "文件大小超过限制")
|
||||
@@ -146,7 +146,7 @@ func (d *Deps) presignInit(c *gin.Context) {
|
||||
FileSize: req.FileSize,
|
||||
SavePath: savePath,
|
||||
Mode: mode,
|
||||
Engine: d.Store.CurrentName(), // v3:会话归属引擎
|
||||
Engine: d.Store.CurrentName(), // 26.9:会话归属引擎
|
||||
ExpireValue: req.ExpireValue,
|
||||
ExpireStyle: req.ExpireStyle,
|
||||
ExpiresAt: time.Now().Add(presignSessionExpires * time.Second),
|
||||
@@ -195,7 +195,7 @@ func (d *Deps) presignProxy(c *gin.Context) {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// v3.1:自定义提取码随代理上传表单携带(init 时已预校验)
|
||||
// 26.9:自定义提取码随代理上传表单携带(init 时已预校验)
|
||||
if err := validatePickupCode(c.PostForm("code")); err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "提取码非法")
|
||||
@@ -234,7 +234,7 @@ func (d *Deps) presignProxy(c *gin.Context) {
|
||||
if err == nil {
|
||||
defer func() { _ = f.Close() }()
|
||||
if err = validateFileMagic(d.Cfg, session.FileName, fh.Header.Get("Content-Type"), readMultipartHeader(f, 64)); err == nil {
|
||||
// v3:落盘走会话归属引擎
|
||||
// 26.9:落盘走会话归属引擎
|
||||
var ps storage.Storage
|
||||
ps, sErr := d.storeFor(session.Engine)
|
||||
if sErr != nil {
|
||||
@@ -259,7 +259,7 @@ func (d *Deps) presignProxy(c *gin.Context) {
|
||||
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
||||
if err != nil {
|
||||
if ps, sErr := d.storeFor(session.Engine); sErr == nil {
|
||||
_ = ps.DeleteFile(ctx, session.SavePath) // v3:清理走归属引擎
|
||||
_ = ps.DeleteFile(ctx, session.SavePath) // 26.9:清理走归属引擎
|
||||
}
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "创建分享失败")
|
||||
@@ -299,7 +299,7 @@ func (d *Deps) presignConfirm(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// v3:直传文件存在性按会话归属引擎检查(直传可能落在旧引擎)
|
||||
// 26.9:直传文件存在性按会话归属引擎检查(直传可能落在旧引擎)
|
||||
psCheck, sErr := d.storeFor(session.Engine)
|
||||
if sErr != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
@@ -307,7 +307,7 @@ func (d *Deps) presignConfirm(c *gin.Context) {
|
||||
respondError(c, mapStorageError(sErr))
|
||||
return
|
||||
}
|
||||
// v3.1:自定义提取码随确认请求携带(query 或 JSON/form body,均可选)
|
||||
// 26.9:自定义提取码随确认请求携带(query 或 JSON/form body,均可选)
|
||||
customCode := c.Query("code")
|
||||
if customCode == "" && c.Request.Body != nil && c.Request.ContentLength != 0 {
|
||||
var fin struct {
|
||||
@@ -378,7 +378,7 @@ func (d *Deps) presignConfirm(c *gin.Context) {
|
||||
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
||||
if err != nil {
|
||||
if ps, sErr := d.storeFor(session.Engine); sErr == nil {
|
||||
_ = ps.DeleteFile(ctx, session.SavePath) // v3:清理走归属引擎
|
||||
_ = ps.DeleteFile(ctx, session.SavePath) // 26.9:清理走归属引擎
|
||||
}
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "创建分享失败")
|
||||
@@ -399,7 +399,7 @@ func (d *Deps) createRecordFromSession(c *gin.Context, session *model.PresignUpl
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// v3.1:完成请求的自定义提取码兜底校验(init 已验,防只发完成请求绕过)
|
||||
// 26.9:完成请求的自定义提取码兜底校验(init 已验,防只发完成请求绕过)
|
||||
if err := validatePickupCode(customCode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -420,10 +420,14 @@ func (d *Deps) createRecordFromSession(c *gin.Context, session *model.PresignUpl
|
||||
ExpiredAt: exp.ExpiredAt,
|
||||
ExpiredCount: exp.ExpiredCount,
|
||||
UsedCount: exp.UsedCount,
|
||||
Engine: session.Engine, // v3:归属引擎戳
|
||||
Engine: session.Engine, // 26.9:归属引擎戳
|
||||
}
|
||||
if err := d.DB.WithContext(ctx).Create(&fc).Error; err != nil {
|
||||
return "", mapCodeConflict(err) // v3.1:并发占用自定义码 → 友好 400
|
||||
return "", mapCodeConflict(err) // 26.9:并发占用自定义码 → 友好 400
|
||||
}
|
||||
// 26.9:SHA512 内容去重(命中则复用旧对象并删除本次副本)
|
||||
if store, sErr := d.storeFor(session.Engine); sErr == nil {
|
||||
d.applyDedup(ctx, store, session.SavePath, &fc)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
@@ -474,7 +478,7 @@ func (d *Deps) presignCancel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if session.Mode == "direct" {
|
||||
// v3:清理走会话归属引擎
|
||||
// 26.9:清理走会话归属引擎
|
||||
if ps, sErr := d.storeFor(session.Engine); sErr == nil {
|
||||
if exists, eErr := ps.FileExists(ctx, session.SavePath); eErr == nil && exists {
|
||||
_ = ps.DeleteFile(ctx, session.SavePath)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -6,12 +6,12 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"filecodebox/internal/audit"
|
||||
"filecodebox/internal/config"
|
||||
"filecodebox/internal/middleware"
|
||||
"filecodebox/internal/response"
|
||||
"filecodebox/internal/settings"
|
||||
"filecodebox/internal/storage"
|
||||
"fileshare/internal/audit"
|
||||
"fileshare/internal/config"
|
||||
"fileshare/internal/middleware"
|
||||
"fileshare/internal/response"
|
||||
"fileshare/internal/settings"
|
||||
"fileshare/internal/storage"
|
||||
)
|
||||
|
||||
// Deps API 层共享依赖(main.go 装配后注入)。
|
||||
@@ -21,7 +21,7 @@ type Deps struct {
|
||||
Mgr *settings.Manager
|
||||
AuditSvc *audit.Service
|
||||
Limiter *middleware.RateLimiter
|
||||
Store *storage.Manager // v3:可热切换引擎管理器(实现 Storage 接口)
|
||||
Store *storage.Manager // 26.9:可热切换引擎管理器(实现 Storage 接口)
|
||||
Version string
|
||||
}
|
||||
|
||||
@@ -46,13 +46,14 @@ func Register(r *gin.Engine, d *Deps) {
|
||||
share := r.Group("/share")
|
||||
{
|
||||
share.POST("/text", d.shareText)
|
||||
share.POST("/file", d.shareFile)
|
||||
share.POST("/file", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.shareFile)
|
||||
// metadata:每次访问即计数(RequireRateLimit=进入检查+完成计数)
|
||||
share.GET("/metadata", d.Limiter.RequireRateLimit(middleware.LimitMeta), d.shareMetadata)
|
||||
share.POST("/metadata", d.Limiter.RequireRateLimit(middleware.LimitMeta), d.shareMetadataPost)
|
||||
share.GET("/select", d.shareSelect)
|
||||
share.POST("/select", d.shareSelectPost)
|
||||
share.GET("/download", d.shareDownload)
|
||||
// 26.9:下载防盗链(动态开关,空 Referer 放行)
|
||||
share.GET("/download", middleware.HotlinkMiddleware(d.Cfg), d.shareDownload)
|
||||
}
|
||||
|
||||
// —— 分片上传 ——
|
||||
@@ -61,8 +62,8 @@ func Register(r *gin.Engine, d *Deps) {
|
||||
chunk.POST("/upload/init", d.chunkInit)
|
||||
// 主路径(参考语义):/chunk/upload/{uploadID}/{index};
|
||||
// 扁平兼容:/chunk/upload + 表单/query 传 upload_id/chunk_index
|
||||
chunk.POST("/upload/:uploadID/:chunkIndex", d.chunkUpload)
|
||||
chunk.POST("/upload", d.chunkUploadFlat)
|
||||
chunk.POST("/upload/:uploadID/:chunkIndex", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.chunkUpload)
|
||||
chunk.POST("/upload", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.chunkUploadFlat)
|
||||
chunk.GET("/upload/status/:uploadID", d.chunkStatus)
|
||||
chunk.POST("/upload/complete/:uploadID", d.chunkComplete)
|
||||
chunk.DELETE("/upload/:uploadID", d.chunkCancel)
|
||||
@@ -72,7 +73,7 @@ func Register(r *gin.Engine, d *Deps) {
|
||||
presign := r.Group("/presign")
|
||||
{
|
||||
presign.POST("/upload/init", d.presignInit)
|
||||
presign.PUT("/upload/proxy/:uploadID", d.presignProxy)
|
||||
presign.PUT("/upload/proxy/:uploadID", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.presignProxy)
|
||||
presign.POST("/upload/confirm/:uploadID", d.presignConfirm)
|
||||
presign.GET("/upload/status/:uploadID", d.presignStatus)
|
||||
presign.DELETE("/upload/:uploadID", d.presignCancel)
|
||||
@@ -104,7 +105,7 @@ func (d *Deps) robotsText(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(d.Cfg.GetString("robotsText")))
|
||||
}
|
||||
|
||||
// publicConfig 公共配置(前端首页/上传页所需;v2 需求 ①②③④⑩ 扩展):
|
||||
// publicConfig 公共配置(前端首页/上传页所需;26.9 需求 ①②③④⑩ 扩展):
|
||||
// - 展示字段:站点信息、Logo/favicon、背景图、页脚文案/备案号、通知;
|
||||
// - 策略范围(上传页动态渲染):大小上限、类型白名单、过期方式、保存
|
||||
// 时间/次数上限、上传频率(仅范围,不含内部实现键)。
|
||||
@@ -132,9 +133,12 @@ func (d *Deps) publicConfig(c *gin.Context) {
|
||||
// 需求 ②:页脚自定义内容与备案号
|
||||
"footer_text": cfg.FooterText(),
|
||||
"footer_beian": cfg.FooterBeian(),
|
||||
// v3:当前存储引擎名(仅名称,任何引擎参数/凭据不下发)
|
||||
// 26.9:当前存储引擎名(仅名称,任何引擎参数/凭据不下发)
|
||||
"storage_engine": d.Store.CurrentName(),
|
||||
"site_domain": d.Cfg.SiteDomain(),
|
||||
// 26.9:上下行带宽字节/秒(公开下发,0=不限速,方便管理端展示当前值)
|
||||
"upload_rate": d.Cfg.UploadRate(),
|
||||
"download_rate": d.Cfg.DownloadRate(),
|
||||
// 需求 ③:系统通知(开关 + 内容,前台右上角悬浮窗)
|
||||
// L7:读取侧再做一次白名单净化,覆盖历史存量与直改库的数据
|
||||
"notify_enabled": boolToInt(cfg.NotifyEnabled()),
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"filecodebox/internal/model"
|
||||
"filecodebox/internal/settings"
|
||||
"fileshare/internal/model"
|
||||
"fileshare/internal/settings"
|
||||
)
|
||||
|
||||
// postJSON 以 JSON body 调用 POST 端点。
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"filecodebox/internal/response"
|
||||
"filecodebox/internal/settings"
|
||||
"fileshare/internal/response"
|
||||
"fileshare/internal/settings"
|
||||
)
|
||||
|
||||
// fileSizeUnits 文件大小单位(对齐参考 FILE_SIZE_UNITS)。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -12,11 +13,12 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"filecodebox/internal/audit"
|
||||
"filecodebox/internal/middleware"
|
||||
"filecodebox/internal/model"
|
||||
"filecodebox/internal/response"
|
||||
"filecodebox/internal/storage"
|
||||
"fileshare/internal/audit"
|
||||
"fileshare/internal/janitor"
|
||||
"fileshare/internal/middleware"
|
||||
"fileshare/internal/model"
|
||||
"fileshare/internal/response"
|
||||
"fileshare/internal/storage"
|
||||
)
|
||||
|
||||
// nowRFC3339 当前时间的 RFC3339 表示。
|
||||
@@ -61,9 +63,22 @@ func (d *Deps) consumeUsage(c *gin.Context, fc *model.FileCodes) bool {
|
||||
"expired_count": gorm.Expr("CASE WHEN expired_count > 0 THEN expired_count - 1 ELSE expired_count END"),
|
||||
"used_count": gorm.Expr("used_count + 1"),
|
||||
})
|
||||
if res.Error == nil && res.RowsAffected == 0 {
|
||||
// 26.9:取件时惰性回收——记录已过期/次数耗尽,后台异步删除记录与对象
|
||||
// (定时回收循环之外的"更好检查方法":访问即发现即回收,不等下一轮扫描)
|
||||
d.recycleAsync(fc)
|
||||
}
|
||||
return res.Error == nil && res.RowsAffected > 0
|
||||
}
|
||||
|
||||
// recycleAsync 异步回收单条过期分享(不阻塞请求;记录不存在时为幂等空操作)。
|
||||
func (d *Deps) recycleAsync(fc *model.FileCodes) {
|
||||
go janitor.RecycleRecord(context.Background(), d.DB, d.Store, fc, &janitor.Recycler{
|
||||
Enabled: d.Cfg.RecycleEnabled,
|
||||
RetentionDays: d.Cfg.RetentionDays,
|
||||
})
|
||||
}
|
||||
|
||||
// fileSavePath 拼接分享记录的存储相对路径(file_path/uuid_file_name)。
|
||||
func fileSavePath(fc *model.FileCodes) string {
|
||||
dir := ""
|
||||
@@ -90,7 +105,7 @@ func (d *Deps) shareText(c *gin.Context) {
|
||||
if !requireUploadLimit(c, d.Limiter) {
|
||||
return
|
||||
}
|
||||
// v3.1 修复:JSON/表单/ultipart 统一绑定(form+json 双标签——此前仅 PostForm 时,
|
||||
// 26.9 修复:JSON/表单/ultipart 统一绑定(form+json 双标签——此前仅 PostForm 时,
|
||||
// JSON 提交会静默存成空文本并 200,取件页空白)。
|
||||
var body struct {
|
||||
Text string `json:"text" form:"text"`
|
||||
@@ -120,7 +135,7 @@ func (d *Deps) shareText(c *gin.Context) {
|
||||
if expireStyle == "" {
|
||||
expireStyle = "day"
|
||||
}
|
||||
// v3.1:自定义提取码格式校验(4-8 位字母数字,空=随机)
|
||||
// 26.9:自定义提取码格式校验(4-8 位字母数字,空=随机)
|
||||
if err := validatePickupCode(body.Code); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
@@ -153,11 +168,11 @@ func (d *Deps) shareText(c *gin.Context) {
|
||||
ExpiredAt: exp.ExpiredAt,
|
||||
ExpiredCount: exp.ExpiredCount,
|
||||
UsedCount: exp.UsedCount,
|
||||
Engine: d.Store.CurrentName(), // v3:归属引擎戳(文本也记录,保持一致性)
|
||||
Engine: d.Store.CurrentName(), // 26.9:归属引擎戳(文本也记录,保持一致性)
|
||||
}
|
||||
err = d.DB.WithContext(ctx).Create(&fc).Error
|
||||
}
|
||||
err = mapCodeConflict(err) // v3.1:自定义码唯一索引冲突 → 友好 400
|
||||
err = mapCodeConflict(err) // 26.9:自定义码唯一索引冲突 → 友好 400
|
||||
releaseStorage(ctx, d.DB, token)
|
||||
if err != nil {
|
||||
auditRecordFailed(c, d.AuditSvc, "文本分享创建失败")
|
||||
@@ -188,7 +203,7 @@ func (d *Deps) shareFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
origName := fh.Filename
|
||||
// v2 需求 ④⑩:动态策略校验(max_file_size,0=回落 uploadSize)
|
||||
// 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, "大小超过限制")
|
||||
@@ -205,7 +220,7 @@ func (d *Deps) shareFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// v3.1:自定义提取码(落盘前校验,失败快速返回不占容量预留)
|
||||
// 26.9:自定义提取码(落盘前校验,失败快速返回不占容量预留)
|
||||
if err := validatePickupCode(c.PostForm("code")); err != nil {
|
||||
auditUploadEntry(c, "", origName, fh.Size, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "提取码非法")
|
||||
@@ -257,12 +272,15 @@ func (d *Deps) shareFile(c *gin.Context) {
|
||||
ExpiredAt: exp.ExpiredAt,
|
||||
ExpiredCount: exp.ExpiredCount,
|
||||
UsedCount: exp.UsedCount,
|
||||
Engine: d.Store.CurrentName(), // v3:归属引擎戳(下载按此取回)
|
||||
Engine: d.Store.CurrentName(), // 26.9:归属引擎戳(下载按此取回)
|
||||
}
|
||||
if err = d.DB.WithContext(ctx).Create(&fc).Error; err != nil {
|
||||
err = mapCodeConflict(err) // v3.1
|
||||
err = mapCodeConflict(err) // 26.9
|
||||
// 记录创建失败:清理已落盘文件
|
||||
_ = d.Store.DeleteFile(ctx, savePath)
|
||||
} else {
|
||||
// 26.9:SHA512 内容去重(命中则复用旧对象并删除本次副本)
|
||||
d.applyDedup(ctx, d.Store, savePath, &fc)
|
||||
}
|
||||
} else {
|
||||
// 保存失败:尽力清理半写文件
|
||||
@@ -273,7 +291,7 @@ func (d *Deps) shareFile(c *gin.Context) {
|
||||
auditUploadEntry(c, "", origName, fh.Size, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件保存失败")
|
||||
if be, ok := err.(*apiError); ok && be.Status == http.StatusBadRequest {
|
||||
respondError(c, err) // v3.1:提取码冲突等业务 400 原样透出
|
||||
respondError(c, err) // 26.9:提取码冲突等业务 400 原样透出
|
||||
} else {
|
||||
respondError(c, mapStorageError(err))
|
||||
}
|
||||
@@ -544,7 +562,7 @@ func (d *Deps) serveFile(c *gin.Context, fc *model.FileCodes) {
|
||||
savePath := fileSavePath(fc)
|
||||
name := fc.Prefix + fc.Suffix
|
||||
|
||||
// v3:按文件归属引擎取回(切换引擎后旧文件仍可下载);空戳=历史数据回落当前引擎
|
||||
// 26.9:按文件归属引擎取回(切换引擎后旧文件仍可下载);空戳=历史数据回落当前引擎
|
||||
store, err := d.storeFor(fc.Engine)
|
||||
if err != nil {
|
||||
auditUploadEntry(c, fc.Code, name, fc.Size, 0)
|
||||
@@ -553,6 +571,29 @@ func (d *Deps) serveFile(c *gin.Context, fc *model.FileCodes) {
|
||||
return
|
||||
}
|
||||
|
||||
// 26.9:对象存储直链下载——引擎支持 + 直链开关开启时 302 到限时预签名 URL,
|
||||
// 文件字节不再经过本服务器(带宽成本转嫁对象存储)。签名有效期取
|
||||
// direct_link_expire 与分享剩余时效的较小值;直链不可用静默回落代理。
|
||||
if d.Cfg.DirectDownload() {
|
||||
expires := d.Cfg.DirectLinkExpire()
|
||||
if fc.ExpiredAt != nil {
|
||||
if remain := int64(time.Until(*fc.ExpiredAt).Seconds()); remain > 0 && remain < expires {
|
||||
expires = remain
|
||||
}
|
||||
}
|
||||
if url, err := store.PresignGetURL(ctx, savePath, expires); err == nil && url != "" {
|
||||
auditUploadEntry(c, fc.Code, name, fc.Size, fc.Size)
|
||||
middleware.AuditSet(c, func(e *audit.Entry) {
|
||||
e.TransferredBytes = fc.Size
|
||||
e.SizeBytes = fc.Size
|
||||
})
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
c.Redirect(http.StatusFound, url)
|
||||
return
|
||||
}
|
||||
// 直链不可用:继续走代理下载(不中断取件)
|
||||
}
|
||||
|
||||
// 先 Stat 拿总大小(用于审计与 Range 后缀解析)
|
||||
var total int64 = -1
|
||||
if meta, err := store.Stat(ctx, savePath); err == nil && meta != nil {
|
||||
@@ -597,7 +638,9 @@ func (d *Deps) serveFile(c *gin.Context, fc *model.FileCodes) {
|
||||
}
|
||||
auditUploadEntry(c, fc.Code, name, total, 0)
|
||||
c.Status(status)
|
||||
n, _ := io.Copy(c.Writer, dl)
|
||||
// 26.9:下载带宽限速(storage.ReadCloser → 限速 reader → c.Writer)
|
||||
dlReader := middleware.WrapReadCloser(dl, d.Cfg.DownloadRate())
|
||||
n, _ := io.Copy(c.Writer, dlReader)
|
||||
middleware.AuditSet(c, func(e *audit.Entry) {
|
||||
if e.TransferredBytes == 0 {
|
||||
e.TransferredBytes = n
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"filecodebox/internal/response"
|
||||
web "filecodebox/web"
|
||||
"fileshare/internal/response"
|
||||
web "fileshare/web"
|
||||
)
|
||||
|
||||
// registerWeb 注册前端静态资源与 SPA 回退(必须最后注册):
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"filecodebox/internal/model"
|
||||
"fileshare/internal/model"
|
||||
)
|
||||
|
||||
// Service 审计日志服务。
|
||||
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
AdminSessionExpireMax = 365 * 24 * 60 * 60 // 最大 365 天
|
||||
|
||||
// DefaultSQLitePath SQLite 模式默认数据库文件路径(相对运行目录,自动创建 data/)。
|
||||
DefaultSQLitePath = "./data/filecodebox.db"
|
||||
DefaultSQLitePath = "./data/fileshare.db"
|
||||
)
|
||||
|
||||
// 数据库驱动常量(需求 ⑧:SQLite 默认、Postgres 可选)。
|
||||
@@ -28,7 +28,7 @@ const (
|
||||
)
|
||||
|
||||
// DefaultLogoURL / DefaultFaviconURL 默认 Logo 与 favicon(需求 ⑤):
|
||||
// v2 起默认改用前端打包的本地资源(web/src/assets/brand/logo.svg + favicon.png,
|
||||
// 26.9 起默认改用前端打包的本地资源(web/src/assets/brand/logo.svg + favicon.png,
|
||||
// 经 Vite 产出 /assets/logo-*.svg 与 /assets/favicon-*.png)。此处留空,
|
||||
// GET /api/v1/config 下发空值时前端 displayLogoUrl/displayFaviconUrl 回落到本地打包资源;
|
||||
// 管理端仍可设置任意 URL 全站替换。
|
||||
@@ -62,9 +62,20 @@ func defaults() map[string]any {
|
||||
"file_storage": "local",
|
||||
"storage_path": "",
|
||||
"storageLimit": 0,
|
||||
// v3:存储引擎运行时可配(热切换);空=沿用 Env.StorageEngine 启动值
|
||||
// 26.9:存储引擎运行时可配(热切换);空=沿用 Env.StorageEngine 启动值
|
||||
"storage_engine": "",
|
||||
"site_domain": "",
|
||||
"upload_rate": "0",
|
||||
"download_rate": "0",
|
||||
// 26.9 回收与下载安全
|
||||
"recycle_enabled": 1,
|
||||
"recycle_interval": 1800,
|
||||
"retention_days": 0,
|
||||
"dedup_enabled": 1,
|
||||
"hotlink_enabled": 0,
|
||||
"hotlink_whitelist": "",
|
||||
"direct_download": 1,
|
||||
"direct_link_expire": 900,
|
||||
// 站点信息
|
||||
"name": "文件快传",
|
||||
"site_name": "文件快传", // 新增:管理端可自定义
|
||||
@@ -76,7 +87,7 @@ func defaults() map[string]any {
|
||||
// 需求 ⑤:默认 Logo 与 favicon(空 = 前端使用打包的本地资源)
|
||||
"logo_url": DefaultLogoURL,
|
||||
"favicon_url": DefaultFaviconURL,
|
||||
// 需求 ①:背景图(v2 新增 background_url;background 为参考实现既有键,保留兼容)
|
||||
// 需求 ①:背景图(26.9 新增 background_url;background 为参考实现既有键,保留兼容)
|
||||
"background": "",
|
||||
"background_url": "",
|
||||
// 需求 ②:页脚自定义内容与备案号
|
||||
@@ -278,7 +289,89 @@ func (c *Config) GetBool(key string) bool {
|
||||
}
|
||||
|
||||
// GetStringSlice 取字符串切片配置。
|
||||
// SiteDomain 站点对外域名(v3.1):空=分享链接用当前访问地址。
|
||||
// UploadRate 上传带宽字节/秒(26.9,0=不限速)。
|
||||
func (c *Config) UploadRate() int {
|
||||
v := c.GetInt(KeyUploadRate)
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// DownloadRate 下载带宽字节/秒(26.9,0=不限速)。
|
||||
func (c *Config) DownloadRate() int {
|
||||
v := c.GetInt(KeyDownloadRate)
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// —— 26.9 回收与下载安全 ——
|
||||
|
||||
// RecycleEnabled 过期自动回收开关。
|
||||
func (c *Config) RecycleEnabled() bool { return c.GetInt(KeyRecycleEnabled) == 1 }
|
||||
|
||||
// RecycleInterval 回收扫描间隔(秒,钳位 60~86400)。
|
||||
func (c *Config) RecycleInterval() int64 {
|
||||
v := c.GetInt64(KeyRecycleInterval)
|
||||
if v < RecycleIntervalMin {
|
||||
return RecycleIntervalMin
|
||||
}
|
||||
if v > RecycleIntervalMax {
|
||||
return RecycleIntervalMax
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// RetentionDays 全局最长存储时长(天,0=不限制)。
|
||||
func (c *Config) RetentionDays() int64 {
|
||||
v := c.GetInt64(KeyRetentionDays)
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// DedupEnabled SHA512 内容去重开关。
|
||||
func (c *Config) DedupEnabled() bool { return c.GetInt(KeyDedupEnabled) == 1 }
|
||||
|
||||
// HotlinkEnabled 下载防盗链开关。
|
||||
func (c *Config) HotlinkEnabled() bool { return c.GetInt(KeyHotlinkEnabled) == 1 }
|
||||
|
||||
// HotlinkWhitelist 防盗链 Referer 白名单(逗号分隔域名,返回小写去空白切片)。
|
||||
func (c *Config) HotlinkWhitelist() []string {
|
||||
raw := c.GetString(KeyHotlinkWhitelist)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.ToLower(strings.TrimSpace(p))
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DirectDownload 对象存储直链下载开关。
|
||||
func (c *Config) DirectDownload() bool { return c.GetInt(KeyDirectDownload) == 1 }
|
||||
|
||||
// DirectLinkExpire 直链有效期(秒,钳位 60~3600)。
|
||||
func (c *Config) DirectLinkExpire() int64 {
|
||||
v := c.GetInt64(KeyDirectLinkExpire)
|
||||
if v < DirectLinkExpireMin {
|
||||
return DirectLinkExpireMin
|
||||
}
|
||||
if v > DirectLinkExpireMax {
|
||||
return DirectLinkExpireMax
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// SiteDomain 站点对外域名(26.9):空=分享链接用当前访问地址。
|
||||
func (c *Config) SiteDomain() string {
|
||||
return strings.TrimRight(strings.TrimSpace(c.GetString("site_domain")), "/")
|
||||
}
|
||||
@@ -401,7 +494,7 @@ func (c *Config) AdminSessionExpireSeconds() int {
|
||||
}
|
||||
|
||||
// Engine 当前存储引擎。
|
||||
// Engine 返回当前存储引擎名:KV storage_engine 优先(v3 运行时可改),
|
||||
// Engine 返回当前存储引擎名:KV storage_engine 优先(26.9 运行时可改),
|
||||
// 空(未设置/历史数据)回落启动值 Env.StorageEngine(env 校验过的 local|s3|webdav)。
|
||||
// 枚举校验内联(避免 config→storage 反向依赖)。
|
||||
func (c *Config) Engine() string {
|
||||
|
||||
@@ -1,38 +1,49 @@
|
||||
// Package config — schema.go 定义 v2 新增配置键(KV)schema:
|
||||
// Package config — schema.go 定义 26.9 新增配置键(KV)schema:
|
||||
// 键名常量、类型、默认值与取值边界。管理与 API 层(t2)按下表读写与校验,
|
||||
// 文档(t4)按本表生成说明。键名除参考实现既有 camelCase 键外,
|
||||
// v2 新增键统一 snake_case。
|
||||
// 26.9 新增键统一 snake_case。
|
||||
package config
|
||||
|
||||
// —— v2 新增/沿用键名常量(单一事实来源;settings 包会 re-export)——
|
||||
// 命名规则:v2 新增键 snake_case;与参考实现对齐的既有键保持原拼写。
|
||||
// —— 26.9 新增/沿用键名常量(单一事实来源;settings 包会 re-export)——
|
||||
// 命名规则:26.9 新增键 snake_case;与参考实现对齐的既有键保持原拼写。
|
||||
const (
|
||||
// 需求 ①:背景图
|
||||
KeyBackground = "background" // 参考实现既有键(v1 兼容保留)
|
||||
KeyBackgroundURL = "background_url" // v2 新增:背景图 URL 或上传后的访问地址(空=默认主题)
|
||||
KeyBackgroundURL = "background_url" // 26.9 新增:背景图 URL 或上传后的访问地址(空=默认主题)
|
||||
// 需求 ②:页脚
|
||||
KeyFooterText = "footer_text" // v2 新增:页脚自定义内容(纯文本或受控 HTML 片段)
|
||||
KeyFooterBeian = "footer_beian" // v2 新增:备案号(如 京ICP备2024xxxxxx号-1)
|
||||
KeyFooterText = "footer_text" // 26.9 新增:页脚自定义内容(纯文本或受控 HTML 片段)
|
||||
KeyFooterBeian = "footer_beian" // 26.9 新增:备案号(如 京ICP备2024xxxxxx号-1)
|
||||
// 需求 ③:系统通知
|
||||
KeyNotifyEnabled = "notify_enabled" // v2 新增:通知开关,1 开启 / 0 关闭
|
||||
KeyNotifyEnabled = "notify_enabled" // 26.9 新增:通知开关,1 开启 / 0 关闭
|
||||
KeyNotifyTitle = "notify_title" // 既有键:通知标题
|
||||
KeyNotifyContent = "notify_content" // 既有键:通知内容(允许 <a> 等受控 HTML)
|
||||
// 需求 ④:保存策略(上传页动态读取并在范围内选择)
|
||||
KeyMaxSaveSeconds = "max_save_seconds" // 既有键:最长保存秒数,0=不限制(仅受默认 7 天兜底)
|
||||
KeyMaxSaveCount = "max_save_count" // v2 新增:单次分享最大可取(保存)次数上限,0=不限制
|
||||
KeyMaxSaveCount = "max_save_count" // 26.9 新增:单次分享最大可取(保存)次数上限,0=不限制
|
||||
KeyExpireStyle = "expireStyle" // 既有键:允许的过期方式白名单
|
||||
// 需求 ④:上传频率限制(既有键,对齐参考 ip_limit["upload"])
|
||||
KeyUploadCount = "uploadCount" // 窗口内允许上传次数
|
||||
KeyUploadMinute = "uploadMinute" // 频率窗口(分钟)
|
||||
// 需求 ④⑩:存储策略(最大文件大小/允许类型/总容量)
|
||||
KeyUploadSize = "uploadSize" // 既有键:单文件上限(字节),参考实现语义
|
||||
KeyMaxFileSize = "max_file_size" // v2 新增:存储策略-单文件上限(字节),0=回落 uploadSize
|
||||
KeyMaxFileSize = "max_file_size" // 26.9 新增:存储策略-单文件上限(字节),0=回落 uploadSize
|
||||
KeyAllowedTypes = "allowed_file_types" // 既有键:允许类型白名单("*" 不限制)
|
||||
KeyStorageLimit = "storageLimit" // 既有键:站点总容量(字节),0=不限制
|
||||
KeyOpenUpload = "openUpload" // 既有键:游客上传开关
|
||||
// v3:存储引擎运行时可配(热切换;file_storage 为参考既有键保留兼容)
|
||||
// 26.9:存储引擎运行时可配(热切换;file_storage 为参考既有键保留兼容)
|
||||
KeyStorageEngine = "storage_engine" // 当前存储引擎:local|s3|webdav
|
||||
KeySiteDomain = "site_domain" // 站点对外域名(空=分享链接用当前地址)
|
||||
KeyUploadRate = "upload_rate" // 上传带宽字节/秒(0=不限速)
|
||||
KeyDownloadRate = "download_rate" // 下载带宽字节/秒(0=不限速)
|
||||
// —— 26.9 回收与下载安全 ——
|
||||
KeyRecycleEnabled = "recycle_enabled" // 过期分享自动回收开关(1 开 / 0 关)
|
||||
KeyRecycleInterval = "recycle_interval" // 回收扫描间隔(秒,60~86400)
|
||||
KeyRetentionDays = "retention_days" // 全局最长存储时长(天,0=不限制)
|
||||
KeyDedupEnabled = "dedup_enabled" // SHA512 内容去重开关
|
||||
KeyHotlinkEnabled = "hotlink_enabled" // 下载防盗链开关
|
||||
KeyHotlinkWhitelist = "hotlink_whitelist" // 防盗链 Referer 白名单(逗号分隔域名)
|
||||
KeyDirectDownload = "direct_download" // 对象存储直链下载开关(仅 S3 引擎生效)
|
||||
KeyDirectLinkExpire = "direct_link_expire" // 直链有效期(秒,60~3600)
|
||||
)
|
||||
|
||||
// —— 取值边界(管理端保存与 API 校验用)——
|
||||
@@ -52,6 +63,16 @@ const (
|
||||
// 通知标题/内容最大长度。
|
||||
NotifyTitleMaxLen = 128
|
||||
NotifyContentMaxLen = 2000
|
||||
// 回收扫描间隔边界(秒):最快 1 分钟一轮,最慢 1 天一轮。
|
||||
RecycleIntervalMin = 60
|
||||
RecycleIntervalMax = 86400
|
||||
// 全局存储时长上限(天):0=不限制,最长 10 年。
|
||||
RetentionDaysMax = 3650
|
||||
// 防盗链白名单最大长度。
|
||||
HotlinkWhitelistMaxLen = 2048
|
||||
// 直链有效期边界(秒)。
|
||||
DirectLinkExpireMin = 60
|
||||
DirectLinkExpireMax = 3600
|
||||
)
|
||||
|
||||
// KVSchemaEntry 配置键元数据:类型 / 默认值 / 说明,供管理端 UI 与文档生成。
|
||||
@@ -64,7 +85,7 @@ type KVSchemaEntry struct {
|
||||
Description string // 中文说明
|
||||
}
|
||||
|
||||
// KVSchema v2 全量配置键 schema 表(含既有策略键,供管理端/文档/AI 校验)。
|
||||
// KVSchema 26.9 全量配置键 schema 表(含既有策略键,供管理端/文档/AI 校验)。
|
||||
// 注意:Default 与 config defaults() 逐一对应(schema_test 保证)。
|
||||
func KVSchema() []KVSchemaEntry {
|
||||
return []KVSchemaEntry{
|
||||
@@ -90,8 +111,19 @@ func KVSchema() []KVSchemaEntry {
|
||||
{KeyAllowedTypes, "[]string", []string{"*"}, -1, -1, "允许上传类型白名单(\"*\" 不限制)"},
|
||||
{KeyStorageLimit, "int64", int64(0), 0, -1, "站点总容量(字节),0=不限制"},
|
||||
{KeyOpenUpload, "int", 1, 0, 1, "游客上传开关:1 开 / 0 需管理员登录"},
|
||||
// —— v3 存储引擎(热切换;引擎参数键沿用 defaults() 既有键,管理端经 config get/update 读写)——
|
||||
// —— 26.9 存储引擎(热切换;引擎参数键沿用 defaults() 既有键,管理端经 config get/update 读写)——
|
||||
{KeyStorageEngine, "string", "", 0, 16, "当前存储引擎:local|s3|webdav(热切换,健康检查通过才生效;空=回落启动值 FCB_STORAGE_ENGINE)"},
|
||||
{KeySiteDomain, "string", "", 0, 256, "站点对外域名(http(s)://host[:port],不带路径;空=分享链接用当前访问地址)"},
|
||||
{KeyUploadRate, "int64", "0", 0, 1073741824, "上传带宽字节/秒(0=不限速;范围 0~1 GiB/s)"},
|
||||
{KeyDownloadRate, "int64", "0", 0, 1073741824, "下载带宽字节/秒(0=不限速;范围 0~1 GiB/s)"},
|
||||
// —— 26.9 回收与下载安全 ——
|
||||
{KeyRecycleEnabled, "int", 1, 0, 1, "过期分享自动回收开关:1 定时清理过期记录与存储对象 / 0 关闭"},
|
||||
{KeyRecycleInterval, "int64", int64(1800), RecycleIntervalMin, RecycleIntervalMax, "回收扫描间隔(秒;范围 60~86400,默认 30 分钟)"},
|
||||
{KeyRetentionDays, "int64", int64(0), 0, RetentionDaysMax, "全局最长存储时长(天):上传超过该天数的分享将被回收,0=不限制"},
|
||||
{KeyDedupEnabled, "int", 1, 0, 1, "SHA512 内容去重:相同文件仅存储一份(多分享引用同一对象)"},
|
||||
{KeyHotlinkEnabled, "int", 0, 0, 1, "下载防盗链:校验 Referer 白名单(空 Referer 放行)"},
|
||||
{KeyHotlinkWhitelist, "string", "", 0, HotlinkWhitelistMaxLen, "防盗链白名单:逗号分隔域名(如 a.com,b.org;空=仅本站域名)"},
|
||||
{KeyDirectDownload, "int", 1, 0, 1, "对象存储直链下载:S3 引擎时 302 跳转到限时预签名 URL(不走服务器代理)"},
|
||||
{KeyDirectLinkExpire, "int64", int64(900), DirectLinkExpireMin, DirectLinkExpireMax, "直链有效期(秒;范围 60~3600,默认 15 分钟;不超过分享剩余时效)"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestKVSchemaNoDuplicates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestV2NewKeysPresent v2 新增键必须在 schema 与 defaults 中同时存在。
|
||||
// TestV2NewKeysPresent 26.9 新增键必须在 schema 与 defaults 中同时存在。
|
||||
func TestV2NewKeysPresent(t *testing.T) {
|
||||
def := defaults()
|
||||
newKeys := []string{
|
||||
@@ -44,13 +44,13 @@ func TestV2NewKeysPresent(t *testing.T) {
|
||||
}
|
||||
for _, k := range newKeys {
|
||||
if _, ok := def[k]; !ok {
|
||||
t.Fatalf("v2 新键 %q 缺少默认值", k)
|
||||
t.Fatalf("26.9 新键 %q 缺少默认值", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestV2AccessorDefaults v2 便捷访问器默认语义。
|
||||
func TestV2AccessorDefaults(t *testing.T) {
|
||||
// TestKVAccessorDefaults 26.9 便捷访问器默认语义。
|
||||
func TestKVAccessorDefaults(t *testing.T) {
|
||||
t.Setenv("FCB_DB_DRIVER", "sqlite")
|
||||
t.Setenv("FCB_DB_DSN", "")
|
||||
c, err := New()
|
||||
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"filecodebox/internal/config"
|
||||
"filecodebox/internal/model"
|
||||
"fileshare/internal/config"
|
||||
"fileshare/internal/model"
|
||||
)
|
||||
|
||||
// Options 连接选项(main.go 从 config.Env 装配)。
|
||||
|
||||
@@ -18,8 +18,8 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"filecodebox/internal/database"
|
||||
"filecodebox/internal/model"
|
||||
"fileshare/internal/database"
|
||||
"fileshare/internal/model"
|
||||
)
|
||||
|
||||
// pgTestDSN 返回 Postgres 测试连接串;未设置 FCB_TEST_PG_DSN 时返回空。
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Package janitor 后台清理循环(安全审计 M5):
|
||||
// 回收过期容量预留、超时未完成的上传会话(含其分片对象)与过期预签名会话
|
||||
// (direct 模式残留对象一并删除)。此前这些资源仅在同 token 复用/显式取消时
|
||||
// 释放,恶意 init 可长期占用容量预留或累积垃圾数据。
|
||||
// Package janitor 后台清理循环(安全审计 M5 / 26.9 过期回收):
|
||||
// 回收过期容量预留、超时未完成的上传会话(含其分片对象)、过期预签名会话
|
||||
// (direct 模式残留对象一并删除),以及过期/超留存期的分享记录与存储对象。
|
||||
package janitor
|
||||
|
||||
import (
|
||||
@@ -12,8 +11,8 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"filecodebox/internal/model"
|
||||
"filecodebox/internal/storage"
|
||||
"fileshare/internal/model"
|
||||
"fileshare/internal/storage"
|
||||
)
|
||||
|
||||
// chunkSessionMaxAge 未完成分片会话的最大保留时长(预留 TTL 为 2h,
|
||||
@@ -23,8 +22,22 @@ const chunkSessionMaxAge = 24 * time.Hour
|
||||
// presignGrace 过期预签名会话的宽限时长(到点即删,避免与在途 confirm 竞争)。
|
||||
const presignGrace = time.Hour
|
||||
|
||||
// 回收批次上限:单轮每类最多处理 200 条,避免大清理阻塞下一 tick。
|
||||
const recycleBatch = 200
|
||||
|
||||
// Recycler 回收配置(26.9):由 API 层注入(管理端 KV 动态读取)。
|
||||
type Recycler struct {
|
||||
// Enabled 过期自动回收开关。
|
||||
Enabled func() bool
|
||||
// RetentionDays 全局最长存储时长(天,0=不限制)。
|
||||
RetentionDays func() int64
|
||||
// OnRecycled 回收成功后的回调(审计可选),参数:码、文件名、字节数。
|
||||
OnRecycled func(code, name string, size int64)
|
||||
}
|
||||
|
||||
// Start 启动周期清理循环;ctx 取消时退出。
|
||||
func Start(ctx context.Context, db *gorm.DB, store *storage.Manager, interval time.Duration) {
|
||||
// interval 为兜底默认间隔;recycler 非 nil 时按 RecycleInterval 动态取间隔。
|
||||
func Start(ctx context.Context, db *gorm.DB, store *storage.Manager, interval time.Duration, recycler *Recycler) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
@@ -34,12 +47,15 @@ func Start(ctx context.Context, db *gorm.DB, store *storage.Manager, interval ti
|
||||
return
|
||||
case <-ticker.C:
|
||||
Run(ctx, db, store)
|
||||
if recycler != nil && recycler.Enabled != nil && recycler.Enabled() {
|
||||
RecycleExpired(ctx, db, store, recycler)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Run 执行一轮清理;单项失败仅记日志,不影响其他项。
|
||||
// Run 执行一轮基础设施清理;单项失败仅记日志,不影响其他项。
|
||||
func Run(ctx context.Context, db *gorm.DB, store *storage.Manager) {
|
||||
now := time.Now()
|
||||
cleanExpiredReservations(ctx, db, now)
|
||||
@@ -122,3 +138,102 @@ func cleanExpiredPresignSessions(ctx context.Context, db *gorm.DB, store *storag
|
||||
log.Printf("[janitor] 已清理过期预签名会话 upload_id=%s mode=%s", s.UploadID, s.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 26.9:过期分享回收 ============
|
||||
|
||||
// RecycleExpired 回收过期/超存储时长的分享记录与存储对象:
|
||||
// - 时间过期:expired_count<0 且 expired_at 已过;
|
||||
// - 次数耗尽:expired_count>=0 且 <=0;
|
||||
// - 超留存期:retentionDays>0 且 created_at 早于 now-retentionDays;
|
||||
// - 内容去重开启时同一存储对象可能被多条分享引用,删除前做引用计数
|
||||
// (按 ContentHash/Engine/UUIDFileName 统计),仅删除最后一个引用。
|
||||
//
|
||||
// 返回本轮回收的分享数。由 janitor 定时循环与管理端手动触发共用。
|
||||
func RecycleExpired(ctx context.Context, db *gorm.DB, store *storage.Manager, r *Recycler) int {
|
||||
now := time.Now()
|
||||
q := db.WithContext(ctx).Model(&model.FileCodes{}).
|
||||
Where("(expired_count < 0 AND expired_at IS NOT NULL AND expired_at < ?)"+
|
||||
" OR (expired_count >= 0 AND expired_count <= 0)", now)
|
||||
if r.RetentionDays != nil && r.RetentionDays() > 0 {
|
||||
cutoff := now.AddDate(0, 0, -int(r.RetentionDays()))
|
||||
q = q.Or("created_at < ?", cutoff)
|
||||
}
|
||||
var ids []int64
|
||||
if err := q.Limit(recycleBatch).Pluck("id", &ids).Error; err != nil {
|
||||
log.Printf("[recycle] 查询过期分享失败: %v", err)
|
||||
return 0
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, id := range ids {
|
||||
var fc model.FileCodes
|
||||
if err := db.WithContext(ctx).First(&fc, id).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
// 复核:Expired 语义(避免查询窗口内被取件续期)
|
||||
if !fc.Expired(now) {
|
||||
if r.RetentionDays == nil || r.RetentionDays() <= 0 || fc.CreatedAt.After(now.AddDate(0, 0, -int(r.RetentionDays()))) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
recycleOne(ctx, db, store, &fc, r)
|
||||
n++
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[recycle] 本轮回收 %d 条过期分享", n)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// RecycleRecord 回收单条分享(取件惰性回收入口):删除记录与存储对象(带引用计数)。
|
||||
// 记录不存在时为幂等空操作。
|
||||
func RecycleRecord(ctx context.Context, db *gorm.DB, store *storage.Manager, fc *model.FileCodes, r *Recycler) {
|
||||
// 存在性复核:可能已被定时循环/其他请求回收
|
||||
var cur model.FileCodes
|
||||
if err := db.WithContext(ctx).Where("id = ?", fc.ID).First(&cur).Error; err != nil {
|
||||
return
|
||||
}
|
||||
recycleOne(ctx, db, store, &cur, r)
|
||||
}
|
||||
|
||||
// recycleOne 删除单条分享记录及其存储对象(带去重引用计数)。
|
||||
func recycleOne(ctx context.Context, db *gorm.DB, store *storage.Manager, fc *model.FileCodes, r *Recycler) {
|
||||
if fc.Text == nil && fc.UUIDFileName != nil {
|
||||
engine, err := engineFor(store, fc.Engine)
|
||||
if err != nil {
|
||||
log.Printf("[recycle] 引擎不可用 code=%s: %v", fc.Code, err)
|
||||
// 引擎不可用也删记录,避免永久堆积;对象留给对账巡检
|
||||
} else {
|
||||
// 去重引用计数:同 ContentHash+Engine+UUIDFileName 的其他分享还在,则不删对象
|
||||
if fc.ContentHash != nil && *fc.ContentHash != "" {
|
||||
var cnt int64
|
||||
_ = db.WithContext(ctx).Model(&model.FileCodes{}).
|
||||
Where("content_hash = ? AND engine = ? AND uuid_file_name = ? AND id <> ?",
|
||||
*fc.ContentHash, fc.Engine, *fc.UUIDFileName, fc.ID).
|
||||
Count(&cnt).Error
|
||||
if cnt == 0 && fc.SavePath() != "" {
|
||||
delFile(ctx, engine, fc.SavePath(), fc.Code)
|
||||
}
|
||||
} else if fc.SavePath() != "" {
|
||||
delFile(ctx, engine, fc.SavePath(), fc.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := db.WithContext(ctx).Delete(fc).Error; err != nil {
|
||||
log.Printf("[recycle] 删除分享记录失败 code=%s: %v", fc.Code, err)
|
||||
return
|
||||
}
|
||||
if r != nil && r.OnRecycled != nil {
|
||||
r.OnRecycled(fc.Code, fc.Prefix+fc.Suffix, fc.Size)
|
||||
}
|
||||
}
|
||||
|
||||
// delFile 删除存储对象,NotFound 视为成功(幂等)。
|
||||
func delFile(ctx context.Context, engine storage.Storage, savePath, code string) {
|
||||
if err := engine.DeleteFile(ctx, savePath); err != nil &&
|
||||
!errors.Is(err, storage.ErrNotFound) && !errors.Is(err, storage.ErrInvalidPath) {
|
||||
log.Printf("[recycle] 删除存储对象失败 code=%s path=%s: %v", code, savePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"filecodebox/internal/audit"
|
||||
"filecodebox/internal/model"
|
||||
"filecodebox/internal/response"
|
||||
"fileshare/internal/audit"
|
||||
"fileshare/internal/model"
|
||||
"fileshare/internal/response"
|
||||
)
|
||||
|
||||
// auditHooks 审计钩子:由 API 层在响应前后填充与落库。
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"filecodebox/internal/audit"
|
||||
"filecodebox/internal/model"
|
||||
"fileshare/internal/audit"
|
||||
"fileshare/internal/model"
|
||||
)
|
||||
|
||||
type captureSink struct {
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"filecodebox/internal/audit"
|
||||
"filecodebox/internal/model"
|
||||
"fileshare/internal/audit"
|
||||
"fileshare/internal/model"
|
||||
)
|
||||
|
||||
// memSink 测试用内存落库实现。
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"filecodebox/internal/response"
|
||||
"fileshare/internal/response"
|
||||
)
|
||||
|
||||
// jwtClaims 自定义声明:对齐参考实现(payload 含 is_admin 与 exp)。
|
||||
@@ -36,7 +36,7 @@ func SignAdminToken(secret string, expires time.Duration) (string, time.Time, er
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "filecodebox",
|
||||
Issuer: "fileshare",
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"filecodebox/internal/response"
|
||||
"fileshare/internal/response"
|
||||
)
|
||||
|
||||
const testSecret = "unit-test-secret-0123456789abcdef"
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"filecodebox/internal/cache"
|
||||
"filecodebox/internal/response"
|
||||
"fileshare/internal/cache"
|
||||
"fileshare/internal/response"
|
||||
)
|
||||
|
||||
// 限流类别(对齐参考 apps/base/utils.py 的 ip_limit)。
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"filecodebox/internal/cache"
|
||||
"fileshare/internal/cache"
|
||||
)
|
||||
|
||||
// failingCache 模拟缓存故障(Get/Incr 均返回非 ErrNotFound 错误)。
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"filecodebox/internal/cache"
|
||||
"fileshare/internal/cache"
|
||||
)
|
||||
|
||||
func rateLimitRouter(rl *RateLimiter, kind string) *gin.Engine {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -22,15 +23,33 @@ type FileCodes struct {
|
||||
ExpiredCount int `gorm:"default:0" json:"expired_count"` // 剩余可取次数;<0 表示按时间过期
|
||||
UsedCount int `gorm:"default:0" json:"used_count"` // 已取次数
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
FileHash *string `gorm:"size:64" json:"file_hash"` // SHA256
|
||||
FileHash *string `gorm:"size:64" json:"file_hash"` // SHA256
|
||||
ContentHash *string `gorm:"size:128;index" json:"content_hash"` // 26.9:SHA512(内容去重;同哈希分享复用同一存储对象)
|
||||
IsChunked bool `gorm:"default:false" json:"is_chunked"`
|
||||
UploadID *string `gorm:"size:36" json:"upload_id"` // 分片上传会话 ID
|
||||
Engine string `gorm:"size:16;default:''" json:"engine"` // 归属存储引擎(v3:local|s3|webdav;空=历史数据按当前引擎取)
|
||||
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 {
|
||||
@@ -55,7 +74,7 @@ type UploadChunk struct {
|
||||
SavePath string `gorm:"size:512" json:"save_path"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Completed bool `gorm:"default:false" json:"completed"`
|
||||
Engine string `gorm:"size:16;default:''" json:"engine"` // 归属存储引擎(v3:分片与会话记录当时引擎,合并走同一引擎)
|
||||
Engine string `gorm:"size:16;default:''" json:"engine"` // 归属存储引擎(26.9:分片与会话记录当时引擎,合并走同一引擎)
|
||||
}
|
||||
|
||||
// TableName 表名。
|
||||
@@ -84,7 +103,7 @@ type PresignUploadSession struct {
|
||||
ExpireStyle string `gorm:"size:20;default:day" json:"expire_style"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Engine string `gorm:"size:16;default:''" json:"engine"` // 归属存储引擎(v3:直传/代理完成走同一引擎取回)
|
||||
Engine string `gorm:"size:16;default:''" json:"engine"` // 归属存储引擎(26.9:直传/代理完成走同一引擎取回)
|
||||
}
|
||||
|
||||
// TableName 表名。
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"filecodebox/internal/config"
|
||||
"filecodebox/internal/database"
|
||||
"filecodebox/internal/settings"
|
||||
"fileshare/internal/config"
|
||||
"fileshare/internal/database"
|
||||
"fileshare/internal/settings"
|
||||
)
|
||||
|
||||
// pgTestDSN 返回 Postgres 测试连接串;未设置 FCB_TEST_PG_DSN 时跳过。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package settings — schema.go:v2 配置键 schema 常量与元数据表。
|
||||
// Package settings — schema.go:26.9 配置键 schema 常量与元数据表。
|
||||
//
|
||||
// 键名常量的单一事实来源在 internal/config/schema.go(defaults() 需引用);
|
||||
// 本文件 re-export 供 API/管理层使用,并提供「键名/类型/默认值」全量表,
|
||||
@@ -9,7 +9,7 @@
|
||||
// 4. schema 同步测试(config schema_test / settings schema_test)
|
||||
package settings
|
||||
|
||||
import "filecodebox/internal/config"
|
||||
import "fileshare/internal/config"
|
||||
|
||||
// —— 键名 re-export(与 config 包保持同一字符串,避免魔法值散落)——
|
||||
const (
|
||||
@@ -35,7 +35,7 @@ const (
|
||||
KeyAllowedTypes = config.KeyAllowedTypes
|
||||
KeyStorageLimit = config.KeyStorageLimit
|
||||
KeyOpenUpload = config.KeyOpenUpload
|
||||
// v3 存储引擎
|
||||
// 26.9 存储引擎
|
||||
KeyStorageEngine = config.KeyStorageEngine
|
||||
)
|
||||
|
||||
@@ -52,7 +52,7 @@ const (
|
||||
)
|
||||
|
||||
// 敏感键:不允许出现在管理端 config get 下发/前端可见集合中(双模式下一致生效)。
|
||||
// v3:引擎凭据(webdav_password/s3_secret_access_key/aws_session_token)加入敏感集——
|
||||
// 26.9:引擎凭据(webdav_password/s3_secret_access_key/aws_session_token)加入敏感集——
|
||||
// 管理端 get 返回掩码占位,update 时空串/掩码=不修改;公开 config 永不下发。
|
||||
var SensitiveKeys = []string{
|
||||
"admin_token", "jwt_secret",
|
||||
@@ -72,7 +72,7 @@ func IsSensitiveKey(key string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// KVSchema 返回 v2 全量配置键元数据(键名/类型/默认值/边界/说明)。
|
||||
// KVSchema 返回 26.9 全量配置键元数据(键名/类型/默认值/边界/说明)。
|
||||
// 默认值必须与 config defaults() 一致(schema 同步测试保证)。
|
||||
func KVSchema() []config.KVSchemaEntry { return config.KVSchema() }
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"filecodebox/internal/config"
|
||||
"filecodebox/internal/model"
|
||||
"fileshare/internal/config"
|
||||
"fileshare/internal/model"
|
||||
)
|
||||
|
||||
// settingsKey 数据库中的配置键(对齐参考实现)。
|
||||
|
||||
@@ -30,10 +30,10 @@ type LocalStorage struct {
|
||||
rootReal string
|
||||
}
|
||||
|
||||
// NewLocalStorage 构造本地引擎。root 为空时使用系统临时目录下的 filecodebox_storage。
|
||||
// NewLocalStorage 构造本地引擎。root 为空时使用系统临时目录下的 fileshare_storage。
|
||||
func NewLocalStorage(root string) (*LocalStorage, error) {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
root = filepath.Join(os.TempDir(), "filecodebox_storage")
|
||||
root = filepath.Join(os.TempDir(), "fileshare_storage")
|
||||
}
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
|
||||
@@ -32,7 +32,7 @@ func sha256Hex(b []byte) string {
|
||||
func TestLocalSaveOpenRange(t *testing.T) {
|
||||
st := newTestLocal(t)
|
||||
ctx := context.Background()
|
||||
data := []byte("hello filecodebox 本地引擎 0123456789")
|
||||
data := []byte("hello fileshare 本地引擎 0123456789")
|
||||
|
||||
n, err := st.SaveFile(ctx, bytes.NewReader(data), "2025/08/测试文件.bin")
|
||||
if err != nil {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
// Manager 存储引擎管理器:实现 Storage 全接口并支持运行时热切换。
|
||||
//
|
||||
// v3 需求:管理后台可设置存储类型(local|s3|webdav)与各引擎参数,
|
||||
// 26.9 需求:管理后台可设置存储类型(local|s3|webdav)与各引擎参数,
|
||||
// 保存后无需重启即生效。设计要点:
|
||||
// - 读写/保存类操作全部委托到"当前引擎"(原子指针,无锁热路径);
|
||||
// - Switch 先构建并健康检查新引擎,成功才替换指针,失败保持原引擎;
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestSwitchSuccessAndCurrentName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwitchFailureKeepsCurrent 健康检查失败时保持原引擎(v3 核心语义)。
|
||||
// TestSwitchFailureKeepsCurrent 健康检查失败时保持原引擎(26.9 核心语义)。
|
||||
func TestSwitchFailureKeepsCurrent(t *testing.T) {
|
||||
var s3Fail atomic.Bool
|
||||
s3Fail.Store(true) // s3 不健康
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
import{d as p,u as v,i as h,I as g,c as w,G as s,b as e,t as n,f as t,A as d,w as k,F as y,O as r,q as m,B as R,o as V}from"./index-DYsKpclu.js";import{_ as x}from"./SiteNav.vue_vue_type_script_setup_true_lang-CwaEJKIZ.js";import{u as A}from"./auth-B7MDTgxJ.js";import"./admin-DEOkRyTC.js";const B={class:"admin-shell"},C={class:"admin-aside"},b={class:"aside-title"},L=["aria-label"],N={class:"admin-main"},q=p({__name:"AdminLayout",setup(S){const{t:a}=v(),u=R(),o=A(),l=h();g(async()=>{o.isAuthed&&!o.checked&&await o.verify()});async function _(){await o.logout(),l.success(a("admin.nav.loggedOut")),u.replace({name:"admin-login"})}return(F,c)=>{const i=r("RouterLink"),f=r("RouterView");return V(),w(y,null,[s(x),e("div",B,[e("aside",C,[e("div",b,n(t(a)("admin.nav.title")),1),e("nav",{class:"aside-menu","aria-label":t(a)("admin.nav.menu")},[s(i,{to:{name:"admin-files"}},{default:d(()=>[m("📁 "+n(t(a)("admin.nav.files")),1)]),_:1}),s(i,{to:{name:"admin-audit"}},{default:d(()=>[m("🛡 "+n(t(a)("admin.nav.audit")),1)]),_:1}),s(i,{to:{name:"admin-settings"}},{default:d(()=>[m("⚙️ "+n(t(a)("admin.nav.settings")),1)]),_:1}),c[0]||(c[0]=e("div",{class:"aside-sep"},null,-1)),e("a",{href:"#",onClick:k(_,["prevent"])},"🚪 "+n(t(a)("admin.nav.logout")),1)],8,L)]),e("div",N,[s(f)])])],64)}}});export{q as default};
|
||||
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.field-sub[data-v-d049fcf9]{display:block;font-size:13px;font-weight:600;color:var(--c-text-2);margin-bottom:6px}.result-head[data-v-8bebf3d9]{display:flex;align-items:center;gap:10px;margin-bottom:14px}.result-name[data-v-8bebf3d9]{font-weight:600;word-break:break-all}.link-row[data-v-8bebf3d9]{display:flex;gap:8px}.link-row .input[data-v-8bebf3d9]{flex:1}.result-meta[data-v-8bebf3d9]{display:flex;align-items:center;gap:6px;color:var(--c-text-2);font-size:13px;flex-wrap:wrap}.code-copy[data-v-8bebf3d9]{display:block;width:100%;cursor:pointer;appearance:none;-webkit-appearance:none;transition:filter .15s}.code-copy[data-v-8bebf3d9]:hover{filter:brightness(.96)}.code-copy[data-v-8bebf3d9]:active{filter:brightness(.92)}.dropzone.disabled[data-v-1b57fe5c]{opacity:.55;cursor:not-allowed}
|
||||
@@ -1 +0,0 @@
|
||||
import{d as w,u as b,a as y,z as x,A as k,b as e,f as t,t as a,w as V,C as B,D as C,c as m,g as _,q as N,j as r,N as S,B as q,o as d,H as L,_ as P}from"./index-DYsKpclu.js";import{P as A}from"./PageShell-D87JkPkG.js";import{u as D}from"./auth-B7MDTgxJ.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-CwaEJKIZ.js";import"./admin-DEOkRyTC.js";const E={class:"card",style:{"max-width":"380px",margin:"8vh auto 0"}},I={class:"login-head"},M=["src"],R={class:"card-title"},T={class:"card-sub"},U={class:"field"},j={for:"admin-password"},z=["placeholder"],H={key:0,class:"hint",style:{color:"var(--c-danger)","margin-bottom":"12px"}},F=["disabled"],G={key:0,class:"spin","aria-hidden":"true"},J={class:"hint",style:{"margin-top":"16px"}},K=w({__name:"LoginView",setup(O){const{t:s}=b(),c=S(),g=q(),h=D(),u=y(),i=r(""),l=r(!1),n=r("");async function f(){if(!i.value){n.value=s("admin.login.required");return}l.value=!0,n.value="";try{await h.login(i.value);const o=typeof c.query.redirect=="string"?c.query.redirect:"/admin/files";g.replace(o)}catch(o){n.value=o instanceof L?o.code===401?s("admin.login.wrongPassword"):o.msg:s("admin.login.failed"),i.value=""}finally{l.value=!1}}return(o,p)=>(d(),x(A,null,{default:k(()=>[e("section",E,[e("div",I,[e("img",{src:t(u).displayLogoUrl,alt:"Logo",class:"login-logo"},null,8,M),e("h1",R,a(t(s)("admin.login.title")),1),e("p",T,a(t(s)("admin.login.subtitle",{name:t(u).displayName})),1)]),e("form",{onSubmit:V(f,["prevent"])},[e("div",U,[e("label",j,a(t(s)("admin.login.password")),1),B(e("input",{id:"admin-password","onUpdate:modelValue":p[0]||(p[0]=v=>i.value=v),class:"input",type:"password",placeholder:t(s)("admin.login.passwordPlaceholder"),autocomplete:"current-password",autofocus:""},null,8,z),[[C,i.value]])]),n.value?(d(),m("p",H,a(n.value),1)):_("",!0),e("button",{class:"btn btn-block",type:"submit",disabled:l.value},[l.value?(d(),m("span",G)):_("",!0),N(" "+a(t(s)("admin.login.submit")),1)],8,F)],32),e("p",J,a(t(s)("admin.login.hint")),1)])]),_:1}))}}),$=P(K,[["__scopeId","data-v-26e7f2a4"]]);export{$ as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{d as c,u as i,z as d,A as a,b as t,t as e,f as s,G as l,q as p,O as u,o as _}from"./index-DYsKpclu.js";import{P as m}from"./PageShell-D87JkPkG.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-CwaEJKIZ.js";const f={class:"card empty",style:{"max-width":"480px",margin:"10vh auto 0"}},h={style:{"font-weight":"600",color:"var(--c-text)"}},x={class:"hint"},F=c({__name:"NotFoundView",setup(y){const{t:o}=i();return(g,n)=>{const r=u("RouterLink");return _(),d(m,null,{default:a(()=>[t("div",f,[n[0]||(n[0]=t("div",{class:"empty-icon"},"🧭",-1)),t("p",h,e(s(o)("notFound.title")),1),t("p",x,e(s(o)("notFound.desc")),1),l(r,{class:"btn",to:"/",style:{"margin-top":"10px"}},{default:a(()=>[p(e(s(o)("notFound.back")),1)]),_:1})])]),_:1})}}});export{F as default};
|
||||
-267
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{d as h,u as g,a as k,c as n,G as i,b as t,Q as y,e as v,f as e,t as a,g as c,q as r,A as d,F as x,h as B,O as C,o as l,_ as b}from"./index-DYsKpclu.js";import{_ as w}from"./SiteNav.vue_vue_type_script_setup_true_lang-CwaEJKIZ.js";const N={class:"site-footer"},S={class:"footer-left"},F={key:0,class:"footer-text"},P={key:1,class:"footer-beian"},T={key:2},V=["aria-label"],$=h({__name:"PageShell",setup(D){const{t:s}=g(),o=k(),m=new Date().getFullYear(),u=B(()=>!!(o.footerText.trim()||o.footerBeian.trim()));return(p,_)=>{const f=C("RouterLink");return l(),n(x,null,[i(w),t("main",{class:v(["page",{"page-wide":p.$route.meta.wide}])},[y(p.$slots,"default",{},void 0,!0)],2),t("footer",N,[t("div",S,[e(o).footerText.trim()?(l(),n("span",F,a(e(o).footerText),1)):c("",!0),e(o).footerBeian.trim()?(l(),n("span",P,a(e(o).footerBeian),1)):c("",!0),u.value?c("",!0):(l(),n("span",T,a(e(s)("footer.copyright",{year:e(m),name:e(o).displayName})),1))]),_[0]||(_[0]=t("span",{class:"footer-powered"},[r(" Powered by "),t("a",{href:"https://skymirror.top",target:"_blank",rel:"noopener noreferrer"},"SKYMirror"),r(" 26.9 ")],-1)),t("nav",{"aria-label":e(s)("footer.linkNav")},[i(f,{to:"/docs"},{default:d(()=>[r(a(e(s)("footer.docs")),1)]),_:1}),i(f,{to:"/openapi"},{default:d(()=>[r(a(e(s)("footer.openapi")),1)]),_:1}),i(f,{to:"/admin/files"},{default:d(()=>[r(a(e(s)("footer.admin")),1)]),_:1})],8,V)])],64)}}}),R=b($,[["__scopeId","data-v-80685d6f"]]);export{R as P};
|
||||
@@ -1 +0,0 @@
|
||||
import{d as b,u as p,c as r,b as o,t as i,f as c,h,o as f}from"./index-DYsKpclu.js";const v={class:"pager"},x={class:"pager-info"},k=["disabled"],M=["disabled"],y=b({__name:"Pager",props:{page:{},size:{},total:{}},emits:["change"],setup(t,{emit:m}){const a=t,d=m,{t:n}=p(),s=h(()=>Math.max(1,Math.ceil(a.total/a.size)));function g(l){const e=Math.min(Math.max(1,l),s.value);e!==a.page&&d("change",e,a.size)}return(l,e)=>(f(),r("div",v,[o("span",x,i(c(n)("common.pagerInfo",{total:t.total,page:t.page,pages:s.value})),1),o("button",{class:"btn btn-ghost btn-sm",type:"button",disabled:t.page<=1,onClick:e[0]||(e[0]=u=>g(t.page-1))},i(c(n)("common.previousPage")),9,k),o("button",{class:"btn btn-ghost btn-sm",type:"button",disabled:t.page>=s.value,onClick:e[1]||(e[1]=u=>g(t.page+1))},i(c(n)("common.nextPage")),9,M)]))}});export{y as _};
|
||||
@@ -1 +0,0 @@
|
||||
import{d as N,u as U,i as q,I,J as b,z as L,A as R,H as C,h as T,j as p,b as t,c as u,q as z,t as s,f as n,w as E,C as H,D as $,F as y,s as _,g as w,K as j,L as J,n as K,M as B,k as W,N as G,B as O,o as l}from"./index-DYsKpclu.js";import{P as Q}from"./PageShell-D87JkPkG.js";import{g as X,p as Y,b as Z}from"./share-CqDDLJWI.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-CwaEJKIZ.js";const ee={class:"card",style:{"max-width":"640px",margin:"12px auto 0"}},te={key:0,class:"loading-block"},ae={class:"empty"},oe={style:{"font-weight":"600",color:"var(--c-text)"}},ne={class:"hint"},se=["placeholder"],ie={class:"btn",type:"submit"},le={style:{display:"flex","align-items":"center",gap:"10px","flex-wrap":"wrap","margin-bottom":"4px"}},ue={class:"badge"},re={style:{"font-size":"16px","word-break":"break-all"}},ce={key:0,class:"hint",style:{margin:"0"}},pe={class:"hint",style:{"margin-bottom":"18px"}},de={key:0,class:"loading-block"},me={class:"text-view"},ve={style:{display:"flex",gap:"10px","margin-top":"14px","flex-wrap":"wrap"}},ye={class:"file-summary"},ke={style:{"font-weight":"600","word-break":"break-all"}},fe={class:"hint",style:{margin:"0"}},ge={key:0,style:{margin:"16px 0 6px"}},he={class:"progress"},xe={class:"hint"},_e=["disabled"],Be=N({__name:"PickupView",setup(we){const{t:e}=U(),D=G(),F=O(),k=q(),d=T(()=>String(D.params.code??"").trim().split(/\s+/)[0]),r=p("loading"),m=p(""),o=p(null),v=p(""),f=p(!1),c=p(null);async function g(){if(!d.value){r.value="error",m.value=e("pickup.emptyCode");return}r.value="loading",m.value="",o.value=null,v.value="";try{const a=await X(d.value);if(o.value=a,r.value="ready",a.isText){f.value=!0;try{v.value=await Y(d.value)}finally{f.value=!1}}}catch(a){r.value="error",m.value=a instanceof C?a.code===404?e("pickup.notFound"):a.code===423?e("home.rateLimited"):a.code===428?e("home.notInitialized"):a.msg:e("pickup.failedDefault")}}I(g),b(d,()=>{g()}),b(()=>e("pickup.emptyCode"),()=>{r.value==="error"&&m.value&&g()});async function A(){if(o.value){c.value=0;try{const{blob:a,filename:i}=await Z(d.value,x=>c.value=x);B(a,i||o.value.name||"download"),k.success(e("pickup.downloaded"))}catch(a){k.error(a instanceof C?a.msg:e("pickup.downloadFailed"))}finally{c.value=null}}}async function M(){await W(v.value)?k.success(e("pickup.copied")):k.error(e("common.copyFailed"))}function P(){if(!o.value)return;const a=new Blob([v.value],{type:"text/plain;charset=utf-8"}),i=o.value.name?.includes(".")?o.value.name:`${o.value.name||"text"}.txt`;B(a,i)}const h=p("");function S(){const a=h.value.trim();a&&F.push({name:"pickup",params:{code:a}})}const V=T(()=>{const a=o.value;return a?a.remainingDownloads===null||a.remainingDownloads===void 0||a.remainingDownloads<0?e("pickup.remainingUnlimited"):e("pickup.remainingCount",{n:a.remainingDownloads}):""});return(a,i)=>(l(),L(Q,null,{default:R(()=>[t("section",ee,[r.value==="loading"?(l(),u("div",te,[i[1]||(i[1]=t("span",{class:"spin","aria-hidden":"true"},null,-1)),z(" "+s(n(e)("pickup.querying",{code:d.value})),1)])):r.value==="error"?(l(),u(y,{key:1},[t("div",ae,[i[2]||(i[2]=t("div",{class:"empty-icon"},"📮",-1)),t("p",oe,s(m.value||n(e)("pickup.failed")),1),t("p",ne,s(n(e)("pickup.confirmHint")),1)]),t("form",{class:"quick-pickup",style:{"margin-top":"6px","max-width":"none"},onSubmit:E(S,["prevent"])},[H(t("input",{"onUpdate:modelValue":i[0]||(i[0]=x=>h.value=x),class:"input input-mono",placeholder:n(e)("pickup.retryPlaceholder"),maxlength:"32"},null,8,se),[[$,h.value]]),t("button",ie,s(n(e)("pickup.retryButton")),1)],32)],64)):o.value?(l(),u(y,{key:2},[t("div",le,[t("span",ue,s(o.value.isText?n(e)("common.text"):n(e)("common.file")),1),t("strong",re,s(o.value.name),1),o.value.isText?w("",!0):(l(),u("span",ce,s(n(_)(o.value.size)),1))]),t("p",pe,s(V.value)+" · "+s(n(e)("pickup.expireAt",{time:o.value.expiredAt?n(j)(o.value.expiredAt):n(e)("time.permanent")}))+" · "+s(n(J)(o.value.expiredAt)),1),o.value.isText?(l(),u(y,{key:0},[f.value?(l(),u("div",de,[i[3]||(i[3]=t("span",{class:"spin","aria-hidden":"true"},null,-1)),z(" "+s(n(e)("pickup.loadingText")),1)])):(l(),u(y,{key:1},[t("pre",me,s(v.value),1),t("div",ve,[t("button",{class:"btn",type:"button",onClick:M},s(n(e)("pickup.copyContent")),1),t("button",{class:"btn btn-ghost",type:"button",onClick:P},s(n(e)("pickup.downloadTxt")),1)])],64))],64)):(l(),u(y,{key:1},[t("div",ye,[i[4]||(i[4]=t("span",{style:{"font-size":"30px"},"aria-hidden":"true"},"📄",-1)),t("div",null,[t("div",ke,s(o.value.name),1),t("div",fe,s(n(e)("pickup.sizeUsed",{size:n(_)(o.value.size),n:o.value.usedCount})),1)])]),c.value!==null?(l(),u("div",ge,[t("div",he,[t("i",{style:K({width:`${c.value}%`})},null,4)]),t("p",xe,s(n(e)("pickup.downloading",{percent:c.value})),1)])):w("",!0),t("button",{class:"btn btn-block",type:"button",disabled:c.value!==null,onClick:A}," ⬇ "+s(n(e)("pickup.downloadFile",{size:n(_)(o.value.size)})),9,_e)],64))],64)):w("",!0)])]),_:1}))}});export{Be as default};
|
||||
-129
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.settings-grid[data-v-03aa64c9]{display:grid;gap:16px}.save-row[data-v-03aa64c9]{display:flex;justify-content:flex-end}.notify-switch-row[data-v-03aa64c9]{display:flex;align-items:center;justify-content:space-between;gap:12px}.notify-switch-row label[data-v-03aa64c9]{margin-bottom:0}.unit-row[data-v-03aa64c9]{display:flex;gap:8px;align-items:center}.unit-row .input[data-v-03aa64c9]{flex:1}.unit-select[data-v-03aa64c9]{flex:0 0 110px!important}
|
||||
-1
@@ -1 +0,0 @@
|
||||
import{d as z,u as E,a as x,c as i,G as B,f as t,A as m,b as a,F as _,r as v,az as S,t as r,h as d,aA as p,aB as T,O as A,o,z as M,q as V,e as b,aC as w}from"./index-DYsKpclu.js";const D={class:"site-nav"},F=["src"],I={class:"brand-name"},O=["aria-label"],R={class:"nav-controls"},U=["aria-label"],q=["aria-checked","title","onClick"],G={class:"nav-control-icon","aria-hidden":"true"},H=["title"],K=z({__name:"SiteNav",setup($){const{t:s}=E(),c=x(),{mode:u,setMode:k}=w(),g=d(()=>[{to:"/",label:s("nav.home"),match:n=>n==="/"}]);function y(n){return n.match(location.pathname)}const C={light:"☀️",dark:"🌙",system:"💻"},f=d(()=>({light:s("theme.light"),dark:s("theme.dark"),system:s("theme.system")})),L=d(()=>p()==="zh-CN"?"中文":"English");function N(){const n=p()==="zh-CN"?"en-US":"zh-CN";T(n)}return(n,l)=>{const h=A("RouterLink");return o(),i("header",D,[B(h,{class:"brand",to:"/",title:t(s)("nav.homeTitle",{name:t(c).displayName})},{default:m(()=>[a("img",{src:t(c).displayLogoUrl,alt:"Logo",onError:l[0]||(l[0]=e=>e.target.style.visibility="hidden")},null,40,F),a("span",I,r(t(c).displayName),1)]),_:1},8,["title"]),a("nav",{class:"nav-links","aria-label":t(s)("nav.mainNav")},[(o(!0),i(_,null,v(g.value,e=>(o(),M(h,{key:e.to,to:e.to,class:b({"router-link-active":y(e)})},{default:m(()=>[V(r(e.label),1)]),_:2},1032,["to","class"]))),128))],8,O),a("div",R,[a("div",{class:"theme-seg",role:"radiogroup","aria-label":t(s)("theme.label")},[(o(!0),i(_,null,v(t(S),e=>(o(),i("button",{key:e,type:"button",role:"radio","aria-checked":t(u)===e,class:b({active:t(u)===e}),title:f.value[e],onClick:j=>t(k)(e)},[a("span",G,r(C[e]),1)],10,q))),128))],8,U),a("button",{class:"nav-control",type:"button",title:t(s)("lang.label"),onClick:N},[l[1]||(l[1]=a("span",{class:"nav-control-icon","aria-hidden":"true"},"🌐",-1)),a("span",null,r(L.value),1)],8,H)])])}}});export{K as _};
|
||||
-1
@@ -1 +0,0 @@
|
||||
import{v as n,S as e,x as s}from"./index-DYsKpclu.js";function u(t){return n(s.adminLogin,{method:"POST",json:{password:t}})}function m(){return n(s.adminVerify)}async function l(){await n(s.adminLogout,{method:"POST"})}async function g(t){const a=await n(s.adminFileList,{query:{page:t.page,size:t.size,keyword:t.keyword||void 0}}),o=e(a,["data","list","items","files"])??[],d=Number(e(a,["total","count"])??o.length);return{page:Number(e(a,["page"])??t.page),size:Number(e(a,["size"])??t.size),total:d,data:o.map(i=>({id:Number(e(i,["id"])??0),code:String(e(i,["code"])??""),name:String(e(i,["name"])??`${e(i,["prefix"])??""}${e(i,["suffix"])??""}`),suffix:String(e(i,["suffix"])??""),size:Number(e(i,["size"])??0),isText:!!(e(i,["isText","is_text"])??!1),expiredAt:e(i,["expiredAt","expired_at","expires_at"])??null,expiredCount:e(i,["expiredCount","expired_count"])??null,usedCount:Number(e(i,["usedCount","used_count"])??0),createdAt:e(i,["createdAt","created_at"])??null,isExpired:!!(e(i,["isExpired","is_expired"])??!1)}))}}async function p(t){await n(s.adminFileDelete,{method:"DELETE",json:{id:t}})}async function f(t){await n(s.adminFileBatchDelete,{method:"POST",json:{ids:t}})}async function y(t){await n(s.adminFileUpdate,{method:"PATCH",json:t})}async function w(){const t=await n(s.adminConfigGet);if(t&&typeof t=="object"&&!Array.isArray(t)){const a=t;return e(a,["config","data","settings"])??a}return{}}async function S(t){await n(s.adminConfigUpdate,{method:"PATCH",json:t})}async function _(t){const a=await n(s.adminStorageSwitch,{method:"POST",json:{engine:t}});return String(a?.engine??t)}async function x(t,a){await n(s.adminPasswordUpdate,{method:"PATCH",json:{old_password:t,new_password:a}})}async function b(t){const a=await n(s.adminAuditList,{query:{page:t.page,size:t.size,action:t.action||void 0,result:t.result||void 0,ip:t.ip||void 0,start_time:t.startTime||void 0,end_time:t.endTime||void 0}}),o=e(a,["data","list","items","logs"])??[],d=Number(e(a,["total","count"])??o.length);return{page:Number(e(a,["page"])??t.page),size:Number(e(a,["size"])??t.size),total:d,data:o.map((i,r)=>({id:Number(e(i,["id"])??r+1),action:String(e(i,["action"])??""),result:String(e(i,["result"])??""),fileCode:String(e(i,["file_code","fileCode","code"])??""),fileName:String(e(i,["file_name","fileName","name"])??""),sizeBytes:e(i,["size_bytes","sizeBytes","size"])??null,transferredBytes:e(i,["transferred_bytes","transferredBytes","bytes"])??null,ip:String(e(i,["ip","client_ip","clientIp"])??""),userAgent:String(e(i,["user_agent","userAgent"])??""),deviceOs:String(e(i,["device_os","deviceOs","os"])??""),deviceBrowser:String(e(i,["device_browser","deviceBrowser","browser"])??""),deviceType:String(e(i,["device_type","deviceType"])??""),actor:String(e(i,["actor"])??""),errorMsg:String(e(i,["error_msg","errorMsg","error"])??""),durationMs:e(i,["duration_ms","durationMs","duration"])??null,createdAt:e(i,["created_at","createdAt","time"])??null}))}}export{g as a,f as b,p as c,y as d,b as e,w as f,_ as g,S as h,x as i,l as j,m as k,u as l};
|
||||
-1
@@ -1 +0,0 @@
|
||||
import{av as o,aw as a,W as r}from"./index-DYsKpclu.js";import{j as n,k as i,l as c}from"./admin-DEOkRyTC.js";const s="fcb_admin_flag",l=o("auth",{state:()=>({token:r(),checked:!1}),getters:{isAuthed:t=>!!t.token},actions:{async login(t){const e=(await c(t))?.token;if(!e)throw new Error("登录响应缺少 token");this.token=e,a(e);try{localStorage.setItem(s,"1")}catch{}this.checked=!0},async verify(){if(!this.token)return!1;try{return await i(),this.checked=!0,!0}catch{return this.reset(),!1}},async logout(){try{this.token&&await n()}catch{}this.reset()},reset(){this.token="",this.checked=!1,a("");try{localStorage.removeItem(s)}catch{}}}});export{l as u};
|
||||
-2517
File diff suppressed because one or more lines are too long
-28
File diff suppressed because one or more lines are too long
-1
@@ -1 +0,0 @@
|
||||
import{v as x,S as s,U as m,V as g,x as u,W as w,H as i,X as f,Y as h}from"./index-DYsKpclu.js";function T(n,t,r,a=""){return x(u.shareText,{method:"POST",form:{text:n,expire_value:String(t),expire_style:r,code:a}})}function y(n,t,r,a,o=""){const e=new FormData;return e.append("file",n),e.append("expire_value",String(t)),e.append("expire_style",r),o&&e.append("code",o),h(u.shareFile,e,a)}async function S(n){const t=await x(u.shareMetadata,{query:{code:n}}),r=!!(s(t,["is_text","isText"])??s(t,["type"])==="text");return{code:String(s(t,["code"])??n),name:String(s(t,["name"])??"未命名"),size:Number(s(t,["size"])??0),type:r?"text":"file",isText:r,createdAt:s(t,["created_at","createdAt"])??null,expiredAt:s(t,["expired_at","expires_at","expiredAt","expiresAt"])??null,expiredCount:s(t,["expired_count","expiredCount","remaining_downloads","remainingDownloads"])??null,usedCount:Number(s(t,["used_count","usedCount"])??0),remainingDownloads:s(t,["remaining_downloads","remainingDownloads","expired_count","expiredCount"])??null}}async function b(n){const{blob:t}=await m(u.shareSelect,{query:{code:n}});return t.text()}function A(n,t){return new Promise((r,a)=>{const o=new URL(g(u.shareSelect),location.origin);o.searchParams.set("code",n);const e=new XMLHttpRequest;e.open("GET",o.toString()),e.timeout=6e5,e.responseType="blob";const l=w();l&&e.setRequestHeader("Authorization",`Bearer ${l}`),e.onprogress=p=>{p.lengthComputable&&t&&t(Math.round(p.loaded/p.total*100))},e.onload=()=>{if((e.getResponseHeader("content-type")??"").includes("application/json")){const d=new FileReader;d.onload=()=>{try{const c=JSON.parse(String(d.result));a(new i(c.code??e.status,c.msg||"取件失败",e.status))}catch{a(new i(e.status,"取件失败",e.status))}},d.readAsText(e.response);return}e.status>=200&&e.status<300?r({blob:e.response,filename:f(e.getResponseHeader("content-disposition"))}):a(new i(e.status,`取件失败(HTTP ${e.status})`,e.status))},e.onerror=()=>a(new i(0,"网络异常,取件失败")),e.ontimeout=()=>a(new i(0,"下载超时,请重试")),e.send()})}export{y as a,A as b,S as g,b as p,T as s};
|
||||
Vendored
+1
-1
@@ -27,7 +27,7 @@
|
||||
background: #0b0f1a;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-DYsKpclu.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BumeFNdu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CtyCxWf5.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+7
-4
@@ -2,10 +2,13 @@
|
||||
|
||||
Vue 3 + Vite + TypeScript 单页应用:分享 / 取件 / 管理后台(文件管理、审计日志、系统设置)/ 站内文档。
|
||||
|
||||
v2(任务 t3):液态玻璃主题(亮/暗/跟随系统)、中英双语(vue-i18n)、本地 Logo/favicon 打包资源、
|
||||
背景图 / 页脚自定义 / 系统通知(读 `GET /api/v1/config` 下发的 v2 字段)、上传页策略动态渲染、
|
||||
26.9(任务 t3):液态玻璃主题(亮/暗/跟随系统)、中英双语(vue-i18n)、本地 Logo/favicon 打包资源、
|
||||
背景图 / 页脚自定义 / 系统通知(读 `GET /api/v1/config` 下发的 26.9 字段)、上传页策略动态渲染、
|
||||
管理设置页全量策略表单(PATCH `/admin/config/update`)。
|
||||
|
||||
26.9 起在管理设置「上传频率限制」卡底部新增**上下行带宽限速**字段(MB/s,0=不限速,
|
||||
提交后换算为字节/秒写入 `upload_rate`/`download_rate` KV)。
|
||||
|
||||
## 页面
|
||||
|
||||
| 路由 | 说明 |
|
||||
@@ -26,7 +29,7 @@ v2(任务 t3):液态玻璃主题(亮/暗/跟随系统)、中英双语
|
||||
- 语言(导航栏切换 + `localStorage` 键 `fcb_locale`):默认 `navigator.language`(zh 开头 → zh-CN,其余 → en-US)。
|
||||
文案在 `src/locales/`,新增文案先加 zh-CN 再同步 en-US。
|
||||
|
||||
## v2 配置字段(消费 `GET /api/v1/config`,管理端经 `PATCH /admin/config/update` 写回)
|
||||
## 26.9 配置字段(消费 `GET /api/v1/config`,管理端经 `PATCH /admin/config/update` 写回)
|
||||
|
||||
`background_url`、`footer_text`、`footer_beian`、`notify_enabled/notify_title/notify_content`、
|
||||
`max_file_size`、`max_save_seconds`、`max_save_count`、`allowedFileTypes`、`expireStyle`、
|
||||
@@ -63,7 +66,7 @@ npm run preview # 预览构建产物
|
||||
- 构建产物输出到 `web/dist/`(入口 `web/dist/index.html`)。**注意**:`web/` 在 Go module(server/)之外,go:embed 无法直接引用 —— go-api 需在构建脚本中拷贝(如 `cp -a web/dist server/web/dist`)或以 `os.DirFS` 方式挂载;`base: '/'` + history 路由配合其 NoRoute SPA 回退(静态资源优先、GET+text/html 回 index.html、/api/* JSON 404)。
|
||||
- 如需改输出目录,调整 `vite.config.ts` 的 `build.outDir`。
|
||||
|
||||
## Logo 自定义(v2:本地资源兜底)
|
||||
## Logo 自定义(26.9:本地资源兜底)
|
||||
|
||||
- 默认导航 Logo:`src/assets/brand/logo.svg`(构建内嵌;`DEFAULT_LOGO_URL`)
|
||||
- 默认 favicon:`src/assets/brand/favicon.png`(`index.html` + `DEFAULT_FAVICON_URL`)
|
||||
|
||||
Generated
+4
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "filecodebox-web",
|
||||
"version": "1.0.0",
|
||||
"name": "fileshare-web",
|
||||
"version": "26.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "filecodebox-web",
|
||||
"version": "1.0.0",
|
||||
"name": "fileshare-web",
|
||||
"version": "26.9",
|
||||
"dependencies": {
|
||||
"marked": "^16.3.0",
|
||||
"naive-ui": "^2.45.3",
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "filecodebox-web",
|
||||
"name": "fileshare-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "26.9",
|
||||
"type": "module",
|
||||
"description": "FileCodeBox Go 版前端:分享 / 取件 / 管理后台 / 审计日志 / 站内文档",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
|
||||
if (json !== undefined) body = JSON.stringify(json)
|
||||
else if (formData) body = formData
|
||||
else if (form) {
|
||||
// v3.1.1 根因修复:URLSearchParams.toString() 产出的是字符串 body,
|
||||
// 26.9 根因修复:URLSearchParams.toString() 产出的是字符串 body,
|
||||
// fetch 对字符串默认 Content-Type: text/plain —— 后端无法按表单解析,
|
||||
// 文本分享被静默存成空(或被空文本校验拒绝)。必须显式声明 urlencoded。
|
||||
body = new URLSearchParams(form).toString()
|
||||
|
||||
@@ -24,7 +24,7 @@ function routerBase(): string {
|
||||
}
|
||||
|
||||
export function pickupPageUrl(code: string, base?: string): string {
|
||||
// v3.1:配置了站点对外域名时用之(内网访问也能生成公网链接),否则当前地址
|
||||
// 26.9:配置了站点对外域名时用之(内网访问也能生成公网链接),否则当前地址
|
||||
const origin = (base?.trim() || location.origin).replace(/\/$/, '')
|
||||
return `${origin}${routerBase()}${encodeURIComponent(code)}`
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function getMetadata(code: string): Promise<ShareMetadata> {
|
||||
remainingDownloads:
|
||||
(pick<number>(raw, ['remaining_downloads', 'remainingDownloads', 'expired_count', 'expiredCount']) as number) ??
|
||||
null,
|
||||
downloadUrl: (pick<string>(raw, ['download_url', 'downloadUrl']) as string) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface ShareMetadata {
|
||||
expiredCount: number | null
|
||||
usedCount: number
|
||||
remainingDownloads: number | null
|
||||
/** 26.9:select 接口返回的下载地址(代理路径或 S3 预签名直链) */
|
||||
downloadUrl?: string | null
|
||||
}
|
||||
|
||||
export interface ShareTextResult {
|
||||
|
||||
@@ -40,6 +40,12 @@ const acceptHint = computed(() => {
|
||||
function accept(f: File | null | undefined): void {
|
||||
error.value = ''
|
||||
if (!f) return
|
||||
// 26.9:拒绝文件夹条目(webkitRelativePath 非空 = 来自文件夹选择)
|
||||
if ((f as File & { webkitRelativePath?: string }).webkitRelativePath) {
|
||||
error.value = t('drop.folderHint')
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
if (props.maxSize && f.size > props.maxSize) {
|
||||
error.value = t('drop.tooLarge', { size: formatBytes(f.size), limit: formatBytes(props.maxSize) })
|
||||
emit('update:modelValue', null)
|
||||
@@ -48,9 +54,25 @@ function accept(f: File | null | undefined): void {
|
||||
emit('update:modelValue', f)
|
||||
}
|
||||
|
||||
/** 26.9:拖拽条目里是否夹带目录(dataTransfer.files 对目录为空,须用 entries 探测) */
|
||||
function hasDirectory(items: DataTransferItemList | null | undefined): boolean {
|
||||
if (!items) return false
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const entry = items[i]?.webkitGetAsEntry?.()
|
||||
if (entry?.isDirectory) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent): void {
|
||||
dragover.value = false
|
||||
if (props.disabled) return
|
||||
// 26.9:拖入目录 → 明确提示"建议压缩后上传"(替代此前的静默忽略)
|
||||
if (hasDirectory(e.dataTransfer?.items)) {
|
||||
error.value = t('drop.folderHint')
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
accept(e.dataTransfer?.files?.[0])
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ const link = computed(() => pickupPageUrl(props.code, config.shareLinkBase))
|
||||
|
||||
async function copy(value: 'code' | 'link'): Promise<void> {
|
||||
// 复制链接本身即可:链接已含取件码(/s/<code>),再拼一遍会导致
|
||||
// 「/s/XVR3H XVR3H」被整串当码查询而 404(v3.1.2 遗留缺陷)
|
||||
// 「/s/XVR3H XVR3H」被整串当码查询而 404(26.9 遗留缺陷)
|
||||
const text = value === 'link' ? link.value : props.code
|
||||
const ok = await copyText(text)
|
||||
if (ok) {
|
||||
|
||||
@@ -47,6 +47,9 @@ export default {
|
||||
close: 'Close',
|
||||
actions: 'Actions',
|
||||
all: 'All',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
days: 'days',
|
||||
query: 'Query',
|
||||
reset: 'Reset',
|
||||
previousPage: 'Previous',
|
||||
@@ -144,6 +147,8 @@ export default {
|
||||
loadingText: 'Fetching content…',
|
||||
copyContent: 'Copy content',
|
||||
downloadTxt: 'Download as .txt',
|
||||
previewImage: 'Image preview',
|
||||
previewAudio: 'Audio playback',
|
||||
downloaded: 'Download complete',
|
||||
downloadFailed: 'Download failed, please retry',
|
||||
copied: 'Content copied',
|
||||
@@ -170,6 +175,7 @@ export default {
|
||||
remove: 'Remove',
|
||||
tooLarge: 'File size {size} exceeds the limit {limit}',
|
||||
typeHint: 'Allowed types: {types}',
|
||||
folderHint: 'Folder upload is not supported. Please compress it into an archive first.',
|
||||
},
|
||||
docs: {
|
||||
searchPlaceholder: 'Search documentation…',
|
||||
@@ -360,6 +366,28 @@ export default {
|
||||
uploadCountHint: 'Min 1, max {max}',
|
||||
uploadMinute: 'Window length (minutes) · uploadMinute',
|
||||
uploadMinuteHint: 'Min 1, max {max}',
|
||||
uploadRate: 'Upload bandwidth (optional)',
|
||||
uploadRateHint: '0 = unlimited; MB/s; range 0~1024. Live-effective (admin reads latest KV each request)',
|
||||
sectionRecycle: 'Recycle & Download Safety',
|
||||
recycleHint: 'Expired-share recycling, retention limit, content dedup, direct download and hotlink protection',
|
||||
recycleEnabled: 'Auto-recycle expired',
|
||||
recycleEnabledHint: 'Periodically delete expired/exhausted shares together with their stored objects',
|
||||
recycleInterval: 'Recycle scan interval',
|
||||
recycleIntervalHint: '1~1440 minutes; accessing an expired share also triggers immediate recycling',
|
||||
retentionDays: 'Max retention (days)',
|
||||
retentionDaysHint: 'Shares older than this many days are recycled automatically; 0 = unlimited',
|
||||
dedupEnabled: 'SHA512 dedup',
|
||||
dedupEnabledHint: 'Identical files are stored once (multiple shares reference the same object)',
|
||||
directDownload: 'Direct download',
|
||||
directDownloadHint: '302 redirect to a presigned URL on S3-like engines; bytes bypass this server',
|
||||
directLinkExpire: 'Direct link TTL',
|
||||
directLinkExpireHint: '1~60 minutes (never longer than the share remains valid)',
|
||||
hotlinkEnabled: 'Hotlink protection',
|
||||
hotlinkEnabledHint: 'Validates Referer against a whitelist; empty Referer (direct visit) is allowed',
|
||||
hotlinkWhitelist: 'Hotlink whitelist',
|
||||
hotlinkWhitelistHint: 'Comma-separated domains, supports *.example.com; empty = same-site only',
|
||||
downloadRate: 'Download bandwidth (optional)',
|
||||
downloadRateHint: '0 = unlimited; MB/s; range 0~1024. S3 presigned direct upload cannot be throttled server-side',
|
||||
// —— v3: friendly units + storage engine ——
|
||||
unitHour: 'Hour(s)',
|
||||
unitDay: 'Day(s)',
|
||||
|
||||
@@ -47,6 +47,9 @@ export default {
|
||||
close: '关闭',
|
||||
actions: '操作',
|
||||
all: '全部',
|
||||
enabled: '开启',
|
||||
disabled: '关闭',
|
||||
days: '天',
|
||||
query: '查询',
|
||||
reset: '重置',
|
||||
previousPage: '上一页',
|
||||
@@ -140,6 +143,8 @@ export default {
|
||||
loadingText: '正在获取内容…',
|
||||
copyContent: '复制内容',
|
||||
downloadTxt: '下载为 .txt',
|
||||
previewImage: '图片预览',
|
||||
previewAudio: '音频播放',
|
||||
downloaded: '下载完成',
|
||||
downloadFailed: '下载失败,请重试',
|
||||
copied: '内容已复制',
|
||||
@@ -166,6 +171,7 @@ export default {
|
||||
remove: '移除',
|
||||
tooLarge: '文件大小 {size} 超过限制 {limit}',
|
||||
typeHint: '仅支持 {types}',
|
||||
folderHint: '暂不支持文件夹上传,建议压缩后上传',
|
||||
},
|
||||
docs: {
|
||||
searchPlaceholder: '检索文档内容…',
|
||||
@@ -354,6 +360,28 @@ export default {
|
||||
uploadCountHint: '最小 1,最大 {max}',
|
||||
uploadMinute: '频率窗口(分钟)uploadMinute',
|
||||
uploadMinuteHint: '最小 1,最大 {max}',
|
||||
uploadRate: '上行带宽(可选)',
|
||||
uploadRateHint: '0 = 不限速;单位 MB/s;范围 0~1024。修改后立即生效(管理端读最新 KV)',
|
||||
sectionRecycle: '回收与下载安全',
|
||||
recycleHint: '过期分享自动回收、存储时长上限、内容去重、直链下载与防盗链',
|
||||
recycleEnabled: '过期自动回收',
|
||||
recycleEnabledHint: '开启后定时清理已过期/次数耗尽的分享(含其存储对象)',
|
||||
recycleInterval: '回收扫描间隔',
|
||||
recycleIntervalHint: '范围 1~1440 分钟;取件时发现过期也会立即触发回收',
|
||||
retentionDays: '最长存储时长',
|
||||
retentionDaysHint: '上传超过该天数的分享将被自动回收;0 = 不限制',
|
||||
dedupEnabled: 'SHA512 内容去重',
|
||||
dedupEnabledHint: '相同内容的文件仅存储一份(多分享引用同一对象)',
|
||||
directDownload: '直链下载',
|
||||
directDownloadHint: 'S3 等对象存储引擎时 302 跳转到限时直链,文件不经过本站带宽',
|
||||
directLinkExpire: '直链有效期',
|
||||
directLinkExpireHint: '范围 1~60 分钟(不超过分享剩余时效)',
|
||||
hotlinkEnabled: '下载防盗链',
|
||||
hotlinkEnabledHint: '校验 Referer 白名单;空 Referer(直接访问)放行',
|
||||
hotlinkWhitelist: '防盗链白名单',
|
||||
hotlinkWhitelistHint: '逗号分隔域名,支持 *.example.com;空 = 仅本站域名放行',
|
||||
downloadRate: '下行带宽(可选)',
|
||||
downloadRateHint: '0 = 不限速;单位 MB/s;范围 0~1024。S3 预签名直传(客户端→S3)无法限速',
|
||||
// —— v3:单位友好化 + 存储引擎 ——
|
||||
unitHour: '小时',
|
||||
unitDay: '天',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user