26.9(安全审计修复版)

Go 1.27.1 (Gin+GORM) + Vue 3 文件快传服务:

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

验证:gofmt/go vet/go test 全绿;二进制端到端冒烟通过
This commit is contained in:
2026-09-05 04:22:41 +08:00
commit 7f060dd0e4
173 changed files with 32455 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
# API 概述
文件快传 Go 版(v2.5.6)对外提供一套 REST API,覆盖文本/文件分享、分片上传、
预签名直传、管理后台与审计日志查询。本文档与 `server/internal/api/` 实际实现逐一对齐,
交互式规范见站内 `/openapi`(源文件 `docs/openapi.yaml`)。
## Base URL
- 服务默认监听 `:8466`Base URL 为 `http://<host>:8466`(下文示例统一用 `http://localhost:8466`)。
- **业务路由挂根路径**(与参考实现一致):`/share/*``/chunk/*``/presign/*``/admin/*``/setup`
- 仅两个公共接口带 `/api/v1` 前缀:`/api/v1/health``/api/v1/config`
## 统一响应封装
所有 JSON 接口返回统一结构,HTTP 状态码与 `code` 一致;失败时 `data` 缺省:
```json
{ "code": 200, "msg": "ok", "data": { } }
```
失败示例(404):
```json
{ "code": 404, "msg": "文件不存在" }
```
个别端点直接返回原始内容而非 JSON 封装(文档中已单独标注):
| 端点 | 响应形式 |
|---|---|
| `GET /share/select?code=`(文本分享) | `text/plain; charset=utf-8` 正文 |
| `GET /share/select?code=`(文件分享) | 文件二进制流(200/206,支持 Range |
| `GET /share/download?key=&code=`(文件分享) | 文件二进制流(200/206,支持 Range |
| `GET /admin/file/download?id=`(文件分享) | 文件二进制流 |
| `GET /setup` / `POST /setup`(表单) | HTML 向导/成功页 |
## 字段命名约定
- 接口字段以 **snake_case** 为主(`file_code``size_bytes`)。
- 文件列表与审计日志的行字段同时输出 **snake_case 与 camelCase 双份**(如 `expired_at``expiredAt`),文档以 snake_case 为准,camelCase 仅为前端兼容保留。
## 认证
- 游客接口无需认证;是否允许游客上传由配置 `openUpload` 控制(关闭时上传类接口要求管理员 `Authorization: Bearer <token>`,否则 403)。
- 管理接口(`/admin/login` 除外)一律要求 `Authorization: Bearer <JWT>`,无效/缺失返回 401。
- 详情见《认证与限流》。
## 限流
按 IP(可信代理场景解析 `X-Forwarded-For`)维度限流,超限返回 **423**
| 规则 | 计数时机 | 默认(次/窗口) | 相关配置 |
|---|---|---|---|
| `upload` | **上传成功**后计数 | 10 次 / 1 分钟 | `uploadCount` / `uploadMinute` |
| `error` | 取件失败(404/过期)时计数 | 10 次 / 1 分钟 | `errorCount` / `errorMinute` |
| `login` | 登录失败时计数 | 5 次 / 15 分钟 | `loginCount` / `loginMinute` |
| `metadata` | 每次访问即计数 | 同 `error` | `errorCount` / `errorMinute` |
## 初始化守卫
系统未初始化(未设置管理员密码)时,除 `GET|POST /setup``GET /api/v1/health` 外,
**所有接口一律返回 428**
```json
{ "code": 428, "msg": "系统未初始化,请先完成初始化" }
```
首次部署请先访问 `GET /setup` 获取 HTML 向导,或直接 `POST /setup` 完成初始化(见《管理后台 API》初始化章节)。
## 审计
所有上传/下载端点经审计中间件自动落库(操作时间/IP/UA/设备解析/动作/结果/字节数/耗时/角色),
失败与被拒绝的请求同样记录;管理端经 `GET /admin/audit/list` 查询,详见《审计日志》。
## 端点总览
| 模块 | 端点 |
|---|---|
| 公共 | `GET /api/v1/health` · `GET /api/v1/config` · `GET /robots.txt`(输出 `robotsText` 配置) |
| 初始化 | `GET /setup` · `POST /setup` |
| 文本分享 | `POST /share/text` |
| 文件分享 | `POST /share/file` |
| 查询与取件 | `GET/POST /share/metadata` · `GET /share/select` · `POST /share/select` · `GET /share/download` |
| 分片上传 | `POST /chunk/upload/init` · `POST /chunk/upload/{uploadID}/{chunkIndex}` · `GET /chunk/upload/status/{uploadID}` · `POST /chunk/upload/complete/{uploadID}` · `DELETE /chunk/upload/{uploadID}` |
| 预签名直传 | `POST /presign/upload/init` · `PUT /presign/upload/proxy/{uploadID}` · `POST /presign/upload/confirm/{uploadID}` · `GET /presign/upload/status/{uploadID}` · `DELETE /presign/upload/{uploadID}` |
| 管理后台 | `POST /admin/login` · `GET /admin/verify` · `POST /admin/logout` · `GET /admin/dashboard` · 文件管理 `/admin/file/*` · 配置 `/admin/config/*` · 密码 `/admin/settings/password` |
| 审计日志 | `GET /admin/audit/list`(别名 `/admin/audit/logs` |
## 时间与编码
- 时间字段一律 RFC 3339(如 `2025-06-01T12:00:00+08:00`);管理员会话过期时间为 Unix 秒。
- 请求体支持 `application/json``application/x-www-form-urlencoded`(上传类为 `multipart/form-data`),文档示例以 JSON/curl 为主。
- CORS:公开接口放开(Bearer 认证,无 Cookie CSRF 面);**管理端 `/admin/*` 已收紧**——携带 Origin 且既不同源也不在 `site_domain` 白名单时不下发 CORS 头(浏览器拦截跨域读取)。
## 交互式文档
- 站内文档页:`/docs`(渲染本目录 markdown,构建时内嵌)。
- Swagger UI`/openapi`(渲染 `docs/openapi.yaml`,构建时内嵌)。
- OpenAPI 规范源文件为仓库内 `docs/openapi.yaml`;如需经后端直接下载,
需在部署时把它拷贝进前端静态产物 `web/dist/`(未拷贝时该路径按 SPA 回退返回页面)。
+105
View File
@@ -0,0 +1,105 @@
# 认证与限流
## 角色
| 角色 | 能力 |
|---|---|
| 游客(无 Authorization 头) | 取件、查询元信息;`openUpload=1` 时可上传 |
| 管理员(`Authorization: Bearer <JWT>` | 全部能力 + `/admin/*` 管理接口 |
## 管理员令牌
-`POST /admin/login` 用管理员密码换取,HS256 JWT,默认有效期 **7 天**`adminSessionExpire`1~365 整天,v2.5.6 起由 30 天缩短)。
- 请求头格式:`Authorization: Bearer <token>`
- **改密/重置管理员密码会轮换 `jwt_secret`,所有已签发令牌立即失效**(401)。
- 密码存储为 bcryptcost 12);历史 `sha256$`/明文格式在登录成功后自动升级重哈希,无需手动迁移。
- 游客上传关闭(`openUpload=0`)时,上传类接口也可用管理员 Bearer 令牌通过鉴权。
## 认证失败语义
| 场景 | 状态码 |
|---|---|
| `/admin/*` 缺失/无效令牌 | 401 |
| `POST /admin/login` 密码错误 | 401(并计入 login 限流) |
| 游客上传被关闭且未携带有效令牌 | 403 |
| 代理下载 `key` 校验失败 | 403 |
## 未初始化(428
管理员密码未设置(`admin_token` 为空)时,除 `GET|POST /setup``GET /api/v1/health` 外全部接口返回 428。
完成 `POST /setup` 初始化后自动解除。
## 限流规则
限流按 **客户端 IP** 维度(配置 `FCB_TRUSTED_PROXIES` 声明可信代理 CIDR,命中时解析 `X-Forwarded-For` 取真实 IP),
窗口计数原子化存储于缓存(未配置 Redis 时为进程内存)。**超限一律返回 423**:
```json
{ "code": 423, "msg": "请求次数过多,请稍后再试" }
```
| 规则 | 生效端点 | 计数时机 | 默认 | 配置键 |
|---|---|---|---|---|
| `upload` | `/share/text``/share/file``/chunk/upload/*``/presign/upload/*` | **成功后**计数(进入时仅检查) | 10 次 / 1 分钟 | `uploadCount``uploadMinute` |
| `error` | `/share/select``/share/download` | 取件失败(不存在/过期/鉴权失败)时计数 | 10 次 / 1 分钟 | `errorCount``errorMinute` |
| `login` | `/admin/login` | 登录失败时计数 | 5 次 / 15 分钟 | `loginCount``loginMinute` |
| `metadata` | `/share/metadata` | **每次访问即计数**(含失败) | 同 `error` | `errorCount``errorMinute` |
- 规则值可由管理端 `PATCH /admin/config/update` 运行时修改,立即生效(无需重启)。
- 取件成功(`/share/select``/share/download`)不计入 `error` 限流。
## 代理下载令牌(key
`GET /share/download``key` 由服务端按窗口生成:
`sha256(code + timeFactor + "000" + jwt_secret)``timeFactor = unix秒 / 1000`(约 16.7 分钟一个窗口)。
服务端**同时接受当前与上一窗口**的令牌,避免窗口边界竞态。令牌通过 `POST /share/select` 的响应
`download_url` 下发,客户端不应自行构造。
## 示例
登录获取令牌:
```bash
curl -s http://localhost:8466/admin/login \
-H 'Content-Type: application/json' \
-d '{"password":"your-admin-password"}'
```
```json
{
"code": 200, "msg": "ok",
"data": {
"id": "admin", "username": "admin",
"token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_at": 1750000000,
"expires_in": 604800
}
}
```
携带令牌调用管理接口:
```bash
TOKEN="eyJhbGciOiJIUzI1NiIs..."
curl -s http://localhost:8466/admin/dashboard -H "Authorization: Bearer $TOKEN"
```
校验令牌是否有效:
```bash
curl -s http://localhost:8466/admin/verify -H "Authorization: Bearer $TOKEN"
```
```json
{
"code": 200, "msg": "ok",
"data": { "id": "admin", "username": "admin", "token": "eyJhbGciOiJIUzI1NiIs...", "token_type": "Bearer", "expires_at": 1750000000 }
}
```
令牌失效时:
```json
{ "code": 401, "msg": "令牌无效或已过期" }
```
+91
View File
@@ -0,0 +1,91 @@
# 文本分享
创建纯文本分享,返回取件码。文本大小上限 **222KB**(超限建议改用文件分享);请求体全局上限 1MiB,`Content-Length` >441KB 时读前直接 403。
经审计中间件落库(action=upload)。
## POST /share/text
**请求参数**`application/x-www-form-urlencoded`,亦支持 multipart`text` 为必需):
| 参数 | 类型 | 必需 | 默认 | 说明 |
|---|---|---|---|---|
| `code` | string,可选;自定义提取码,5-8 位字母或数字(空=随机生成;占用 400「该提取码已被占用」) |
| `text` | string | ✅ | - | 文本内容(≤222KB,按 UTF-8 字节数) |
| `expire_value` | int | ❌ | `1` | 过期值(配合 `expire_style` |
| `expire_style` | string | ❌ | `day` | `day`/`hour`/`minute`/`count`/`forever`(须在站点允许列表内) |
过期语义:
- `day`/`hour`/`minute`:按时间过期,`expired_count = -1`
- `count`:按次数过期,取件 `expire_value` 次后失效(`expired_count = expire_value`);
**v2 需求 ④**`max_save_count>0``expire_value` 不得超出该上限,超限 403。
- `forever`:永久(需站点允许;`max_save_seconds>0` 时其他方式受最长保存上限约束,超限 403)。
> 可选值与上限来自公开配置 `GET /api/v1/config``expireStyle`、`max_save_seconds`、
> `max_save_count`),上传页动态读取并在范围内选择;管理端改策略后立即生效。
**curl 示例**
```bash
# 自定义提取码(可选):-d 'code=MYCODE1'
curl -s -X POST http://localhost:8466/share/text \
-d 'text=你好,文件快传' \
-d 'expire_value=1' \
-d 'expire_style=day'
```
**成功响应**200):
```json
{ "code": 200, "msg": "ok", "data": { "code": "8XQ2M" } }
```
`data.code` 为 5 位取件码(数字或大写字母+数字,取决于 `code_generate_type`)。
**错误响应**
```json
{ "code": 400, "msg": "过期时间类型错误" }
```
```json
{ "code": 400, "msg": "过期时间值必须大于 0" }
```
```json
{ "code": 403, "msg": "内容过多,建议采用文件形式" }
```
```json
{ "code": 403, "msg": "限制最长时间为 7天,可换用其他方式" }
```
```json
{ "code": 403, "msg": "限制次数最多为 5 次" }
```
```json
{ "code": 423, "msg": "请求次数过多,请稍后再试" }
```
> 游客上传关闭(`openUpload=0`)时需携带管理员令牌,否则 403:
> `{"code":403,"msg":"本站未开启游客上传,如需上传请先登录后台"}`
## 取回文本
文本分享的取回走统一的取件接口(消耗次数):
- `GET /share/select?code=<code>``text/plain` 正文即文本内容(响应头 `Content-Disposition` 带文件名,无扩展名时为 `<prefix>.txt`)。
- `POST /share/select``{"code":"8XQ2M"}`)→ JSON`data.text` / `data.content` 为文本内容。
示例:
```bash
curl -s "http://localhost:8466/share/select?code=8XQ2M"
```
```text
你好,文件快传
```
**v3.1 变更**:① 支持 JSON 提交(`Content-Type: application/json`,字段同名);② 空文本 400「分享内容不能为空」;③ 可选 `code` 自定义提取码(5-8 位字母数字,占用 400)。
+103
View File
@@ -0,0 +1,103 @@
# 文件分享
上传单个文件并创建分享。支持扩展名/MIME 白名单 + **magic bytes 防伪**(读文件前 64 字节校验,
伪造类型返回 403)。经审计中间件落库(action=upload,记录文件总大小与实际传输字节)。
## POST /share/file
**请求参数**`multipart/form-data`):
| 参数 | 类型 | 必需 | 默认 | 说明 |
|---|---|---|---|---|
| `code` | string,可选;自定义提取码,5-8 位字母或数字(空=随机生成;占用 400) |
| `file` | file | ✅ | - | 上传的文件(大小 ≤ 生效上限:`max_file_size>0` 时为其,否则 `uploadSize` |
| `expire_value` | int | ❌ | `1` | 过期值(配合 `expire_style``count` 型受 `max_save_count` 约束) |
| `expire_style` | string | ❌ | `day` | `day`/`hour`/`minute`/`count`/`forever`(须在 `expireStyle` 白名单内) |
**curl 示例**
```bash
curl -s -X POST http://localhost:8466/share/file \
-F 'file=@./report.pdf;type=application/pdf' \
-F 'expire_value=7' \
-F 'expire_style=day'
```
**成功响应**200):
```json
{ "code": 200, "msg": "ok", "data": { "code": "K3P9W", "name": "report.pdf" } }
```
**错误响应**
```json
{ "code": 400, "msg": "缺少上传文件 file 字段" }
```
```json
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
```
> 大小上限为动态策略(v2 需求 ④⑩):管理端改 `max_file_size`0=回落 `uploadSize`)后
> **下一次上传立即按新上限执行**,无需重启;上限值可经 `GET /api/v1/config` 的
> `max_file_size`/`maxFileSize` 字段读取。
```json
{ "code": 403, "msg": "不允许上传该类型文件" }
```
```json
{ "code": 403, "msg": "文件内容与扩展名不匹配,疑似伪造类型" }
```
```json
{ "code": 403, "msg": "限制最长时间为 7天,可换用其他方式" }
```
```json
{ "code": 403, "msg": "限制次数最多为 5 次" }
```
```json
{ "code": 403, "msg": "请求次数过多,请稍后再试" }
```
```json
{ "code": 507, "msg": "存储空间已达到管理员设置的容量上限" }
```
```json
{ "code": 503, "msg": "存储服务不可用,请稍后再试" }
```
## 文件类型白名单
由配置 `allowed_file_types` 控制(管理端可改):
- `*`:不限制(默认)。
- 扩展名规则:`.png``pdf`(自动补点)等,按文件名后缀匹配。
- MIME 规则:`image/*``application/pdf` 等,按请求 `Content-Type` 通配匹配。
已知类型(png/jpg/gif/webp/bmp/pdf/zip/rar/7z/gz/mp3/mp4/exe/elf)会做 **magic bytes 交叉校验**
扩展名或 Content-Type 声明了已知类型,但文件头不匹配时拒绝(403「疑似伪造类型」)。
## 下载取件
- `GET /share/select?code=<code>`:消耗 1 次取件,返回文件流(`200` 全量 / `206` 区间,
支持 `Range` 请求头;响应含 `Accept-Ranges: bytes``Content-Disposition: attachment; filename*=UTF-8''...`)。
- `POST /share/select`:返回详情 JSON`download_url` 为代理下载地址(见下)。
- `GET /share/download?key=<token>&code=<code>`:代理下载,消耗 1 次,同样支持 Range。
Range 示例(取前 1024 字节):
```bash
curl -s -H 'Range: bytes=0-1023' -o part.bin \
"http://localhost:8466/share/select?code=K3P9W"
```
区间越界返回:
```json
{ "code": 416, "msg": "请求范围超出文件大小" }
```
+173
View File
@@ -0,0 +1,173 @@
# 分享查询与取件
查询分享元信息(不消耗次数)与真正取件(消耗次数)的完整接口。
`metadata` 每次 423 限流计数外,取件失败还会计入 `error` 限流。
## 元信息:GET /share/metadata
按取件码查询元信息,**不消耗次数**。每次访问即计入 `metadata` 限流。
**参数**`code`query,必需)。
```bash
curl -s "http://localhost:8466/share/metadata?code=K3P9W"
```
**成功响应**200):
```json
{
"code": 200, "msg": "ok",
"data": {
"code": "K3P9W",
"name": "report.pdf",
"size": 1048576,
"type": "file",
"is_text": false,
"created_at": "2025-06-01T12:00:00+08:00",
"expired_at": "2025-06-08T12:00:00+08:00",
"expires_at": "2025-06-08T12:00:00+08:00",
"expired_count": -1,
"used_count": 3,
"remaining_downloads": null
}
}
```
字段说明:
| 字段 | 说明 |
|---|---|
| `type` | `text`(文本分享)/ `file`(文件分享) |
| `is_text` | 是否文本分享 |
| `size` | 字节数 |
| `expired_at` / `expires_at` | 过期时间(RFC 3339);永久分享为 `null` |
| `expired_count` | `-1` 按时间/永久;`>0` 剩余可取次数(原始限额) |
| `used_count` | 已取次数 |
| `remaining_downloads` | 剩余可取次数(仅次数型分享有值,否则 `null` |
> 不返回存储路径等敏感字段。
**错误响应**
```json
{ "code": 404, "msg": "文件不存在" }
```
```json
{ "code": 404, "msg": "文件已过期" }
```
## 元信息(POST):POST /share/metadata
等价的 JSON 版本(`code` 放请求体):
```bash
curl -s -X POST http://localhost:8466/share/metadata \
-H 'Content-Type: application/json' \
-d '{"code":"K3P9W"}'
```
响应与 GET 版本一致。
## 取件(消耗次数):GET /share/select
**每调用一次消耗 1 次取件**(次数型分享扣减 `expired_count`;时间型扣减不计)。
- 文本分享:返回 `text/plain; charset=utf-8` 正文(非 JSON 封装),`Content-Disposition` 携带文件名。
- 文件分享:返回文件流(`200` 全量 / `206` 区间),支持 `Range`
```bash
curl -s -OJ "http://localhost:8466/share/select?code=K3P9W"
```
次数耗尽或已过期:
```json
{ "code": 404, "msg": "文件已过期" }
```
超限(计入 error 限流):
```json
{ "code": 423, "msg": "请求次数过多,请稍后再试" }
```
## 取件详情:POST /share/select
返回元信息 + 文本内容/下载地址的 JSON 详情。**消耗语义**:次数型分享(`expired_count >= 0`
返回代理地址 `download_url` 且本次**不消耗**(消耗发生在访问代理地址时);时间型/永久/文本分享在本次消耗。
```bash
curl -s -X POST http://localhost:8466/share/select \
-H 'Content-Type: application/json' \
-d '{"code":"K3P9W"}'
```
**文件分享响应**200):
```json
{
"code": 200, "msg": "ok",
"data": {
"code": "K3P9W",
"name": "report.pdf",
"size": 1048576,
"type": "file",
"is_text": false,
"created_at": "2025-06-01T12:00:00+08:00",
"expired_at": "2025-06-08T12:00:00+08:00",
"expires_at": "2025-06-08T12:00:00+08:00",
"expired_count": -1,
"used_count": 4,
"remaining_downloads": null,
"text": "/share/download?key=9f2c…&code=K3P9W",
"download_url": "/share/download?key=9f2c…&code=K3P9W"
}
}
```
> S3 引擎下时间型/永久分享的 `download_url` 可能是预签名直链(1 小时有效)而非代理地址;
> 次数型分享恒为代理地址 `"/share/download?key=…&code=…"`。
**文本分享响应**200):
```json
{
"code": 200, "msg": "ok",
"data": {
"code": "8XQ2M", "name": "Text.txt", "size": 24, "type": "text", "is_text": true,
"created_at": "2025-06-01T12:00:00+08:00",
"expired_at": "2025-06-02T12:00:00+08:00", "expires_at": "2025-06-02T12:00:00+08:00",
"expired_count": -1, "used_count": 1, "remaining_downloads": null,
"text": "你好,文件快传",
"content": "你好,文件快传",
"download_url": null
}
}
```
## 代理下载:GET /share/download
`POST /share/select` 返回的代理地址,**每次访问消耗 1 次**,支持 Range。
| 参数 | 说明 |
|---|---|
| `key` | 窗口令牌(服务端下发,双窗口校验) |
| `code` | 取件码 |
```bash
curl -s -OJ "http://localhost:8466/share/download?key=9f2c…&code=K3P9W"
```
**错误响应**
```json
{ "code": 403, "msg": "下载鉴权失败" }
```
```json
{ "code": 404, "msg": "文件已过期" }
```
> `key` 鉴权失败会计入 error 限流;文本分享经该接口返回 JSON 封装 `data` 为文本内容。
+212
View File
@@ -0,0 +1,212 @@
# 分片上传
大文件分片上传:客户端把文件切成固定大小的分片逐个上传,服务端按索引合并并做 SHA256 校验。
支持**断点续传**(相同 `file_hash` + 大小 + 文件名的未完成会话自动续传)。
需站点开启 `enableChunk`(公共配置 `enableChunk` 返回 `true`)。
分片会话保留 24 小时。全部端点经审计中间件落库(action=upload)。
## 上传流程
```text
POST /chunk/upload/init → upload_id, total_chunks
POST /chunk/upload/{id}/{index} → 逐片上传(0 起,可并发)
GET /chunk/upload/status/{id} → 断点续传时查进度
POST /chunk/upload/complete/{id} → 合并 + SHA256 → 取件码
DELETE /chunk/upload/{id} → 取消(可选)
```
## 初始化:POST /chunk/upload/init
**请求体**JSON,亦兼容表单):
| 参数 | 类型 | 必需 | 默认 | 说明 |
|---|---|---|---|---|
| `file_name` | string | ✅ | - | 文件名(会做清理与白名单校验) |
| `file_size` | int | ✅ | - | 文件总字节数(>0;服务端按分片数校验上限) |
| `chunk_size` | int | ❌ | `5242880`(5MB) | 每片大小(字节),硬上限 32MiB(超出 400「chunk_size 过大」) |
| `file_hash` | string | ❌ | - | 整文件 SHA256(断点续传的匹配键) |
**curl 示例**
```bash
curl -s -X POST http://localhost:8466/chunk/upload/init \
-H 'Content-Type: application/json' \
-d '{"file_name":"movie.mp4","file_size":15728640,"chunk_size":5242880,"file_hash":"<sha256>"}'
```
**成功响应**200,新建会话):
```json
{
"code": 200, "msg": "ok",
"data": {
"existed": false,
"upload_id": "3f6b8c2a4d5e6f708192a3b4c5d6e7f8",
"chunk_size": 5242880,
"total_chunks": 3,
"uploaded_chunks": []
}
}
```
**断点续传响应**(200,命中未完成会话):返回既有会话,`uploaded_chunks` 为已传分片索引列表,
客户端只需补传缺失分片(注意:`existed` 字段恒为 `false`,是否续传以 `upload_id` 复用且
`uploaded_chunks` 非空为准):
```json
{
"code": 200, "msg": "ok",
"data": {
"existed": false,
"upload_id": "3f6b8c2a4d5e6f708192a3b4c5d6e7f8",
"chunk_size": 5242880,
"total_chunks": 3,
"uploaded_chunks": [0, 1]
}
}
```
**错误响应**
```json
{ "code": 400, "msg": "file_size 必须大于 0" }
```
```json
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
```
```json
{ "code": 403, "msg": "分片上传未启用" }
```
## 上传分片:POST /chunk/upload/{uploadID}/{chunkIndex}
主路径(与参考实现语义一致)。`chunkIndex``0` 起。
**multipart 字段**`chunk`(必需,该分片的二进制数据)。
**curl 示例**
```bash
split -b 5242880 movie.mp4 part- # 本地分片
curl -s -X POST http://localhost:8466/chunk/upload/3f6b8c2a4d5e6f708192a3b4c5d6e7f8/0 \
-F 'chunk=@./part-aa'
```
**成功响应**200):
```json
{ "code": 200, "msg": "ok", "data": { "chunk_hash": "9af1…", "chunk_index": 0 } }
```
重复上传已完成的分片(幂等跳过):
```json
{ "code": 200, "msg": "ok", "data": { "chunk_hash": "9af1…", "chunk_index": 0, "skipped": true } }
```
**错误响应**
```json
{ "code": 404, "msg": "上传会话不存在" }
```
```json
{ "code": 400, "msg": "无效的分片索引" }
```
```json
{ "code": 400, "msg": "分片大小超过声明值: 最大 5242880, 实际 5300000" }
```
```json
{ "code": 400, "msg": "缺少分片文件字段 chunk" }
```
```json
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
```
> 约束:单分片 ≤ `chunk_size`init 声明值)且 ≤ **32MiB 硬上限**init 时 `chunk_size>33554432` 直接 400「chunk_size 过大」);
> 总大小(init 按分片数上限、上传/合并按累计)受**动态策略上限**约束——`max_file_size>0` 时为其,
> 否则回落 `uploadSize`(v2 需求 ④⑩,管理端改后立即生效,超限清理会话);首个分片做 magic bytes
> 防伪校验(403「文件内容校验失败:…」)。分片哈希由服务端计算;合并时与分片记录交叉校验,不一致报 400。
>
> **enableChunk 开关**:管理端关闭分片上传后,`/chunk/upload/*` 全部端点返回 403「分片上传未启用」(后端强制,前端仅隐藏入口)。
## 查询进度:GET /chunk/upload/status/{uploadID}
```bash
curl -s http://localhost:8466/chunk/upload/status/3f6b8c2a4d5e6f708192a3b4c5d6e7f8
```
**成功响应**200):
```json
{
"code": 200, "msg": "ok",
"data": {
"upload_id": "3f6b8c2a4d5e6f708192a3b4c5d6e7f8",
"file_name": "movie.mp4",
"file_size": 15728640,
"chunk_size": 5242880,
"total_chunks": 3,
"uploaded_chunks": [0, 1],
"progress": 66.66666666666667
}
}
```
## 完成合并:POST /chunk/upload/complete/{uploadID}
全部分片到齐后调用。服务端按索引有序合并 + SHA256 校验,成功后创建分享并清理分片。
**请求体**JSON,亦兼容表单):
| 参数 | 类型 | 必需 | 默认 | 说明 |
|---|---|---|---|---|
| `expire_value` | int | ❌ | `1` | 过期值 |
| `expire_style` | string | ❌ | `day` | 过期方式(同文件分享) |
```bash
curl -s -X POST http://localhost:8466/chunk/upload/complete/3f6b8c2a4d5e6f708192a3b4c5d6e7f8 \
-H 'Content-Type: application/json' \
-d '{"expire_value":1,"expire_style":"day"}'
```
**成功响应**200):
```json
{ "code": 200, "msg": "ok", "data": { "code": "R7T2K", "name": "movie.mp4" } }
```
**错误响应**
```json
{ "code": 400, "msg": "分片不完整" }
```
```json
{ "code": 400, "msg": "分片哈希校验失败,请重新上传" }
```
```json
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
```
## 取消上传:DELETE /chunk/upload/{uploadID}
清理分片文件与上传记录,释放容量预留。
```bash
curl -s -X DELETE http://localhost:8466/chunk/upload/3f6b8c2a4d5e6f708192a3b4c5d6e7f8
```
```json
{ "code": 200, "msg": "ok", "data": { "message": "上传已取消" } }
```
```json
{ "code": 404, "msg": "上传会话不存在" }
```
+210
View File
@@ -0,0 +1,210 @@
# 预签名直传
服务端预生成上传地址,客户端直接向存储引擎(或服务端代理)上传文件,最后确认建分享。
两种模式:
| 模式 | 引擎 | 上传方式 |
|---|---|---|
| `direct` | S3(含 MinIO/R2 等 S3 兼容存储) | 客户端 `PUT` 到预签名 URL,直连对象存储 |
| `proxy` | 本地 / WebDAV | 客户端 `PUT` multipart 到服务端代理接口 |
> 本地 / WebDAV 引擎不支持预签名直链,init 返回正常 `proxy` 模式(仅当引擎预签名调用本身异常时才报错)。
> 会话有效期 **900 秒**(15 分钟)。proxy 模式响应另含 `legacy_proxy_upload_url`
> (`/api` 前缀的兼容别名,已废弃,与 `upload_url` 等价)。
> 全部端点经审计中间件落库(action=upload)。
## 上传流程
```text
direct 模式:
POST /presign/upload/init → upload_urlS3 预签名 PUT
PUT <upload_url> → 客户端直传 S3(无认证头)
POST /presign/upload/confirm/{id} → 确认 → 取件码
proxy 模式:
POST /presign/upload/init → upload_url = /presign/upload/proxy/{id}
PUT /presign/upload/proxy/{id} → multipart 上传,服务端转存并直接建分享
```
## 初始化:POST /presign/upload/init
**请求体**JSON,亦兼容表单):
| 参数 | 类型 | 必需 | 默认 | 说明 |
|---|---|---|---|---|
| `file_name` | string | ✅ | - | 文件名(清理 + 白名单校验) |
| `file_size` | int | ✅ | - | 文件字节数(≤ 生效上限:`max_file_size>0` 时为其,否则 `uploadSize` |
| `expire_value` | int | ❌ | `1` | 过期值(`count` 型受 `max_save_count` 约束) |
| `expire_style` | string | ❌ | `day` | 过期方式(须在 `expireStyle` 白名单内) |
**curl 示例**
```bash
curl -s -X POST http://localhost:8466/presign/upload/init \
-H 'Content-Type: application/json' \
-d '{"file_name":"backup.zip","file_size":20971520,"expire_value":7,"expire_style":"day"}'
```
**S3direct)成功响应**200):
```json
{
"code": 200, "msg": "ok",
"data": {
"upload_id": "6a1e…",
"upload_url": "https://minio:9000/filecodebox/share/data/2025/06/01/6a1e…/backup.zip?X-Amz-…",
"mode": "direct",
"expires_in": 900,
"file_path": "share/data/2025/06/01/6a1e…"
}
}
```
**local/WebDAVproxy)成功响应**200):
```json
{
"code": 200, "msg": "ok",
"data": {
"upload_id": "6a1e…",
"upload_url": "/presign/upload/proxy/6a1e…",
"proxy_upload_url": "/presign/upload/proxy/6a1e…",
"mode": "proxy",
"expires_in": 900,
"file_path": "share/data/2025/06/01/6a1e…"
}
}
```
**错误响应**
```json
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
```
> 大小上限为动态策略(v2 需求 ④⑩):管理端改 `max_file_size`0=回落 `uploadSize`)后
> 立即按新上限校验 init 声明的 `file_size`。
```json
{ "code": 403, "msg": "不允许上传该类型文件" }
```
```json
{ "code": 507, "msg": "存储空间已达到管理员设置的容量上限" }
```
## 直传确认:POST /presign/upload/confirm/{uploadID}
`direct` 模式专用:客户端向 S3 `PUT` 完成后调用。服务端会核对实际对象(多引擎一致):
- **大小核对**:实际大小与声明差 >1KB → 400「文件大小与声明不符」;超过策略上限(`max_file_size`)→ **删除对象、释放容量预留**并 403;
- **内容校验**:取对象前 64 字节做 magic bytes 白名单校验(失败删除对象并报错);
- 全部通过后创建分享记录。
```bash
# 1) 直传 S3(注意:不要带 Authorization 头,预签名 URL 自带鉴权)
curl -X PUT "<data.upload_url>" --upload-file ./backup.zip
# 2) 确认
curl -s -X POST http://localhost:8466/presign/upload/confirm/6a1e…
```
**成功响应**200):
```json
{ "code": 200, "msg": "ok", "data": { "code": "B4N8Q", "name": "backup.zip" } }
```
**错误响应**
```json
{ "code": 404, "msg": "文件未上传或上传失败" }
```
```json
{ "code": 400, "msg": "文件大小与声明不符" }
```
```json
{ "code": 403, "msg": "文件大小超过限制" }
```
```json
{ "code": 400, "msg": "此会话不支持direct模式" }
```
## 代理上传:PUT /presign/upload/proxy/{uploadID}
`proxy` 模式专用:multipart 上传到服务端,服务端流式转存到存储引擎后立即创建分享记录。
**multipart 字段**`file`(必需)。
**curl 示例**
```bash
curl -s -X PUT http://localhost:8466/presign/upload/proxy/6a1e… -F 'file=@./backup.zip'
```
**成功响应**200):
```json
{ "code": 200, "msg": "ok", "data": { "code": "B4N8Q", "name": "backup.zip" } }
```
**错误响应**
```json
{ "code": 400, "msg": "缺少上传文件 file 字段" }
```
```json
{ "code": 400, "msg": "文件大小与声明不符" }
```
```json
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
```
> 文件实际大小须与 init 声明的 `file_size` 一致(±1KB 容差);成功后会话即删除,不可重复使用。
## 查询会话:GET /presign/upload/status/{uploadID}
```bash
curl -s http://localhost:8466/presign/upload/status/6a1e…
```
**成功响应**200):
```json
{
"code": 200, "msg": "ok",
"data": {
"upload_id": "6a1e…",
"file_name": "backup.zip",
"file_size": 20971520,
"mode": "proxy",
"created_at": "2025-06-01T12:00:00+08:00",
"expires_at": "2025-06-01T12:15:00+08:00",
"is_expired": false
}
}
```
## 取消会话:DELETE /presign/upload/{uploadID}
删除会话并释放容量预留;`direct` 模式会尽力清理已直传到 S3 的对象。
```bash
curl -s -X DELETE http://localhost:8466/presign/upload/6a1e…
```
```json
{ "code": 200, "msg": "ok", "data": { "message": "上传会话已取消" } }
```
```json
{ "code": 404, "msg": "上传会话不存在" }
```
```json
{ "code": 404, "msg": "上传会话已过期" }
```
+434
View File
@@ -0,0 +1,434 @@
# 管理后台 API
管理端接口:除 `POST /admin/login` 与初始化向导 `/setup` 外,一律要求
`Authorization: Bearer <token>`(见《认证与限流》),无效令牌 401。
## 初始化向导:GET /setup
未初始化时返回 HTML 配置页(站点名称、管理员密码、上传/限流/保存策略);
已初始化时 `303` 重定向到 `/`
```bash
curl -i http://localhost:8466/setup
```
## 初始化提交:POST /setup
表单(浏览器向导)或 JSON 均可;成功后写入库配置 KV 并生成密码哈希与 `jwt_secret`
表单提交返回成功 HTML 页;JSON 提交返回 JSON。
**主要字段**
| 字段 | 必需 | 默认 | 说明 |
|---|---|---|---|
| `admin_password` | ✅ | - | 管理员密码(≥8 位) |
| `confirm_password` | ✅ | - | 确认密码(须一致) |
| `site_name` | ❌ | 文件快传 | 站点名称 |
| `upload_size_value` / `upload_size_unit` | ❌ | 10 / MB | 单文件大小限制(单位 KB/MB/GB) |
| `save_time_value` / `save_time_unit` | ❌ | 0 / day | 最长保存秒数(0=不限) |
| `expireStyle` | ❌ | day,hour,minute,forever,count | 过期方式(可多值/逗号分隔) |
| `code_generate_type` | ❌ | secret | 取件码类型 `number`/`secret` |
| `errorCount` / `errorMinute` | ❌ | 10 / 1 | 取件错误限流 |
| `loginCount` / `loginMinute` | ❌ | 5 / 15 | 登录失败限流 |
| `uploadCount` / `uploadMinute` | ❌ | 10 / 1 | 上传限流 |
| `allowed_file_types` | ❌ | `*` | 逗号分隔白名单 |
| `openUpload` / `enableChunk` | ❌ | 1 / 0 | 游客上传 / 分片开关(`1`/`true`/`on`/`yes` |
```bash
curl -s -X POST http://localhost:8466/setup \
-H 'Content-Type: application/json' \
-d '{"admin_password":"admin12345","confirm_password":"admin12345","site_name":"我的文件柜","upload_size_value":10,"upload_size_unit":"MB"}'
```
```json
{ "code": 200, "msg": "ok", "data": { "ok": true, "admin": "/#/admin" } }
```
**错误响应**(400,HTML 表单时内嵌错误提示):
```json
{ "code": 400, "msg": "管理员密码至少 8 位" }
```
## 登录:POST /admin/login
```bash
curl -s -X POST http://localhost:8466/admin/login \
-H 'Content-Type: application/json' \
-d '{"password":"admin12345"}'
```
**成功响应**200):
```json
{
"code": 200, "msg": "ok",
"data": {
"id": "admin", "username": "admin",
"token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_at": 1750000000,
"expires_in": 604800
}
}
```
**错误响应**
```json
{ "code": 401, "msg": "密码错误" }
```
```json
{ "code": 423, "msg": "请求次数过多,请稍后再试" }
```
## 校验会话:GET /admin/verify
```bash
curl -s http://localhost:8466/admin/verify -H "Authorization: Bearer $TOKEN"
```
```json
{ "code": 200, "msg": "ok", "data": { "id": "admin", "username": "admin", "token": "eyJ…", "token_type": "Bearer", "expires_at": 1750000000 } }
```
## 登出:POST /admin/logout
无状态 JWT,服务端仅返回确认(客户端应丢弃令牌):
```json
{ "code": 200, "msg": "ok", "data": { "ok": true } }
```
## 仪表盘:GET /admin/dashboard
```bash
curl -s http://localhost:8466/admin/dashboard -H "Authorization: Bearer $TOKEN"
```
```json
{
"code": 200, "msg": "ok",
"data": {
"totalFiles": 42,
"storageUsed": "123456789",
"sysUptime": 1750000000000,
"yesterdayCount": 5, "yesterdaySize": "1048576",
"todayCount": 12, "todaySize": "5242880",
"activeCount": 40, "expiredCount": 2,
"textCount": 10, "fileCount": 32, "chunkedCount": 3,
"usedCount": 156,
"storageBackend": "local",
"uploadSizeLimit": 10485760,
"openUpload": 1, "enableChunk": 1,
"maxSaveSeconds": 0,
"topSuffixes": [ { "suffix": ".pdf", "count": 12 }, { "suffix": "Text", "count": 10 } ],
"recentFiles": [ { "id": 42, "code": "K3P9W", "name": "report.pdf", "size": 1048576, "created_at": "2025-06-01T12:00:00+08:00", "expired_at": "2025-06-08T12:00:00+08:00", "is_expired": false, "expired_count": -1, "used_count": 3, "is_text": false, "is_chunked": false, "is_permanent": false, "has_download_limit": false, "remaining_downloads": null, "file_hash": null, "prefix": "report", "suffix": ".pdf", "text": false, "createdAt": "2025-06-01T12:00:00+08:00", "expiredAt": "2025-06-08T12:00:00+08:00", "isExpired": false, "expiredCount": -1, "usedCount": 3, "isText": false, "isChunked": false, "isPermanent": false, "hasDownloadLimit": false, "remainingDownloads": null, "fileHash": null } ],
"recentActivities": []
}
}
```
> `storageUsed`/`todaySize`/`yesterdaySize` 为字符串字节数;`sysUptime` 为服务启动时刻的 Unix 毫秒。
## 文件列表:GET /admin/file/list
**参数**
| 参数 | 默认 | 说明 |
|---|---|---|
| `page` / `size` | 1 / 10 | 分页(size 1~100 |
| `keyword` | - | 模糊匹配取件码/文件名/哈希/文本内容 |
| `status` | - | `active` / `expired` |
| `type` | - | `text` / `file` / `chunked` |
| `sortBy` | `created_at` | `created_at`/`expired_at`/`name`/`size`/`used_count`/`code` |
| `sortOrder` | `desc` | `asc` / `desc` |
```bash
curl -s "http://localhost:8466/admin/file/list?page=1&size=10&status=active&sortBy=size&sortOrder=desc" \
-H "Authorization: Bearer $TOKEN"
```
```json
{
"code": 200, "msg": "ok",
"data": {
"page": 1, "size": 10, "total": 40,
"summary": { "totalFiles": 42, "activeCount": 40, "expiredCount": 2, "textCount": 10, "fileCount": 32, "chunkedCount": 3, "storageUsed": 123456789, "usedCount": 156 },
"data": [
{ "id": 42, "code": "K3P9W", "name": "report.pdf", "prefix": "report", "suffix": ".pdf", "size": 1048576, "is_text": false, "is_chunked": false, "is_expired": false, "expired_at": "2025-06-08T12:00:00+08:00", "expired_count": -1, "used_count": 3, "created_at": "2025-06-01T12:00:00+08:00", "has_download_limit": false, "is_permanent": false, "remaining_downloads": null, "file_hash": null, "text": false, "isText": false, "isChunked": false, "isExpired": false, "expiredAt": "2025-06-08T12:00:00+08:00", "expiredCount": -1, "usedCount": 3, "createdAt": "2025-06-01T12:00:00+08:00", "hasDownloadLimit": false, "isPermanent": false, "remainingDownloads": null, "fileHash": null }
]
}
}
```
## 文件详情:GET /admin/file/detail
`GET ?id=42``POST {"id":42}`。返回列表条目字段;文本分享额外含 `content`(全文)。
```json
{ "code": 200, "msg": "ok", "data": { "id": 42, "code": "K3P9W", "name": "report.pdf", "size": 1048576, "is_text": false, "expired_at": "2025-06-08T12:00:00+08:00", "expired_count": -1, "used_count": 3, "created_at": "2025-06-01T12:00:00+08:00", "file_hash": null } }
```
```json
{ "code": 404, "msg": "文件不存在" }
```
## 更新文件:PATCH /admin/file/update
更新取件码/文件名(前后缀)/过期策略。**PATCH 为主名,POST 为兼容别名**。
**请求体**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 必需 |
| `code` | string | 新取件码(冲突 400「code已存在」) |
| `prefix` / `suffix` | string | 文件名前后缀 |
| `expired_at` | string | 过期时间(ISO 8601,如 `2025-07-01T00:00:00+08:00` |
| `expired_count` | int | 取件次数上限(`-1` 按时间/永久) |
```bash
curl -s -X PATCH http://localhost:8466/admin/file/update \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"id":42,"expired_at":"2025-07-01T00:00:00+08:00","expired_count":10}'
```
```json
{ "code": 200, "msg": "ok", "data": "更新成功" }
```
```json
{ "code": 400, "msg": "code已存在" }
```
## 删除文件:DELETE /admin/file/delete
删除分享记录并连带删除存储文件(文本分享无存储文件)。`DELETE``POST`,请求体 `{"id":42}`
```bash
curl -s -X DELETE http://localhost:8466/admin/file/delete \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"id":42}'
```
```json
{ "code": 200, "msg": "ok", "data": null }
```
```json
{ "code": 400, "msg": "请选择要删除的文件" }
```
## 批量删除:POST /admin/file/batch-delete
`POST``DELETE`,请求体 `{"ids":[41,42,43]}`。返回逐条统计:
```json
{
"code": 200, "msg": "ok",
"data": {
"requestedCount": 3, "deletedCount": 2, "missingCount": 1, "failedCount": 0,
"deleted": [41, 42], "missing": [43], "failed": [],
"requested_count": 3, "deleted_count": 2, "missing_count": 1, "failed_count": 0
}
}
```
## 批量更新:PATCH /admin/file/batch-update
`PATCH``POST`。请求体:`ids[]` 必需;`expired_at`ISO 8601/`expired_count` 二选一;
`clearExpiredAt: true`(或 `clear_expired_at`= 清空过期时间并置 `expired_count=-1`(永久)。
```bash
curl -s -X PATCH http://localhost:8466/admin/file/batch-update \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"ids":[41,42],"clearExpiredAt":true}'
```
```json
{
"code": 200, "msg": "ok",
"data": { "requestedCount": 2, "updatedCount": 2, "missingCount": 0, "failedCount": 0, "updated": 2, "missing": [], "failed": [], "requested_count": 2, "updated_count": 2, "missing_count": 0, "failed_count": 0 }
}
```
## 过期策略动作:PATCH /admin/file/policy-action
对单个文件执行快捷策略。`PATCH``POST`。批量版为 `/admin/file/batch-policy-action``{"ids":[…]}`),响应结构同批量更新。
**请求体**
| 字段 | 说明 |
|---|---|
| `id` | 文件 ID |
| `action` | `extend_24h` / `extend_7d` / `make_permanent` / `reset_download_limit` |
| `downloadLimit` | 仅 `reset_download_limit` 用:新取件次数(默认 5,须 >0) |
```bash
curl -s -X PATCH http://localhost:8466/admin/file/policy-action \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"id":42,"action":"reset_download_limit","downloadLimit":3}'
```
```json
{ "code": 200, "msg": "ok", "data": { "id": 42, "action": "reset_download_limit" } }
```
```json
{ "code": 400, "msg": "不支持的策略动作" }
```
> `extend_24h`/`extend_7d` 在当前过期时间(未过期时)基础上顺延;`make_permanent` 清空过期时间并置次数 -1。
## 管理员下载:GET /admin/file/download?id=
下载原文件(**不消耗取件次数**);文件返回二进制流(支持 Range),文本分享返回 JSON(`data` 为文本内容)。
```bash
curl -s -OJ "http://localhost:8466/admin/file/download?id=42" -H "Authorization: Bearer $TOKEN"
```
## 文本预览:GET /admin/file/preview
仅文本分享可用(文件分享返回 400)。`maxChars` 截断长度(默认 40001~20000)。
```bash
curl -s "http://localhost:8466/admin/file/preview?id=41&maxChars=100" -H "Authorization: Bearer $TOKEN"
```
```json
{
"code": 200, "msg": "ok",
"data": {
"id": 41, "code": "8XQ2M", "name": "Text.txt", "type": "text",
"content": "你好,文件快传",
"length": 24, "previewLength": 24, "truncated": false,
"maxChars": 100, "max_chars": 100,
"created_at": "2025-06-01T12:00:00+08:00", "createdAt": "2025-06-01T12:00:00+08:00"
}
}
```
```json
{ "code": 400, "msg": "仅文本分享支持预览" }
```
## 读取配置:GET /admin/config/get
返回运行时配置 KV(含默认值与管理端修改)。`admin_token` 恒返回空串(屏蔽);
`jwt_secret` 不下发;存储引擎为进程级单例,`_engine_hint` 提示引擎配置修改需重启。
v2 新增键(需求 ①②③④⑩)一并返回:`background_url``footer_text``footer_beian`
`notify_enabled``max_save_count``max_file_size` 等。
```json
{
"code": 200, "msg": "ok",
"data": {
"site_name": "文件快传",
"name": "文件快传",
"description": "开箱即用的文件快传系统",
"page_explain": "…", "keywords": "…",
"notify_title": "系统通知", "notify_content": "…", "notify_enabled": 1,
"logo_url": "",
"favicon_url": "",
"background_url": "", "footer_text": "", "footer_beian": "",
"openUpload": 1, "uploadSize": 10485760,
"max_file_size": 0, "max_save_count": 0,
"allowed_file_types": ["*"], "expireStyle": ["day","hour","minute","forever","count"],
"code_generate_type": "secret", "enableChunk": 1,
"uploadMinute": 1, "uploadCount": 10,
"errorMinute": 1, "errorCount": 10,
"loginCount": 5, "loginMinute": 15,
"max_save_seconds": 0, "storageLimit": 0,
"opacity": 0.9, "background": "", "showAdminAddr": 0, "robotsText": "User-agent: *\nDisallow: /",
"adminSessionExpire": 604800,
"storage_path": "", "local_storage_path": "/app/data",
"file_storage": "local",
"admin_token": "",
"_engine_hint": { "storage_backend": "local", "note": "存储引擎为进程级单例,修改存储引擎相关配置后需重启服务生效" }
}
}
```
## 存储引擎热切换:POST /admin/storage/switchv3
运行时切换存储引擎,**无需重启**:
```bash
curl -s -X POST http://localhost:8466/admin/storage/switch \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"engine":"s3"}'
# 成功:{"code":200,"msg":"ok","data":{"ok":true,"engine":"s3"}}
# 失败:503 {"code":503,"msg":"存储引擎切换失败,已保持原引擎: …"}
```
- `engine` 仅接受 `local|s3|webdav`400 中文错误)。
- 切换流程:用最新 KV 参数构建新引擎 → 健康检查 → 通过才替换当前引擎并持久化 `storage_engine`
- 失败(构建/健康检查不过)返回 503,**原引擎与 KV 均保持不变**。
- 切到当前引擎为幂等操作(直接返回 200)。
- 旧文件按归属引擎(`file_codes.engine`)取回,切换后旧引擎文件仍可下载。
## 更新配置:PATCH /admin/config/update
部分更新(JSON 对象,未提供的键不变;表单亦可)。**PATCH 为主名,POST 为兼容别名**。
- 仅接受管理端可见键(见上响应键集合),未知键忽略。
- 数值型键自动转型:`openUpload``enableChunk``uploadSize``storageLimit`、限流四组、`max_save_seconds``adminSessionExpire``showAdminAddr`v2 新增 `max_save_count``max_file_size``notify_enabled``opacity` 为浮点。
- **v3.1**`site_domain`(站点对外域名)可经本端点设置,非法格式 400(仅 http/https、主机+端口、不带路径)。
- **v3 引擎键**`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,中文错误信息):
- 整型边界:`max_file_size` ≤ 10GiB10737418240)、`max_save_count` ≤ 100000、`max_save_seconds` ≤ 31536000365 天)、`notify_enabled` ∈ {0,1}、`uploadCount` 1~10000、`uploadMinute` 1~1440 等;
- 字符串长度:`background_url` ≤ 2048、`footer_text` ≤ 2000、`footer_beian` ≤ 128、`notify_title` ≤ 128、`notify_content` ≤ 2000 字符;
- 列表键 `expireStyle` / `allowed_file_types`:须为字符串数组(或逗号分隔串)且至少保留一项;
- 错误示例:`{"code":400,"msg":"max_file_size 必须是整数"}``{"code":400,"msg":"max_file_size 不能大于 10737418240"}``{"code":400,"msg":"footer_beian 长度不能超过 128 字符"}``{"code":400,"msg":"notify_enabled 不能大于 1"}`
- `background_url` 协议白名单(需求 ①,防 `javascript:` 注入):仅 `http(s)://``data:image/*` 与站内相对路径(`/`开头);空串=清除背景。非法值 400:
`{"code":400,"msg":"background_url 仅支持 http(s) 地址、data:image 图片或站内相对路径"}`
- `admin_token`:明文密码自动哈希,**并轮换 `jwt_secret`(全部管理员令牌立即失效)**;空串忽略;已是哈希格式则原样保存。
- `adminSessionExpire` 须为 1~365 的整天秒数(86400 的整数倍),否则 400。
- `storageLimit` 不能小于 0。
- 修改限流/策略配置**立即生效**(无需重启:限流规则运行时同步,策略由上传链路每次实时读取);引擎相关(`file_storage`/`s3_*`/`webdav_*`/`storage_path`/`local_storage_path`)需重启。
```bash
curl -s -X PATCH http://localhost:8466/admin/config/update \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"site_name":"我的文件柜","uploadSize":52428800,"openUpload":1,"footer_beian":"京ICP备20240001号","max_file_size":10485760,"max_save_count":5}'
```
```json
{ "code": 200, "msg": "ok", "data": { "ok": true } }
```
```json
{ "code": 400, "msg": "adminSessionExpire 必须是 1 到 365 个整天" }
```
```json
{ "code": 400, "msg": "background_url 仅支持 http(s) 地址、data:image 图片或站内相对路径" }
```
## 修改管理员密码:PATCH /admin/settings/password
`PATCH``POST`。新密码 ≥8 位;成功后哈希保存并**轮换 `jwt_secret`,所有旧令牌失效(401)**,需重新登录。
```bash
curl -s -X PATCH http://localhost:8466/admin/settings/password \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"old_password":"admin12345","new_password":"new-pass-6789"}'
```
```json
{ "code": 200, "msg": "ok", "data": { "ok": true } }
```
```json
{ "code": 400, "msg": "新密码长度至少 8 位" }
```
```json
{ "code": 401, "msg": "旧密码错误" }
```
+121
View File
@@ -0,0 +1,121 @@
# 审计日志查询
所有上传/下载请求由审计中间件自动落库(需求 ③),管理端分页查询。
认证:`Authorization: Bearer <token>`
## 审计记录内容
每条审计日志覆盖以下维度(需求 ③):
| 维度 | 字段 | 说明 |
|---|---|---|
| 操作时间 | `created_at` | RFC 3339 |
| 客户端 | `ip` | 可信代理场景解析 XFF 后的真实 IP |
| 终端信息 | `user_agent` | 原始 UA |
| 设备解析 | `device_os` / `device_browser` / `device_type` | 由 UA 解析(如 Windows/Chrome/desktop |
| 动作 | `action` | `upload`(上传类) / `download`(取件/下载类) / `admin`(管理端敏感操作,v2.5.6 新增) |
| 结果 | `result` | `success` / `denied`(拒绝:401/403/423/428/ `failed`(失败:其余 4xx/5xx 或业务报错) |
| 字节数 | `size_bytes` | 文件总大小;`transferred_bytes` 实际传输(**Range 下载只计实际区间字节**;下载由中间件自动统计,上传由各 handler 填充) |
| 耗时 | `duration_ms` | 毫秒 |
| 角色 | `actor` | `admin`(有效管理员令牌)/ `guest` |
| 业务 | `file_code` / `file_name` | 取件码 / 文件名(分片上传时 `file_code``upload_id` |
| 错误 | `error_msg` | 失败/拒绝原因 |
**命中审计的端点**:上传类 `POST /share/text``POST /share/file``/chunk/upload*``/presign*`POST/PUT);
下载类 `GET /share/download``GET /share/select``GET /share/metadata`
管理类(`action=admin``POST /admin/login``POST /admin/logout``PATCH|POST /admin/config/update`
`PATCH|POST /admin/settings/password``POST /admin/storage/switch``PATCH|DELETE /admin/file/update|delete|batch-delete|batch-update|policy-action|batch-policy-action`
管理类动作未显式填结果时按 HTTP 状态兜底落库(401/403/423 → `denied`5xx → `failed`,其余 → `success`)。
失败与被拒绝的请求同样落库。
## 查询接口:GET /admin/audit/list
**参数**
| 参数 | 默认 | 说明 |
|---|---|---|
| `page` | 1 | 页码(≥1 |
| `size` | 20 | 每页条数(1~200;兼容 `pageSize` |
| `action` | - | `upload` / `download` / `admin` |
| `result` | - | `success` / `denied` / `failed` |
| `ip` | - | 按客户端 IP 过滤 |
| `start_time` / `end_time` | - | 时间范围,ISO 8601(如 `2025-06-01T00:00:00+08:00`;也接受 `2006-01-02 15:04:05` / 日期) |
```bash
curl -s "http://localhost:8466/admin/audit/list?page=1&size=20&action=download&result=success&start_time=2025-06-01T00:00:00%2B08:00" \
-H "Authorization: Bearer $TOKEN"
```
**成功响应**200):
```json
{
"code": 200, "msg": "ok",
"data": {
"data": [
{
"id": 318,
"action": "download",
"file_code": "K3P9W",
"file_name": "report.pdf",
"size_bytes": 1048576,
"transferred_bytes": 524288,
"ip": "203.0.113.7",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
"device_os": "Windows",
"device_browser": "Chrome",
"device_type": "desktop",
"actor": "guest",
"result": "success",
"error_msg": "",
"duration_ms": 128,
"created_at": "2025-06-01T12:03:45+08:00",
"fileCode": "K3P9W",
"fileName": "report.pdf",
"sizeBytes": 1048576,
"transferredBytes": 524288,
"userAgent": "Mozilla/5.0 …",
"deviceOs": "Windows",
"deviceBrowser": "Chrome",
"deviceType": "desktop",
"errorMsg": "",
"durationMs": 128,
"createdAt": "2025-06-01T12:03:45+08:00"
}
],
"total": 1180,
"page": 1,
"size": 20
}
}
```
> 行字段以 snake_case 为准;camelCase 为兼容双份输出(文档不再重复列出)。
**错误响应**
```json
{ "code": 400, "msg": "start_time 时间格式错误" }
```
```json
{ "code": 401, "msg": "令牌无效或已过期" }
```
## 别名:GET /admin/audit/logs
`/admin/audit/list` 完全相同(同一 handler 的兼容别名),参数与响应一致。
## 典型查询
```bash
# 最近的下载行为
curl -s "http://localhost:8466/admin/audit/list?action=download&size=50" -H "Authorization: Bearer $TOKEN"
# 某 IP 的全部被拒请求(限流/鉴权失败)
curl -s "http://localhost:8466/admin/audit/list?ip=203.0.113.7&result=denied" -H "Authorization: Bearer $TOKEN"
# 今天 0 点以来的上传失败
curl -s "http://localhost:8466/admin/audit/list?action=upload&result=failed&start_time=2025-06-01T00:00:00%2B08:00" \
-H "Authorization: Bearer $TOKEN"
```
+119
View File
@@ -0,0 +1,119 @@
# 存储引擎配置
存储引擎只支持三种:**本地磁盘 / S3 / WebDAV**,由进程级环境变量 `FCB_STORAGE_ENGINE` 选择。
引擎与引擎相关配置在启动时一次性读取(`storage.SetEngineOptions``NewEngine`),
**运行时修改引擎 KV 需重启服务**(管理端 `GET /admin/config/get``_engine_hint` 亦有提示)。
## 引擎选择
```bash
FCB_STORAGE_ENGINE=local # 启动默认(KV storage_engine 为空时生效)
FCB_STORAGE_ENGINE=s3
FCB_STORAGE_ENGINE=webdav
```
非法值直接启动失败:`FCB_STORAGE_ENGINE 无效值 "xxx",仅支持 local|s3|webdav`
**v3 运行时热切换**:管理端 `POST /admin/storage/switch`(或后台设置页「存储引擎」卡)可在不重启的情况下切换引擎——
先构建新引擎并健康检查,通过才生效;失败 503 保持原引擎。当前引擎持久化在 settings KV `storage_engine`(空=回落启动值)。
各引擎参数(存储目录/服务地址/存储桶/密钥)同样在后台设置页运行时可改;保存后对应引擎实例缓存失效,下次切换/构建生效。
## 文件归属引擎(v3
每条分享记录(`file_codes.engine`)与上传会话(`upload_chunks.engine` / `presign_upload_sessions.engine`
在创建时戳记当时的引擎名。下载、分片合并、删除按**归属引擎**操作——切换引擎后,旧引擎里的文件仍可正常下载与删除
(空戳为历史数据,回落当前引擎)。
## 按日期目录存储
文件落盘路径:`[storage_path/]share/data/YYYY/MM/DD/<uuid>/<文件名>`(如 `share/data/2026/09/04/…`)。
按日嵌套目录自然排序、无日月歧义、避免单日海量文件挤在单目录;三种引擎一致适用;历史路径记录在
`file_codes.file_path`,不受路径规则调整影响。
## 配置键与环境变量
引擎相关配置键(DB settings KV)可由环境变量种子注入(优先级:默认 < 环境变量 < DB KV):
| KV 键 | 环境变量 | 引擎 | 说明 |
|---|---|---|---|
| `local_storage_path` | `FCB_LOCAL_STORAGE_PATH` | local | 本地存储根目录(容器内默认 `/app/data` |
| `storage_path` | `FCB_STORAGE_PATH` | 全部 | 存储相对路径前缀(空 = `share/data/…` |
| `s3_access_key_id` | `FCB_S3_ACCESS_KEY_ID` | s3 | 访问密钥 |
| `s3_secret_access_key` | `FCB_S3_SECRET_ACCESS_KEY` | s3 | 私有密钥 |
| `aws_session_token` | `FCB_AWS_SESSION_TOKEN` | s3 | 可选临时会话令牌 |
| `s3_bucket_name` | `FCB_S3_BUCKET_NAME` | s3 | 桶名 |
| `s3_endpoint_url` | `FCB_S3_ENDPOINT_URL` | s3 | S3 兼容端点(MinIO/R2 等;AWS 原生可空) |
| `s3_region_name` | `FCB_S3_REGION_NAME` | s3 | 区域(默认 `auto` |
| `s3_addressing_style` | `FCB_S3_ADDRESSING_STYLE` | s3 | `auto`/`path`/`virtual` |
| `webdav_url` | `FCB_WEBDAV_URL` | webdav | WebDAV 服务地址(如 `http://webdav:5000` |
| `webdav_username` | `FCB_WEBDAV_USERNAME` | webdav | 用户名 |
| `webdav_password` | `FCB_WEBDAV_PASSWORD` | webdav | 密码 |
| `webdav_root_path` | `FCB_WEBDAV_ROOT_PATH` | webdav | 根目录(默认 `filebox_storage`,不存在自动逐级创建) |
`FCB_STORAGE_ENGINE` 本身不落库(`GET /admin/config/get` 中的 `file_storage` 为 KV 记忆键,
进程实际引擎以 `FCB_STORAGE_ENGINE``_engine_hint.storage_backend` 为准)。
## 各引擎要点
### 本地引擎(local
- 原子写:临时文件 + fsync + rename,避免半写文件。
- 路径安全:`SanitizePath` + 符号链接逃逸双重防穿越。
- Range 下载基于 `SectionReader`;分片按索引有序合并 + SHA256 校验,合并后清理分片目录。
### S3 引擎(s3
- 原生 multipart 流式合并(失败自动 Abort),分片落临时文件保证精确 Content-Length 与可重放。
- 预签名 GET/PUT 直链(预签名直传唯一 `direct` 模式引擎)。
- SDK 内置 5xx 指数退避重试;`when_required` 校验模式兼容 MinIO/R2 与纯流式转发。
### WebDAV 引擎(webdav,重点优化)
- **连接复用**:池化 Transport,连接复用(实测 25 次请求仅 1 条 TCP 连接)。
- **认证**Basic + DigestRFC 2617 `qop=auth`MD5/SHA-256)自动协商,401 挑战驱动。
- **Range**`Range` 头透传 + `206` 解析,支持分块/断点下载。
- **重试**5xx/429/408 指数退避(封顶 2s ± 20% 抖动,尊重 `Retry-After`)。
- **流式**:下载经 `io.Pipe` 流式转发不落盘;上下文取消挂到响应体读完之后,防止提前断连。
- **目录**:按需逐级 `MKCOL` + 目录缓存,避免重复建目录。
- **超时**:可配置(`webdav_url` 同级暂无独立超时键,引擎默认值内置)。
## 健康检查
三引擎均实现 `HealthCheck`local 写探针、s3 `ListObjectsV2`、webdav `PROPFIND`(根目录不存在时自建)。
服务启动时预检失败仅告警不阻断;运行状态可经 `GET /api/v1/health``data.storage` 查看当前引擎名。
## 预签名直传支持矩阵
| 引擎 | `PresignPutURL` / `PresignGetURL` | init 返回 mode |
|---|---|---|
| s3 | ✅ | `direct` |
| local / webdav | ❌(`ErrNotSupported` | `proxy`(走服务端代理上传) |
引擎不支持的操作经统一映射返回 501:
```json
{ "code": 501, "msg": "当前存储引擎不支持该操作" }
```
## Docker Compose 冒烟编排
`deploy/docker-compose.yml` 提供可选 profile(详见 deploy/README.md):
```bash
docker compose --profile minio up -d --build # MinIO(含 mc 自动建桶)+ FCB_STORAGE_ENGINE=s3
docker compose --profile webdav up -d --build # dufs WebDAV 冒烟(admin/admin123+ FCB_STORAGE_ENGINE=webdav
docker compose --profile redis up -d --build # Redis 缓存增强(非引擎)
```
## 存储哨兵错误 → HTTP 状态
| 哨兵错误 | HTTP | 文案 |
|---|---|---|
| `ErrNotFound` | 404 | 文件不存在 |
| `ErrInvalidPath` | 400 | 非法文件路径 |
| `ErrUnavailable` | 503 | 存储服务不可用,请稍后再试 |
| `ErrNotSupported` | 501 | 当前存储引擎不支持该操作 |
| `ErrRangeNotSatisfiable` | 416 | 请求范围超出文件大小 |
| `ErrHashMismatch` | 400 | 分片哈希校验失败,请重新上传 |
容量超限(`storageLimit`,经容量预留判定)返回 507`存储空间已达到管理员设置的容量上限`
+190
View File
@@ -0,0 +1,190 @@
# 环境变量与配置项
配置分三层:**默认值 → `FCB_*` 环境变量 → 数据库 settings KV(管理端运行时修改)**。
v2 起进程必需的环境变量为空集:数据库默认 **SQLite**modernc.org/sqlite 纯 Go 驱动,零外部依赖,
DSN 缺省落 `./data/filecodebox.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_REDIS_ADDR` | ❌ | 空 | Redis 地址(如 `redis:6379`),也支持 `redis://[:password@]host:port[/db]` / `rediss://`(TLS)URL 形式;**空则缓存降级为进程内存实现**(缓存故障时自动降级为进程内限流计数) |
| `FCB_REDIS_DB` | ❌ | `0` | Redis 逻辑库号 0-15;URL 形式地址显式携带 `/N` 时以 URL 为准 |
| `FCB_ADMIN_PASSWORD` | ❌ | 空 | 设置后服务首次启动即自动初始化管理员(≥8 位,不足告警跳过),消除 `/setup` 被抢占窗口;初始化完成后建议移除 |
| `FCB_LISTEN` | ❌ | `:8466` | HTTP 监听地址 |
| `FCB_STORAGE_ENGINE` | ❌ | `local` | 存储引擎:`local` / `s3` / `webdav` |
| `FCB_TRUSTED_PROXIES` | ❌ | 空 | 可信代理 CIDR(逗号分隔),命中时从 `X-Forwarded-For` 解析真实客户端 IP |
- SQLite 连接参数(驱动自动注入):`busy_timeout=10s` + `WAL` 日志模式 + `foreign_keys=1`;连接池 8/4。
- Postgres 连接池沿用 v1 参数(32/8,1h 轮换);`FCB_DB_DRIVER=postgres` 且未设 `FCB_DB_DSN` 时**启动直接报错**。
引擎相关环境变量(种子注入 settings KV,见《存储引擎配置》):`FCB_LOCAL_STORAGE_PATH`
`FCB_STORAGE_PATH``FCB_S3_ACCESS_KEY_ID``FCB_S3_SECRET_ACCESS_KEY``FCB_AWS_SESSION_TOKEN`
`FCB_S3_BUCKET_NAME``FCB_S3_ENDPOINT_URL``FCB_S3_REGION_NAME``FCB_S3_ADDRESSING_STYLE`
`FCB_WEBDAV_URL``FCB_WEBDAV_USERNAME``FCB_WEBDAV_PASSWORD``FCB_WEBDAV_ROOT_PATH`
部署用编排变量(`deploy/.env.example`):`WEB_PORT`(默认 8466)、
`POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB`(默认 filecodebox,仅 `--profile postgres` 时使用)。
## 配置项(settings KV,默认值对齐参考实现)
> 键名/类型/默认值/边界以 `server/internal/config/schema.go` 的 `KVSchema()` 为单一事实来源
> schema 同步测试保证与 defaults() 逐键一致);v2 新增键统一 snake_case。
### 站点信息与展示(需求 ①②③)
| 键 | 类型/边界 | 默认 | 说明 |
|---|---|---|---|
| `site_name` / `name` | string | 文件快传 | 站点名称(`site_name` 优先) |
| `site_domain` | string,≤256 | 空 | **v3.1**:站点对外域名(`http(s)://host[:port]`,不带路径;裸主机自动补 `http://`)。配置后分享链接(结果卡/管理端复制)用该域名生成——内网部署也能把公网链接发出去;留空=用当前访问地址 |
| `description` | string | 开箱即用的文件快传系统 | 站点描述 |
| `page_explain` | string | (合规声明) | 页面说明文案 |
| `keywords` | string | 文件快传, 文件分享… | SEO 关键词 |
| `logo_url` | string | 空(前端回落本地打包 `/assets/logo-*.svg`,需求 ⑤) | 页面导航 Logo,管理端可设任意 URL |
| `favicon_url` | string | 空(前端回落本地打包 `/assets/favicon-*.png`,需求 ⑤) | favicon / 备用 Logo |
| `opacity` | float | 0.9 | 界面不透明度 |
| `background` | string | 空 | 背景图 URL(参考实现既有键,v1 兼容保留) |
| `background_url` | string,≤2048 字符 | 空 | **v2 需求 ①**:背景图 URL 或上传后地址(空=主题默认;取值时 legacy `background` 键兜底)。管理端保存时校验协议白名单:仅 `http(s)``data:image/*` 与站内相对路径(防 `javascript:` 注入,非法 400 |
| `footer_text` | string,≤2000 字符 | 空 | **v2 需求 ②**:页脚自定义内容(纯文本或受控 HTML 片段) |
| `footer_beian` | string,≤128 字符 | 空 | **v2 需求 ②**:备案号(如 `京ICP备2024xxxxxx号-1`),展示于页脚 |
| `notify_enabled` | int0/1 | 1 | **v2 需求 ③**:通知开关(1=前台右上角悬浮窗展示 / 0=关闭) |
| `notify_title` | string,≤128 字符 | 系统通知 | 通知标题 |
| `notify_content` | string,≤2000 字符 | 欢迎使用… | 通知正文(**服务端白名单净化**:仅保留纯文本与 `<a href>` 为 http(s)/站内相对/`#` 锚点的链接,其余标签与事件属性剥离,保存与读取双侧生效) |
| `showAdminAddr` | int(0/1 | 0 | 是否展示后台入口 |
| `robotsText` | string | `User-agent: *\nDisallow: /` | robots.txt 内容(由公开端点 `GET /robots.txt` 输出) |
### 保存策略(需求 ④,上传页动态读取并在范围内选择)
| 键 | 类型/边界 | 默认 | 说明 |
|---|---|---|---|
| `max_save_seconds` | int640~31536000 | 0 | 最长保存秒数上限(0=仅默认 7 天兜底;>0 时按时间过期超限 403「限制最长时间为 X,可换用其他方式」)。**v3**:管理界面以「小时/天」下拉单位编辑(≥1 天自动显示天),提交时前端换算为秒——canonical 单位保持秒,接口语义不变 |
| `max_save_count` | int0~100000 | 0 | **v2 新增**:单次分享最大可取(保存)次数上限(0=不限制;`expire_style=count``expire_value` 超上限时 403「限制次数最多为 N 次」) |
| `expireStyle` | []string | `["day","hour","minute","forever","count"]` | 允许的过期方式白名单(上传时不在白名单 400「过期时间类型错误」) |
### 存储策略(需求 ④⑩)
| 键 | 类型/边界 | 默认 | 说明 |
|---|---|---|---|
| `uploadSize` | int641024~10GiB | 1048576010MB) | 单文件大小上限(字节),参考实现语义;`max_file_size=0` 时作为生效上限 |
| `max_file_size` | int640~10GiB | 0 | **v2 新增**:存储策略-单文件上限(字节),0=回落 `uploadSize`;超出 403(文案 humanSize 自适应 B/KB/MB/GB)。**v3**:管理界面以「MB/GB」下拉单位编辑(≥1 GiB 自动显示 GB),提交时前端换算为字节 |
| `allowed_file_types` | []string | `["*"]` | 允许类型白名单(扩展名/MIME 通配,`*` 不限制;非白名单 403「不允许上传该类型文件」) |
| `storageLimit` | int64,≥0 | 0 | 站点总容量(字节),0=不限制(超限 507) |
| `openUpload` | int0/1) | 1 | 游客上传开关(0 时上传接口要求管理员令牌 403) |
| `enableChunk` | int0/1 | 0 | 启用分片上传 |
### 上传频率限制(需求 ④,既有键对齐参考 ip_limit["upload"]
| 键 | 类型/边界 | 默认 | 说明 |
|---|---|---|---|
| `uploadCount` / `uploadMinute` | int1~10000 / 1~1440 | 10 / 1 | 窗口内允许上传次数 / 窗口分钟(上传成功才计数,超限 423;管理端修改后运行时同步限流规则,立即生效) |
| `errorCount` / `errorMinute` | int | 10 / 1 | 取件错误(失败计数)+ metadata 每次计数 |
| `loginCount` / `loginMinute` | int | 5 / 15 | 登录失败计数 |
### 安全与会话
| 键 | 默认 | 说明 |
|---|---|---|
| `admin_token` | 空(未初始化) | 管理员密码哈希(`sha256$salt$hash`);GET 配置时屏蔽为空串 |
| `jwt_secret` | 空 | JWT 签名密钥(初始化/改密时自动生成轮换;不下发;`settings.SensitiveKeys` 双模式下一致屏蔽) |
| `adminSessionExpire` | 604800(7 天) | 管理员会话秒数(须 1~365 整天) |
### 存储引擎(v3 运行时可配 + 热切换)
**`storage_engine`**v3 新增键):string`local|s3|webdav`,默认空=回落启动值 `FCB_STORAGE_ENGINE`
运行时切换走 **`POST /admin/storage/switch`**(JWT 保护):构建新引擎 → 健康检查通过才生效;
失败返回 503「存储引擎切换失败,已保持原引擎: …」且不改 KV。成功后持久化 `storage_engine`,重启沿用。
`GET /api/v1/config` 公开下发 `storage_engine` 当前名(仅名称,任何引擎参数/凭据不下发)。
引擎参数键(管理端可改;保存后对应引擎实例缓存失效,下次切换/构建生效):
| 键 | 默认 |
|---|---|
| `file_storage` | `local` |
| `storage_path` | 空 |
| `local_storage_path` | 空(容器内由 `FCB_LOCAL_STORAGE_PATH=/app/data` 注入) |
| `s3_access_key_id` / `s3_secret_access_key` / `aws_session_token` | 空 |
| `s3_bucket_name` / `s3_endpoint_url` / `s3_hostname` | 空 |
| `s3_region_name` | `auto` |
| `s3_signature_version` | `s3v4` |
| `s3_addressing_style` | `auto` |
| `s3_proxy` | 0 |
| `webdav_url` / `webdav_username` / `webdav_password` | 空 |
| `webdav_root_path` | `filebox_storage` |
| `webdav_proxy` | 0 |
> 敏感键 `webdav_password` / `s3_secret_access_key` / `aws_session_token`v3 加入 `settings.SensitiveKeys`):
> 管理端 GET 返回掩码 `******`PATCH 时空串或 `******` 表示不修改。直接写库 settings KV 后重启同样生效。
## 策略动态生效机制(v2 需求 ④⑩)
上传页通过 `GET /api/v1/config``config` 字段读取**当前策略快照**并在范围内渲染选项;
上传链路(`/share/file``/chunk/*``/presign/*`**每次请求实时读取** settings KV 同一组值校验:
- 管理端改策略(`PATCH /admin/config/update`)→ 公开 config 即时反映 → 后续上传立即按新策略执行(含 403/400 拒绝与恢复放行)。
- 生效上限:`max_file_size > 0` 时为 `max_file_size`,否则回落 `uploadSize`
- 校验点覆盖:单文件(`/share/file`)、分片 init 按分片数上限、分片上传累计、分片 complete 累计、预签名 init 声明大小,五处口径一致(`api.UploadPolicy.CheckSize`)。
## 公共配置接口
前端启动时经 `GET /api/v1/config` 获取站点公开配置(无需认证;v2 扩展需求 ①②③④⑩):
```json
{
"code": 200, "msg": "ok",
"data": {
"config": {
"name": "文件快传",
"description": "开箱即用的文件快传系统",
"explain": "请勿上传或分享违法内容…",
"logo_url": "",
"favicon_url": "",
"background_url": "",
"footer_text": "自定义页脚内容",
"footer_beian": "京ICP备2024xxxxxx号-1",
"notify_enabled": 1,
"notify_title": "系统通知",
"notify_content": "欢迎使用文件快传…",
"uploadSize": 10485760,
"max_file_size": 10485760,
"maxFileSize": 10485760,
"allowedFileTypes": ["*"],
"expireStyle": ["day", "hour", "minute", "forever", "count"],
"max_save_seconds": 0,
"maxSaveSeconds": 0,
"max_save_count": 0,
"maxSaveCount": 0,
"uploadCount": 10,
"uploadMinute": 1,
"enableChunk": false,
"openUpload": true
},
"meta": {
"version": "2.5.6",
"features": { "chunkUpload": false, "guestUpload": true }
}
}
}
```
> 策略字段 snake_case 与 camelCase 双份下发(前端宽松解析);响应为白名单显式构造,
> 任何敏感键(`admin_token`/`jwt_secret`)均不会出现。
## 健康检查
```bash
curl -s http://localhost:8466/api/v1/health
```
```json
{
"code": 200, "msg": "ok",
"data": {
"status": "ok",
"version": "2.5.6",
"storage": "local",
"time": "2025-06-01T12:00:00+08:00"
}
}
```
+62
View File
@@ -0,0 +1,62 @@
# 错误码
## 响应结构
```json
{ "code": 404, "msg": "文件不存在" }
```
- `code` 与 HTTP 状态码一致;失败时无 `data` 字段。
- `msg` 为中文可读信息,可直接展示给用户。
## 业务状态码
| 状态码 | 语义 | 典型场景 |
|---|---|---|
| 200 | 成功 | 全部正常响应 |
| 400 | 参数/格式错误 | 缺字段、过期策略非法、时间格式错误、分片哈希不匹配、code 冲突、`chunk_size` 超 32MiB 上限、presign 实际大小与声明不符、请求体超过大小上限 |
| 401 | 未认证 | 管理端令牌缺失/无效;登录密码错误 |
| 403 | 拒绝 | 类型白名单拒绝、magic bytes 防伪、游客上传未开启、**分片上传未启用**enableChunk=0)、presign 直传对象超限(服务端删除对象并释放预留)、下载 `key` 鉴权失败、超过大小/时长限制 |
| 404 | 不存在/已过期 | 取件码不存在、文件已过期、上传会话不存在、`/api/*` 未命中路由 |
| 409 | 冲突 | 上传容量预留信息不一致 |
| 416 | Range 越界 | `Range: bytes=…` 超出文件大小 |
| 423 | 限流 | upload/error/login/metadata 任一规则超限 |
| 428 | 未初始化 | 系统未初始化时访问除 `/setup``/api/v1/health` 外的接口 |
| 500 | 服务器错误 | 数据库/内部异常 |
| 501 | 引擎不支持 | 引擎不支持预签名等操作(local/webdav 的 `PresignGetURL/PutURL` |
| 503 | 存储不可用 | 存储引擎连接失败/健康检查不通过时的操作 |
| 507 | 容量超限 | 达到 `storageLimit` 上限(含上传预留判定) |
## 存储哨兵错误映射
存储层哨兵错误统一映射(支持错误包装链判定):
| 哨兵错误 | HTTP | 响应 msg |
|---|---|---|
| `ErrNotFound` | 404 | 文件不存在 |
| `ErrInvalidPath` | 400 | 非法文件路径 |
| `ErrUnavailable` | 503 | 存储服务不可用,请稍后再试 |
| `ErrNotSupported` | 501 | 当前存储引擎不支持该操作 |
| `ErrRangeNotSatisfiable` | 416 | 请求范围超出文件大小 |
| `ErrHashMismatch` | 400 | 分片哈希校验失败,请重新上传 |
未识别的存储错误归入 500`存储操作失败: …`)。
## 错误结果的审计归类
错误响应同时写入审计日志(需求 ③):
- `denied`401 / 403 / 423 / 429 / 428(拒绝类)。
- `failed`:其余 4xx / 5xx 及业务显式报错。
## 常见排障
| 现象 | 原因与处理 |
|---|---|
| 全部接口 428 | 未初始化:访问 `GET /setup``POST /setup` 完成向导 |
| 上传 403「本站未开启游客上传」 | `openUpload=0`,携带管理员 Bearer 令牌或后台开启 |
| 上传 423 | 触发 upload 限流,等待窗口或调大 `uploadCount/uploadMinute` |
| 取件 404「文件已过期」 | 分享过期/次数耗尽;管理员可 `PATCH /admin/file/update` 调整 |
| 下载 403「下载鉴权失败」 | `key` 窗口令牌过期/伪造:重新 `POST /share/select` 获取新地址 |
| 预签名 init 返回 proxy | local/webdav 引擎不支持直链,按 proxy 流程走服务端代理上传 |
| 503 存储服务不可用 | 检查引擎配置与远端服务(S3/WebDAV)连通性;`GET /api/v1/health``storage` 字段确认引擎 |
+61
View File
@@ -0,0 +1,61 @@
# Logo 自定义
## 默认 Logo(内置,v2 需求 ⑤)
| 项 | 默认值 | 用途 |
|---|---|---|
| 页面导航 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` 默认下发空串,
前端 `displayLogoUrl`/`displayFaviconUrl` 判空后回落到打包的本地资源。
管理端仍可设置任意 URL 全站替换(三步如下)。
## 管理端自定义(三步)
1. **登录后台**`POST /admin/login` 获取 Bearer 令牌。
2. **保存配置**`PATCH /admin/config/update` 更新 `logo_url`(与可选 `favicon_url`),值为图片 URL 或经管理端上传后得到的地址。
3. **全站生效**:保存即写入 settings KV 并热更新,前端读取公共配置立即换新 Logo,无需重启。
curl 示例:
```bash
curl -s -X PATCH http://localhost:8466/admin/config/update \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"logo_url":"https://cdn.example.com/logo.svg","favicon_url":"https://cdn.example.com/favicon.png"}'
```
```json
{ "code": 200, "msg": "ok", "data": { "ok": true } }
```
> 也可以在管理界面「系统设置」页操作(上传图片或填写 URL),效果相同。
## 校验生效
```bash
curl -s http://localhost:8466/api/v1/config
```
```json
{
"code": 200, "msg": "ok",
"data": { "config": { "logo_url": "https://cdn.example.com/logo.svg", "favicon_url": "https://cdn.example.com/favicon.png" } }
}
```
## 恢复默认
`logo_url` / `favicon_url` 置回默认值(空串,前端回落本地打包资源)即可:
```bash
curl -s -X PATCH http://localhost:8466/admin/config/update \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"logo_url":"","favicon_url":""}'
```
## 相关行为
- 前端运行时优先读取配置值;空值回退前端打包的本地资源(`web/src/assets/brand/logo.svg` + `favicon.png`,经 `displayLogoUrl`/`displayFaviconUrl` 判空回落)。
- `site_name` 同样支持运行时自定义(`PATCH /admin/config/update``site_name` 键)。
- Logo/favicon 仅涉及展示层,修改不影响会话与令牌(不轮换 `jwt_secret`)。
+1920
View File
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
# FileCodeBox Go 重写版 · 安全审计报告
- 审计日期:2026-09-05
- 审计范围:`server/`Go 1.27.1 Gin+GORM 后端,56 个文件约 13,900 行)、`web/`Vue 3 前端)、`deploy/`Dockerfile / docker-compose
- 审计方式:人工代码审读(认证/会话、上传下载全链路、存储引擎、配置与注入面)+ 工具佐证(`go vet``govulncheck``npm audit`
- 结论速览:**未发现可直接导致 RCE、SQL 注入、路径穿越或认证绕过的高危问题**;发现 5 项中危问题(密码哈希强度、S3 直传校验缺口、请求体无上限 DoS、依赖漏洞、上传会话资源滥用)与若干低危/加固建议。
- **修复状态(2026-09-05 第二轮):M1M5、L1L10 及可行动 Info 项已全部修复**,逐项见各条目「✅ 修复」标记;验证:`gofmt`/`go vet`/`go test ./...` 全绿,`govulncheck` 0 命中,二进制端到端冒烟(初始化→登录→审计落库→限流锁定→XSS 净化→robots→分享取件)通过。前端 `markdown.ts` 净化器加固需重建前端产物(已重建 `web/dist``server/web/dist`)方可进入 go:embed 二进制。
---
## 一、中危(Medium
### M1 管理员密码哈希强度不足(单轮 SHA256+盐)✅ 已修复
- 位置:`server/internal/settings/password.go:15-21`
- 原状:`HashPassword` 生成 `sha256$salt$hash`,单轮 SHA256 + 16 字节随机盐。GPU 单卡对 SHA256 可达 10¹⁰ 次/秒,若数据库泄露(SQLite 文件/Postgres 备份),弱口令可被瞬间离线爆破。
- ✅ 修复:`HashPassword` 改为 **bcryptcost 12**,输出 `bcrypt$` + 原生 `$2a$12$…` 哈希串;`VerifyPassword` 兼容 bcrypt$/sha256$/明文三种历史格式实现平滑迁移;新增 `NeedsRehash`,管理员登录成功时自动将旧格式重哈希写回(`adminLogin` 内触发);密码长度 >72 字节按 bcrypt 语义截断(`bcryptBytes`);`GenerateJWTSecret` 的 rand 错误不再忽略(panic 显式失败)。测试:settings 包 + `TestAdminPasswordAutoUpgrade`
### M2 S3 预签名直传(direct 模式)绕过大小与类型校验 ✅ 已修复
- 位置:`server/internal/api/presign.go:59-171`init)、`presign.go:275-345`confirm)、`server/internal/storage/s3.go:538-554`PresignPutURL
- 原状:
- init 只校验**声明**的 `file_size`;预签名 PUT URL 未签入 Content-Length 约束,客户端实际可 PUT 任意大小对象;
- confirm 仅 `FileExists`,不 `Stat` 校验实际对象大小,分享记录 `Size` 直接取声明值;
- magic bytes / 类型白名单校验在 direct 模式完全不生效(内容不经过服务器)。
- 影响:开启游客上传 + S3 引擎的部署中,任意访客可绕过 `max_file_size``storageLimit`(配额按声明值记账),向桶内塞入任意大小/内容的对象(存储成本攻击、策略绕过)。
- ✅ 修复(多引擎一致,local/S3/WebDAV 全覆盖):
1. `Storage` 接口新增 `HeadMeta(ctx, savePath, headBytes)`S3 用 Range GET `bytes=0-(n-1)`WebDAV 用 Stat + Rangelocal 直接 Open
2. `presignConfirm` 现在:HeadMeta 取实际大小 → 超策略上限则 **DeleteFile + 释放配额 + 403**;实际大小与声明差 >1KB → **DeleteFile + 400**;前 64 字节补做 `validateFileMagic`
3. `presignInit` 拒绝 `file_size <= 0` 声明;
4. 测试:`TestPresignConfirmRejectsOversizeObject``TestPresignConfirmRejectsSizeMismatch`
### M3 请求体无全局大小上限,多处“先整读后校验”可被 DoS ✅ 已修复
- 位置:
- `server/internal/api/helpers.go:681-721``bindJSONOrForm` 对 text/plain 形态 `io.ReadAll` 全量进内存;JSON 绑定同样全量读入)
- `server/internal/api/chunk.go:314``io.ReadAll(LimitReader(f, ChunkSize+1))` 单分片全量进内存,`chunk_size` 上界=策略 `max_file_size`schema 允许至 10GiB
- `server/internal/api/share.go:100-128`(文本 222KB 限制在读完整个 body 之后才判定)
- 原状:全服务无 `http.MaxBytesReader`,也无上传前的 Content-Length 预检;multipart 大文件会先被完整解析(>32MB 落临时盘)后才被 `CheckSize` 拒绝。
- 影响:默认 `openUpload=1` 的部署下,未认证攻击者可用大 body 消耗内存/磁盘/带宽。
- ✅ 修复:
1. 新增 `middleware.BodyLimit(limitFn)``main.go` 全局装配(在 GuardNotInitialized 之后、Audit 之前):`/setup``/admin/*``/share/text|metadata|select` 一律 1MiB,其余端点 `maxFileSize(+2MiB 开销)`(前端单请求单分片,已核实安全);
2. `/share/text` 入口先查 `Content-Length > 441KB` 直接 403(读前预检);
3. 单分片 `chunk_size` 硬上限 32MiB(超出 400),不再跟随 10GiB 的文件策略;
4. 测试:`TestChunkSizeCap`
### M4 依赖漏洞(govulncheck 实际命中 3 个 + 8 个 imported 级)✅ 已修复
- 工具输出(`govulncheck ./...`):
- `golang.org/x/text v0.30.0`GO-2026-5970 非法输入死循环 DoS(经 gorm 归一化路径可达),修复于 **v0.39.0**
- `github.com/quic-go/quic-go v0.54.0`GO-2026-5676(修复 v0.59.1)、GO-2025-4233(修复 v0.57.0QPACK 扩张 DoS(实际未启用 HTTP/3 监听,实践影响低);
- `golang.org/x/net v0.45.0`8 个 imported 级漏洞(GO-2026-5030/5029/5028/5027/5026/5025/4918 等,含 HTTP/2 传输死循环),修复于 v0.53v0.55。
- ✅ 修复:`x/text v0.41.0``x/net v0.58.0``x/crypto v0.56.0`(转为直接依赖,供 bcrypt)、`quic-go v0.59.1``govulncheck ./...` 复扫 **0 个可达漏洞**(模块级仅剩 1 个未调用项)。前端 `npm audit` 仍报 2 个 moderate`vue-i18n → @intlify/core-base`,上游暂无修复版,保持关注升级)。
### M5 上传会话与容量预留可被滥用(init 不计数、无过期清理)✅ 已修复
- 位置:`server/internal/api/chunk.go:39-157`chunkInit 从不 `Limiter.Add`,上传限流仅在 complete/presign-init/shareFile 成功时计数)、`server/internal/api/helpers.go:380-436`(预留 TTLchunk 24h / presign 15min
- 原状:
- 游客可无限次 `POST /chunk/upload/init` 创建会话(每次写入 `upload_chunks` 行 + 24h 容量预留),无后台任务回收过期预留、未完成会话与孤儿分片对象(local `chunks/` 目录、S3 `*.part`);
- 若配置了 `storageLimit`,攻击者可用多次 init 把全部配额占用满 24 小时 → 全站上传 507(拒绝服务);默认 `storageLimit=0` 时则是磁盘/DB 垃圾持续累积。
- ✅ 修复:
1. `chunkInit` 在新会话保留成功后即 `Limiter.Add(c, LimitUpload)` 计数;
2. chunk 预留 TTL 24h → **2h**(续传刷新);
3. 新增 `internal/janitor` 后台清理循环(默认 10 分钟):过期 `storage_reservations`、超时(>24h 未完成)`upload_chunks` 会话(连带清理分片对象)、过期直传 presign 会话(连带删除残留对象);`main.go` 启动时随 ctx 拉起。
---
## 二、低危(Low
### L1 初始化向导(/setup)存在接管窗口 ✅ 已修复
- 位置:`server/internal/api/setup.go:47-63``middleware/audit.go:222-236`
- 原状:服务公开到公网后、管理员完成 /setup 前,任何人可抢先完成初始化并设置管理员密码(经典 setup race;双检查只防并发写坏,不防抢占)。
- ✅ 修复:新增 `autoInitIfNeeded``main.go`SystemStart 后执行)——设置 `FCB_ADMIN_PASSWORD`(≥8 位,否则告警跳过)即可在服务启动瞬间完成管理员初始化并生成 jwt_secret,消除 /setup 被抢占窗口;`deploy/.env.example` 与 README 安全清单已补充说明(初始化后建议移除该变量)。
### L2 下载令牌为非 HMAC 拼接哈希且非常量时间比较 ✅ 已修复
- 位置:`server/internal/api/helpers.go:249-253``sha256(code‖timeFactor‖"000"‖secret)`)、`share.go:458``key != GetSelectToken(...)`
- 原状:secret 后置拼接,长度扩展不适用、256 位密钥不可爆破,当前**不可实际利用**;但拼接串存在理论歧义(code 与时间窗数字边界重叠),且字符串比较非常量时间。
- ✅ 修复:`GetSelectToken` 改为 **HMAC-SHA256(secret, code‖timeFactor)**;新增 `VerifySelectToken``hmac.Equal` 常量时间比较(允许当前/上一两个时间窗,防临界失效);`shareDownload` 已切换到 `VerifySelectToken`
### L3 数字取件码空间过小,防撞库完全依赖单 IP 限流 ✅ 已修复
- 位置:`server/internal/api/helpers.go:114-125``validatePickupCode`4 位下限)
- 原状:`code_generate_type=number` 时仅 9 万空间(5 位数字),默认 `errorCount=10/分/IP` 下单 IP 需约 6 天扫完,分布式多 IP 可显著缩短;自定义码允许 4 位(36⁴≈168 万)。
- ✅ 修复:自定义提码最小长度 4 → **5 位**`pickupCodeMinLen=5`36⁵≈6000 万空间);测试 `v31_test.go``TestPickupCodeMinLen` 同步更新。
### L4 `enableChunk` 开关后端不强制 ✅ 已修复
- 位置:`server/internal/api/router.go:56-67`
- 原状:`/chunk/*` 路由不检查 `cfg.EnableChunk()`,关闭开关后接口仍可用(仅前端隐藏入口)。若该开关被当作安全策略,需在 handler 层强制(403)。
- ✅ 修复:新增 `requireChunkEnabled` 守卫(关闭时 403「分片上传未启用」),挂到 `chunkInit`(首检查)、`chunkUpload``chunkComplete` 三个端点;测试 `TestChunkToggleEnforced` 验证 0→403 / 1→200。
### L5 管理端安全事件未入审计日志 ✅ 已修复
- 位置:`server/internal/middleware/audit.go:55-78`DefaultClassifier 仅覆盖 upload/download
- 原状:管理员登录失败、配置修改、密码修改、引擎切换、文件删除等管理操作均不落审计。
- ✅ 修复:`DefaultClassifier` 扩展 `adminAuditActions`login/logout、config/update、settings/password、storage/switch、file/update/delete/batch-*、policy-action 等 POST/PATCH/DELETE 敏感操作 → `audit.ActionAdmin`);`Audit` 中间件跳过条件由「非 upload/download 即跳过」改为「分类未命中才跳过」,admin 动作同样建 `auditEntry` 并按 HTTP 状态兜底落库;新增 `audit_l5_test.go` 回归。端到端实测:登录失败 401 落 `admin/denied`、成功落 `admin/success`
### L6 CORS 对所有接口(含 /admin/*)放开 `*` ✅ 已修复
- 位置:`server/internal/middleware/cors.go:10`
- 原状:Bearer 模式下无 CSRF 风险,但一旦 token 泄露(见 L8),任意网站均可跨域携带 token 调用管理 API。
- ✅ 修复:`Cors` 重写——`/admin/*` 请求带 Origin 且既不同源也不在白名单(`site_domain` 配置注入)时**不回任何 CORS 头**(浏览器拦截跨域读取),预检直接 204;公开接口维持 `*`Bearer 认证,无 Cookie CSRF 面);无 Origin 的非浏览器请求不受影响。
### L7 管理端通知内容 `v-html` 直出(存储型 XSS 面)✅ 已修复
- 位置:`web/src/components/NotifyPop.vue:23``notify_content` 来自 `GET /api/v1/config`,管理端可设)
- 原状:设计上"允许 `<a>` 等受控 HTML",但服务端/前端均无净化。管理员账号被盗即可对全站访客注入脚本。
- ✅ 修复(双侧):
1. 服务端新增 `settings.SanitizeInlineHTML` 白名单净化器(`sanitize.go` + 18 个单测):仅保留文本与 `<a href="http(s)://|/|#">`(引号内 `>`、未闭合标签、`script/style/iframe/svg/math` 等危险标签连内容整体丢弃、事件属性不透传、`javascript:/data:` 拒绝);在 **adminConfigUpdate 写入侧****publicConfig 读取侧** 双重调用(覆盖历史存量与直改库数据);
2. 前端 `web/src/utils/markdown.ts` 净化器加固:黑名单补 `svg/math/frame/applet/template/noscript` 等,新增 `srcdoc/sandbox/formaction/action/xlink:href/srcset` 等危险属性表,URL 校验改为协议白名单(http/mailto/相对/锚点,src 另许 `data:image/`)。端到端实测:`<script>alert(1)</script>``javascript:` 链接被剔除、合法 `<a href="https://…">` 保留。
- 注:前端净化器改动需重建前端产物(已重建 `web/dist` 并同步 `server/web/dist`)方进入 go:embed 二进制;未来渲染任何外部内容前建议仍替换为 DOMPurify。
### L8 管理员令牌存 localStorage、默认会话 30 天 ✅ 已修复
- 位置:`web/src/api/http.ts:14-33``server/internal/config/config.go:110`
- 原状:XSS 可窃取且有效期长(可配 1–365 天)。
- ✅ 修复:`AdminSessionExpireDefault` 30 天 → **7 天**(仍可配 1–365 天,config 包测试通过);敏感操作改密已有旧密码校验 + jwt_secret 轮换(全部旧 token 失效)。`FCB_ADMIN_SESSION_EXPIRE` 环境变量种子同步支持(`main.go` + `.env.example` 注释)。
### L9 缓存故障时限流 fail-open ✅ 已修复
- 位置:`server/internal/middleware/ratelimit.go:148-164`
- 原状:Redis/缓存异常时 `Check` 一律放行(登录爆破防护随之失效)。
- ✅ 修复:`Check`/`Add` 区分 `cache.ErrNotFound`(窗口内无计数,正常放行)与真实缓存故障;故障时降级为**进程内固定窗口计数**(带过期清理与容量上限 8192,单实例语义),缓存恢复自动回到共享缓存;测试 `TestRateLimiterFallbackOnCacheFailure` 证明故障降级下超限后 fail-close 拒绝。端到端实测:连续错误密码 3 次 401 后第 4 次起 423 锁定。
### L10 反向代理场景的限流与审计 IP 失真 ✅ 已修复(文档 + 配置面)
- 位置:`server/internal/middleware/ratelimit.go:32-96``config.go:52`
- 原状:实现(仅信任 `FCB_TRUSTED_PROXIES` 命中的直连地址才采信 XFF)是正确的;但部署文档未强调反代后必须配置可信代理,否则全体用户共享代理 IP 的限流桶(互相误伤)且审计 IP 全是代理地址;反向配置错误则可伪造 XFF 绕过限流。
- ✅ 修复:`deploy/README.md` 新增「安全清单(生产部署必读)」7 条(可信代理、立即初始化/FCB_ADMIN_PASSWORD、修改组件默认凭据与端口发布、Postgres TLS、会话有效期、限流降级语义、内置清理任务);`deploy/.env.example``FCB_TRUSTED_PROXIES``FCB_ADMIN_PASSWORD``FCB_ADMIN_SESSION_EXPIRE` 补充注释说明,弱凭据处(MinIO/WebDAV/Postgres)加「仅限本机冒烟,生产必须修改」警示。
---
## 三、提示 / 信息(Info)修复情况
1. `version`/`storage_engine` 公开暴露(小信息收集面):**保留**(前端展示与诊断需要,风险极低)。
2. compose 弱凭据/端口发布:✅ 已在 `.env.example` 与 README 安全清单加警示(凭据为示例值,端口建议删除或绑 127.0.0.1)。
3. `robotsText` 无路由:✅ 已新增 `GET /robots.txt``router.go`,输出配置内容,text/plain);端到端实测 200 且内容生效。
4. `adminFileList` LIKE 未转义:✅ 已加 `escapeLike``\``\\``%``\%``_``\_`+ `ESCAPE '\'`admin-only,防御性修复)。
5. Postgres 并发配额超额记账:✅ `reserveStorage` 包事务并对 Postgres 加 `pg_advisory_xact_lock`"FCBQ" 键)串行化配额判定;SQLite 写串行不受影响。
6. `crypto/rand` 错误忽略:✅ `GenerateJWTSecret` 已改 panic 显式失败(见 M1);`generateCode`/`randomHex` 维持重试回退(非安全关键路径)。
---
## 四、确认到位的安全设计(无需修改)
- **JWT**HS256 + `WithValidMethods` 双重防算法混淆;密钥为 64 位 hex 随机;改密自动轮换(使全部旧会话失效);过期/签名校验完备。
- **SQL**:全部 GORM/占位符参数化;排序字段白名单(`normalizeSortBy`);无字符串拼接 SQL。
- **路径安全**`SanitizePath`/`SanitizeFileName` + `withinRoot` + `EvalSymlinks` 符号链接逃逸双重校验;S3 key、WebDAV 路径同样清洗;未发现穿越。
- **下载响应**:统一 `application/octet-stream` + `attachment` + RFC 5987 文件名编码,杜绝分享文件被当 HTML 渲染的存储型 XSS;取件文本以 `text/plain` 下发且前端 `<pre>{{ }}</pre>` 渲染。
- **认证/授权**`/admin/*` 全组 Bearer 鉴权;`/setup` 受 GuardNotInitialized 白名单约束;旧默认密码 `FileCodeBox2023` 视为未初始化;`verifyLegacyDefault`/密码比较用 `hmac.Equal`
- **上传策略**:大小上限五处口径一致(share/chunk-init/chunk-累计/presign/complete),magic bytes 防伪造,自定义码查重 + 唯一索引兜底并发。
- **限流**:取件错误/登录失败/上传/metadata 四类固定窗口;clientIP 仅信任显式配置的代理。
- **容量**:单条 INSERT..SELECT 原子预留判定,防超卖。
- **部署**:多阶段构建、非 rootuid 10001)运行、`.dockerignore` 合理、`.env` 未含真实密钥(当前也非 git 仓库;初始化 git 后应将 `deploy/.env` 加入忽略清单)。
---
## 五、修复优先级建议(原计划,已全部落实)
| 优先级 | 事项 | 状态 |
|---|---|---|
| 立即 | M4 依赖升级;M2 的 confirm 大小校验 | ✅ 已完成 |
| 短期 | M1 密码哈希迁移 bcryptM3 全局 MaxBytesReaderM5 init 计数 + 清理循环 | ✅ 已完成 |
| 计划 | L1–L10 按运营形态取舍 | ✅ 已全部修复 |
---
## 六、修复验证记录(2026-09-05 第二轮)
- **静态检查**`gofmt -l .` 无输出、`go vet ./...` 通过、`go build ./...` 通过。
- **单元/集成测试**`go test ./...` 全绿(api/audit/cache/config/database/middleware/settings/storage 全部 ok),新增修复回归测试:
- `settings/sanitize_test.go`L7 净化器 19 例)
- `middleware/audit_l5_test.go`L5 admin 审计落库)
- `middleware/ratelimit_fallback_test.go`L9 缓存故障降级 fail-close
- `api/security_fixes_test.go`L4 开关强制、M3 chunk 上限、L3 提码长度、M2 confirm 大小/超限、M1 密码迁移)
- **依赖扫描**`govulncheck ./...` 0 可达漏洞;`npm audit --omit=dev` 剩 2 moderatevue-i18n 上游未发布修复,跟踪中)。
- **端到端冒烟**(编译二进制 + SQLite 实跑):
1. `/setup` 初始化 200(M1 生效:DB 中 `admin_token = bcrypt$$2a$12$…`);
2. 错误密码登录 401 → 审计落 `admin/denied`;成功登录落 `admin/success`L5);
3. 连续 3 次错误后第 4 次起 423 锁定(L9 限流);
4. 配置写入 `<script>alert(1)</script>` + `javascript:` 链接 → `/api/v1/config` 输出已剔除、合法 `<a href>` 保留(L7);
5. `/robots.txt` 200 且内容生效(Info3);
6. 文本分享创建 + 取件下载全链路 200。
- **遗留跟踪**`vue-i18n` 上游修复版本发布后升级(当前 npm audit 的 2 个 moderate 均来源于此)。
+38
View File
@@ -0,0 +1,38 @@
// 验证 docs/openapi.yaml 与 Go 路由一致性(t4 验收辅助)
const fs = require('fs');
const YAML = require('/opt/homebrew/lib/node_modules/@deepseek-ai/dsh/node_modules/yaml');
const doc = YAML.parse(fs.readFileSync('docs/openapi.yaml', 'utf8'));
const goRoutes = new Set();
function scan(src) {
let group = '';
for (const raw of src.split('\n')) {
const line = raw.trim();
const gm = line.match(/^(\w+)\s*:=\s*r\.Group\("([^"]+)"/);
if (gm) { group = gm[2]; continue; }
const am = line.match(/^admin\.POST\("([^"]+)"/);
if (am) { goRoutes.add('POST ' + group + am[1]); continue; }
const m = line.match(/^(r|share|chunk|presign|authed)\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"/);
if (m) {
const base = (m[1] === 'r') ? '' : group;
goRoutes.add(m[2] + ' ' + base + m[3].replace(/:([A-Za-z]+)/g, '{$1}'));
}
}
}
scan(fs.readFileSync('server/internal/api/router.go', 'utf8'));
scan(fs.readFileSync('server/internal/api/admin.go', 'utf8'));
scan(fs.readFileSync('server/internal/api/setup.go', 'utf8'));
const oaRoutes = new Set();
for (const [p, item] of Object.entries(doc.paths)) {
for (const method of Object.keys(item)) {
if (['get', 'post', 'put', 'patch', 'delete'].includes(method)) oaRoutes.add(method.toUpperCase() + ' ' + p);
}
}
const goOnly = [...goRoutes].filter(r => !oaRoutes.has(r));
const oaOnly = [...oaRoutes].filter(r => !goRoutes.has(r));
console.log('Go 路由数:', goRoutes.size, ' OpenAPI 操作数:', oaRoutes.size);
console.log('Go 有而 OpenAPI 缺:', goOnly.length ? goOnly : '无');
console.log('OpenAPI 有而 Go 缺:', oaOnly.length ? oaOnly : '无');
console.log('(注:POST /chunk/upload 为扁平兼容端点,文档按 go-api 定稿仅写主路径 /chunk/upload/{uploadID}/{chunkIndex}');