From bd4e58c895f9046d658848d7f0bff59703d3818e Mon Sep 17 00:00:00 2001 From: murphyyi Date: Thu, 25 Sep 2025 08:07:41 +0800 Subject: [PATCH] chore: release v1.9.8 --- .github/workflows/build.yml | 2 +- CONTRIBUTING.md | 6 +- README.md | 415 ++++++++-------------- VERSION | 2 +- docker-compose.prod.yml | 2 +- docs/API-README.md | 12 +- docs/swagger-enhanced.yaml | 350 ++++++++++++++++-- docs/swagger.json | 4 +- docs/swagger.yaml | 4 +- internal/database/database.go | 1 + internal/handlers/api.go | 2 +- internal/handlers/user.go | 97 +++++ internal/middleware/optional_user_auth.go | 98 +++-- internal/models/db/user_api_key.go | 31 ++ internal/models/models.go | 1 + internal/models/service/system.go | 2 +- internal/models/web/user_api_key.go | 66 ++++ internal/repository/manager.go | 2 + internal/repository/user_api_key.go | 85 +++++ internal/routes/chunk.go | 3 + internal/routes/setup.go | 12 +- internal/routes/share.go | 5 +- internal/routes/user.go | 12 +- internal/services/services.go | 1 + internal/services/user/api_keys.go | 145 ++++++++ main.go | 2 +- themes/2025/css/dashboard.css | 220 +++++++++++- themes/2025/dashboard.html | 76 ++++ themes/2025/js/dashboard.js | 373 ++++++++++++++++++- 29 files changed, 1677 insertions(+), 354 deletions(-) create mode 100644 internal/models/db/user_api_key.go create mode 100644 internal/models/web/user_api_key.go create mode 100644 internal/repository/user_api_key.go create mode 100644 internal/services/user/api_keys.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 193722f..f01daeb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,7 @@ name: Build and Release on: push: - # Only trigger the heavy multi-platform build on version tag pushes (e.g. v1.9.1) + # Only trigger the heavy multi-platform build on version tag pushes (e.g. v1.9.8) tags: - 'v*' # Allow manual dispatch for ad-hoc builds from the UI diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 24e964a..3bd0a00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ This document explains how our GitHub Actions are gated and how to trigger heavi ### CI design principles - Lightweight checks (unit tests, linters) run on `push` / `pull_request` for `main` and `develop` branches. -- Heavy tasks (cross-platform builds, Docker image builds & push, release packaging) only run on tag pushes (e.g. `v1.9.1`) or when explicitly requested. +- Heavy tasks (cross-platform builds, Docker image builds & push, release packaging) only run on tag pushes (e.g. `v1.9.8`) or when explicitly requested. This reduces wasted CI minutes and avoids building/publishing artifacts for every code merge. @@ -13,8 +13,8 @@ There are multiple intentional ways to trigger full/heavy workflows: - Push a semantic version tag (recommended for releases): ```bash - git tag v1.9.1 - git push origin v1.9.1 + git tag v1.9.8 + git push origin v1.9.8 ``` Tag pushes trigger the `build.yml` / `release.yml` flows which do cross-platform builds and create the Release. diff --git a/README.md b/README.md index 93a5f54..9f24a49 100644 --- a/README.md +++ b/README.md @@ -1,344 +1,223 @@ -# FileCodeBox (Go) +# FileCodeBox · Go Edition -轻量且高性能的文件/文本分享服务,使用 Go 实现,支持分片上传、秒传、断点续传与多种存储后端。 +> 一个专为自托管场景打造的高性能文件 / 文本分享平台,提供安全、可扩展、可插拔的“随手分享”体验。 -核心目标是提供一个易部署、易扩展的文件快传系统,适合自托管与容器化部署场景。 - -## 主要特性 - -- 高性能:基于 Go 的并发能力构建,低延迟与内存占用 -- 文件/文本分享:支持短链接分享文本和文件 -- 分片上传:大文件分片、断点续传、上传校验与秒传支持 -- 管理后台:内置管理控制台,可管理文件、配置与用户 -- 多存储后端:支持本地、S3、WebDAV、OneDrive(可扩展) -- 容器友好:提供 Docker 与 docker-compose 支持 -- 主题系统:前端主题可替换与定制 +--- -## 环境要求 +## 📌 项目概览 -开发推荐使用 Go 1.25+。项目默认使用 SQLite 作为开发环境的轻量数据库。生产环境请按需选择存储与资源配置。 +FileCodeBox 是一个使用 Go 实现的轻量级分享服务,核心目标是“部署简单、使用顺手、运营安心”。无论你想在团队内搭建一个文件投递站,还是希望为个人项目提供临时分享通道,都可以通过它快速上线并稳定运行。 -## 部署建议(简要) +### 你可以用它做什么? -- 推荐使用 Docker + 反向代理(Nginx)启用 HTTPS -- 将 `data/` 目录做定期备份 -- 将服务放入进程管理(systemd / 容器重启策略) +- 📁 **拖拽上传**,生成短链接后分享文件或文本片段 +- 🔐 **后台管理**,集中查看、搜索、统计、审核、删除分享内容 +- 🪶 **多存储后端**,根据需求切换本地/对象存储/WebDAV/OneDrive 等方案 +- ⚙️ **自定义配置**,调整限速、过期策略、主题皮肤,让系统和业务完美贴合 -## 开发与扩展 +--- -- 新增存储:实现 `storage.StorageInterface` 并在 `storage.NewStorageManager` 注册 -- 新增接口:在 `internal/services` 实现业务逻辑,并在 `internal/handlers` 与 `internal/routes` 添加路由 +## 🌟 关键特性 -运行测试与示例脚本请查看 `tests/` 目录。 +| 分类 | 能力速览 | +| --- | --- | +| 性能 | Go 原生并发、分片上传、断点续传、秒传校验 | +| 分享体验 | 文本 / 文件双通道、链接有效期控制、密码和访问次数限制 | +| 管理后台 | 仪表板、文件列表、用户管理、存储面板、系统配置、维护工具 | +| 安全 | 初始化向导、动态路由热载、JWT 管理登录、限流中间件、可选用户体系 | +| 存储 | 本地磁盘、S3 兼容对象存储、WebDAV、OneDrive(均可扩展) | +| 部署 | Docker / Docker Compose、systemd、Nginx 反代、跨平台二进制 | +| 前端 | 主题系统(`themes/2025`)、自适应布局、可自定义静态资源 | --- -## 常见问题与排查(示例) +## 🧩 架构速写 -- 检查端口占用: - -```bash -lsof -ti:12345 +``` +┌───────────────────────────────────────────────────────────┐ +│ Client │ +│ Web UI (themes) · 管理后台 · RESTful API · CLI · WebDAV │ +└───────────────▲───────────────────────────────▲───────────┘ + │ │ + Admin Console Public Sharing + │ │ +┌──────────────┴───────────────────────────────┴─────────────┐ +│ FileCodeBox Server │ +│ │ +│ Routes ─ internal/routes Middleware │ +│ Handlers ─ internal/handlers RateLimit · Auth │ +│ Services ─ internal/services Chunk · Share · User │ +│ Repos ─ internal/repository GORM Data Access │ +│ Config ─ internal/config 动态配置管理 │ +└──────────────┬───────────────────────────────┬─────────────┘ + │ │ + Storage Manager Database (SQLite/MySQL/PG) + └─ local / S3 / WebDAV / OneDrive · 可插拔 ``` -- 如果数据库被锁或服务异常,尝试重启服务或检查 `data/` 下的 sqlite 文件权限。 - ---- - -## 许可证 +初始化流程已经内置了“未初始化只允许访问 `/setup`”的安全护栏: -MIT +1. 首次运行 → 自动跳转至 Setup 向导创建数据库 + 管理员 +2. 一旦初始化成功 → 所有用户请求 `/setup` 会被重定向至首页,同时拒绝重复初始化 --- -如需我继续: - -- 将 README 翻译为英文 -- 自动生成或更新 Swagger 文档 -- 补全详细部署示例(Kubernetes / systemd) - -请告诉我接下来要做哪个扩展。 -
- FileCodeBox Logo - - # FileCodeBox Go版本 - - 🚀 FileCodeBox的高性能Golang实现 - 开箱即用的文件快传系统 -
+## 🚀 快速起步 -## ✨ 特性 +### 1. 环境要求 -- 🔥 **高性能** - Golang实现,并发处理能力强 -- 📁 **文件分享** - 支持各种格式文件的快速分享 -- 📝 **文本分享** - 支持文本内容的快速分享 -- 🔧 **分片上传** - 支持大文件分片上传,断点续传 -- ⚡ **断点续传** - 网络中断后可恢复上传,支持秒传 -- ⏰ **灵活过期** - 支持时间和次数两种过期方式 -- 🛡️ **安全可靠** - JWT认证、限流保护、权限控制 -- 📊 **管理后台** - 完整的文件管理和系统配置 -- 🗄️ **多存储** - 支持本地、WebDAV、S3存储 -- 🐳 **容器化** - 开箱即用的Docker部署 -- 🎨 **主题系统** - 支持多主题切换 +- **Go** 1.21 及以上(开发环境推荐 1.24+) +- **SQLite / MySQL / PostgreSQL**(三选一,默认 SQLite) +- 可选:Docker 20+、docker-compose v2、Make、Git -## 快速开始 - -### 直接运行 +### 2. 本地开发 ```bash -# 安装依赖 +# 拉取依赖 go mod tidy -# 编译并运行 -go build -o filecodebox -./filecodebox +# 运行服务 +go run ./... +# 或编译后运行 +make build && ./bin/filecodebox ``` -服务启动后访问 `http://localhost:12345` 即可使用。 +服务默认监听 `http://127.0.0.1:12345`。首次访问会被引导到 `/setup` 完成初始化。 -### Docker运行 +### 3. Docker / Compose ```bash -# 构建镜像 -docker build -t filecodebox-go . - -# 运行容器 -docker run -d -p 12345:12345 -v ./data:/root/data filecodebox-go -``` - -### Docker Compose +# Docker +docker build -t filecodebox . +docker run -d \ + --name filecodebox \ + -p 12345:12345 \ + -v $(pwd)/data:/data \ + filecodebox -```bash +# docker-compose +cp docker-compose.yml docker-compose.override.yml # 如需自定义 docker-compose up -d ``` -### 多架构 Docker 构建 +**生产环境建议**: -支持 ARM64 和 AMD64 架构的 Docker 镜像构建: +- 使用 `docker-compose.prod.yml` + `.env` 管理数据库凭证 +- 通过 Nginx/Traefik 等反向代理启用 HTTPS 与缓存策略 +- 将 `data/`、数据库与对象存储做持久化与定期备份 -```bash -# 构建多架构镜像 -./build-docker.sh +### 4. CLI 管理 -# 构建指定架构 -./build-docker.sh --single linux/arm64 -./build-docker.sh --single linux/amd64 +```bash +./filecodebox admin user list +./filecodebox admin stats ``` -## 配置 - -配置文件会自动生成在 `data/config.json`,主要配置项: - -- `port`: 服务端口(默认12345) -- `name`: 站点名称 -- `upload_size`: 最大上传大小 -- `file_storage`: 存储类型(local/s3/webdav/onedrive) -- 管理员认证改为使用管理员用户名/密码登录并通过 `Authorization: Bearer ` 使用 JWT(不再使用静态管理员令牌配置) - -## 管理员后台 - -### 访问方式 -- 管理员页面:`http://localhost:12345/admin/` -- **默认密码**:`FileCodeBox2025` - -### 主要功能 -- 📊 **系统统计** - 查看文件数量、存储使用情况 -- 📁 **文件管理** - 管理所有分享的文件 -- ⚙️ **系统配置** - 配置站点信息、上传限制等 -- 👥 **用户管理** - 启用用户系统、管理用户权限 -- 💾 **存储配置** - 配置本地、S3、WebDAV等存储方式 - -### 初始配置建议 -1. 首次启动后,立即访问管理员页面修改默认密码 -2. 根据需要配置存储方式(本地存储/云存储) -3. 设置合适的上传大小限制 -4. 配置站点名称和描述信息 - -### 管理后台静态资源的安全说明 - -管理后台使用了一组专用静态资源(位于 `themes//admin/`),这些文件包含管理界面的 JavaScript、CSS 与模板。 - -为了避免未授权用户直接访问管理后台页面并读取敏感前端逻辑,服务已将管理专用静态资源改为仅在管理员认证后提供: +所有 CLI 子命令定义在 `internal/cli`,适合在自动化运维或脚本中使用。 -- 管理前端入口 `GET /admin/` 需要有效的管理员 JWT(通过 `Authorization: Bearer ` 方式传递)。 -- 管理专用静态路径(例如 `/admin/js/*`, `/admin/css/*`, `/admin/templates/*`, `/admin/assets/*`, `/admin/components/*`)也仅在认证通过后提供。 -- 公共资源(用户前端和通用资源)仍通过 `/js/*`, `/css/*`, `/assets/*`, `/components/*` 对外公开,以支持用户页面和登录流程。 - -部署注意:如果你的生产环境在前面放了 Nginx、CDN 或其它反向代理,请确保对 `/admin/*` 不做缓存并`不要`将 admin 静态事先缓存到代理上,否则可能绕过后端认证。建议在代理上为 `/admin/*` 添加 `Cache-Control: no-store` 或根据代理文档设置不缓存规则。 +--- -如果需要把少量引导或 favicon 等资产在未认证时可用,请将这些文件放入主题的 `assets/` 目录(由 `/assets/*` 提供),不要将管理专用目录下的文件放到公共路径。 +## 🛠️ 配置指南 -## API接口 +所有配置均由 `config.yml` + 数据库动态配置组合而成: -### 分享文本 -``` -POST /share/text/ -``` +| 配置域 | 说明 | +| --- | --- | +| `base` | 服务端口、站点名称、DataPath | +| `storage` | 存储类型、凭证、路径 | +| `transfer` | 上传限额、断点续传、分片策略 | +| `user` | 用户系统开关、配额、注册策略 | +| `mcp` | 消息通道 / WebSocket 服务配置 | +| `ui` | 主题、背景、页面说明文案 | -### 分享文件 -``` -POST /share/file/ -``` +初始化完成后,配置会同步写入数据库并可在后台在线修改。每次操作都走事务保证一致性。 -### 获取分享内容 -``` -GET /share/select/?code=xxx -POST /share/select/ -``` +> **提示**:未初始化时,仅开放 `/setup`、部分静态资源与 `GET /user/system-info`,避免系统在部署初期被误用。 -### 分片上传和断点续传 -``` -POST /chunk/upload/init/ # 初始化分片上传 -POST /chunk/upload/chunk/:upload_id/:chunk_index # 上传分片 -POST /chunk/upload/complete/:upload_id # 完成上传 -GET /chunk/upload/status/:upload_id # 获取上传状态 -POST /chunk/upload/verify/:upload_id/:chunk_index # 验证分片 -DELETE /chunk/upload/cancel/:upload_id # 取消上传 -``` +--- -### 管理接口 -``` -GET /admin/stats # 统计信息 -GET /admin/files # 文件列表 -DELETE /admin/files/:id # 删除文件 -GET /admin/config # 获取配置 -PUT /admin/config # 更新配置 -``` +## 🧑‍💻 管理后台一览 -## 项目结构 +- **仪表盘**:吞吐趋势、存储占用、系统告警 +- **文件管理**:模糊搜索、批量操作、访问日志 +- **用户管理**:启用用户系统、分配配额、状态冻结 +- **存储配置**:即时切换存储后端,并对接健康检查 +- **系统配置**:修改站点基础信息、主题、分享策略 +- **维护工具**:清理过期数据、生成导出、查看审计日志 -``` -├── assets/ # 项目资源文件 -│ └── images/logos/ # Logo 和图标文件 -├── docs/ # 文档目录 -│ ├── changelogs/ # 改动记录和版本日志 -│ ├── design/ # 设计相关文档 -│ └── *.md # 其他文档 -├── scripts/ # 脚本文件 -│ ├── build-docker.sh # Docker 构建 -│ ├── deploy.sh # 部署脚本 -│ └── *.sh # 其他脚本 -├── main.go # 主程序入口 -├── internal/ # Go 源代码 -│ ├── config/ # 配置管理 -│ ├── database/ # 数据库初始化 -│ ├── models/ # 数据模型 -│ ├── services/ # 业务逻辑 -│ ├── handlers/ # HTTP处理器 -│ ├── middleware/ # 中间件 -│ ├── storage/ # 存储接口 -│ ├── routes/ # 路由设置 -│ └── tasks/ # 后台任务 -├── themes/ # 主题文件 -│ └── 2024/ # 2024年主题 -├── data/ # 数据目录 -├── tests/ # 测试文件 -└── docker-compose.yml # Docker编排 -``` +访问入口:`/admin/`,登录采用 JWT + Bearer Token。自 2025 版起,所有 `admin` 静态资源均由后台鉴权动态下发,避免公共缓存泄露。 -## 与Python版本的差异 +--- -1. **性能提升**: Go版本具有更好的并发性能和更低的内存占用 -2. **依赖更少**: 不需要Python运行时环境 -3. **部署简单**: 编译后为单一可执行文件 -4. **类型安全**: 静态类型系统减少运行时错误 -5. **容器优化**: 支持多架构Docker镜像,更小的镜像体积 +## 📦 存储与上传 -## 环境要求 +| 类型 | 说明 | +| --- | --- | +| `local` | 默认方案,数据持久化在 `data/uploads` | +| `s3` | 兼容 S3 的对象存储(如 MinIO、阿里云 OSS) | +| `webdav` | 适合挂载 NAS / Nextcloud | +| `onedrive` | 利用 Microsoft Graph 的云端存储 | -### 开发环境 -- Go 1.25+ -- SQLite3(用于数据存储) -- Git(用于代码管理) +上传采用“分片 + 秒传 + 断点续传”的三段式策略: -### 生产环境 -- Linux/macOS/Windows -- 至少 512MB 内存 -- 推荐使用 Docker 部署 +1. `POST /chunk/upload/init/` 初始化会返回 upload_id +2. 并行调用 `POST /chunk/upload/chunk/:id/:idx` +3. 最后 `POST /chunk/upload/complete/:id` 合并并校验 -## 部署建议 +上传状态可通过 `GET /chunk/upload/status/:id` 观察,也可主动 `DELETE /chunk/upload/cancel/:id` 终止。 -### Docker 部署(推荐) -```bash -# 使用 docker-compose(最简单) -docker-compose up -d +--- -# 或手动运行 Docker -docker run -d \ - --name filecodebox \ - -p 12345:12345 \ - -v ./data:/root/data \ - --restart unless-stopped \ - filecodebox-go -``` +## 📚 API 与 SDK -### 直接部署 -```bash -# 编译 -go build -ldflags="-w -s" -o filecodebox +虽然 FileCodeBox 主要针对 Web 场景,但服务本身围绕 REST API 架构,便于集成: -# 创建系统服务(可选) -sudo cp filecodebox /usr/local/bin/ -sudo systemctl enable filecodebox -sudo systemctl start filecodebox -``` +| 模块 | 典型接口 | +| --- | --- | +| 分享 | `POST /share/text/` · `POST /share/file/` · `GET /share/select/?code=...` | +| 分片 | `POST /chunk/upload/init/` · `POST /chunk/upload/complete/:id` | +| 用户 | `POST /user/login` · `POST /user/register`(启用用户系统时) | +| 管理 | `GET /admin/stats` · `POST /admin/files/delete` 等 | +| 健康检查 | `GET /health` | -## 开发 +API 文档位于 `docs/swagger-enhanced.yaml`,可通过 `go install github.com/swaggo/swag/cmd/swag@latest` 生成最新文档。 -### 添加新的存储后端 +--- -1. 实现 `storage.StorageInterface` 接口 -2. 在 `storage.NewStorageManager` 中注册新存储 -3. 在配置中添加相关配置项 +## 🧑‍🔬 本地开发与贡献 -### 添加新功能 +1. Fork & clone 仓库 +2. `make dev`(或参考 `Makefile`) +3. 运行测试:`go test ./...` +4. 提交前确保通过 `golangci-lint`/`go fmt`(在 CI 中亦会执行) -1. 在 `models/` 中定义数据模型 -2. 在 `services/` 中实现业务逻辑 -3. 在 `handlers/` 中添加HTTP处理器 -4. 在 `routes/` 中注册路由 +项目保持模块化、接口清晰,欢迎贡献以下方向: -## 安全建议 +- 新的存储适配器 / 用户登录方式 +- 管理后台的交互优化与国际化支持 +- 自动化部署脚本(Helm / Terraform) +- 更丰富的 API 客户端(Python/Node/Swift) -- 🔐 **修改默认密码**: 首次部署后立即修改管理员默认密码 -- 🛡️ **使用HTTPS**: 生产环境建议配置SSL证书 -- 🔥 **防火墙配置**: 只开放必要的端口(如12345) -- 📊 **定期备份**: 定期备份 `data` 目录 -- 🚫 **访问控制**: 根据需要启用用户系统和权限控制 +提交 PR 时请附上:变更说明、测试方式、潜在影响。如涉及迁移,请编写相应文档放在 `docs/`。 -## 故障排除 +--- -### 常见问题 +## 🗺️ 路线图(节选) -1. **端口被占用** - ```bash - # 检查端口占用 - lsof -ti:12345 - # 杀掉占用进程 - lsof -ti:12345 | xargs kill -9 - ``` +- [ ] Webhook / EventHook(上传完成、分享到期、超额告警) +- [ ] 更细颗粒度的访问控制(到期提醒、下载密码、白名单) +- [ ] 多节点部署指南(对象存储 + Redis + MySQL) +- [ ] 管理后台模块化主题系统 & 深色主题 +- [ ] CLI 支持导入/导出配置模板 -2. **数据库锁定** - ```bash - # 重启服务即可解决 - pkill -f filecodebox - ./filecodebox - ``` +欢迎在 Issues 区讨论新需求或报告缺陷。 -3. **文件权限问题** - ```bash - # 确保data目录有正确权限 - chmod -R 755 data/ - ``` +--- -### 日志查看 -```bash -# 查看应用日志 -./filecodebox 2>&1 | tee app.log +## 📄 许可证 -# Docker 容器日志 -docker logs filecodebox -``` +MIT License © FileCodeBox Contributors -## 许可证 +--- -MIT License +> 💬 有任何问题、部署疑问或定制需求,欢迎通过 Issue / Discussions / 邮件联系。我们乐于协助每一个想把 FileCodeBox 搭建成“团队内部效率神器”的你。 diff --git a/VERSION b/VERSION index 9ab8337..66beabb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.9.1 +1.9.8 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b58e1f9..da73662 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -2,7 +2,7 @@ version: '3.8' services: filecodebox-go: - image: filecodebox-go:latest + image: ghcr.io/zy84338719/filecodebox:latest container_name: filecodebox-prod ports: - "12345:12345" diff --git a/docs/API-README.md b/docs/API-README.md index 058cc1e..d43afb8 100644 --- a/docs/API-README.md +++ b/docs/API-README.md @@ -81,7 +81,17 @@ API 支持多种认证方式: 3. **JWT Token**: Bearer token 认证 4. **可选认证**: 部分接口支持匿名访问 -### 📊 响应格式 +### � 用户 API Key 管理 + +登录后的用户可以在 `/user/api-keys` 接口管理个人 API Key,用于从命令行或第三方应用直接上传/下载: + +- `GET /user/api-keys`:列出当前用户的全部 API Key(需要 Bearer Token) +- `POST /user/api-keys`:创建新的 API Key,可选字段 `name`、`expires_in_days` 或 `expires_at` +- `DELETE /user/api-keys/{id}`:撤销指定的 API Key + +创建成功后,响应会包含一次性返回的明文 API Key。后续请求需在 `Authorization: ApiKey ` 或 `X-API-Key` 头中携带,系统会自动识别并注入用户身份,可用于 `/share/*` 和 `/chunk/*` 等上传/下载接口。 + +### �📊 响应格式 所有 API 响应都遵循统一格式: diff --git a/docs/swagger-enhanced.yaml b/docs/swagger-enhanced.yaml index e71f264..284d740 100644 --- a/docs/swagger-enhanced.yaml +++ b/docs/swagger-enhanced.yaml @@ -2,7 +2,7 @@ swagger: "2.0" info: title: "FileCodeBox API" description: "FileCodeBox 是一个用于文件分享和代码片段管理的 Web 应用程序" - version: "1.9.1" + version: "1.9.8" termsOfService: "http://swagger.io/terms/" contact: name: "API Support" @@ -19,14 +19,11 @@ schemes: - "https" securityDefinitions: - ApiKeyAuth: - type: "apiKey" - name: "X-API-Key" - in: "header" BearerAuth: type: "apiKey" name: "Authorization" in: "header" + description: "在请求头中携带 Bearer Token,例如:Authorization: Bearer " BasicAuth: type: "basic" @@ -45,6 +42,8 @@ tags: description: "存储管理接口" - name: "MCP" description: "Model Context Protocol 接口" + - name: "初始化" + description: "系统初始化与安装向导接口" paths: # 系统接口 @@ -69,7 +68,7 @@ paths: example: "2025-09-11T10:00:00Z" version: type: "string" - example: "1.9.1" + example: "1.9.8" uptime: type: "string" example: "2h30m15s" @@ -87,6 +86,91 @@ paths: schema: $ref: "#/definitions/SystemConfig" + /check-init: + get: + tags: ["初始化"] + summary: "检查系统初始化状态" + description: "返回系统是否已经完成初始化,用于引导 Setup 向导" + produces: + - "application/json" + responses: + 200: + description: "初始化状态" + schema: + $ref: "#/definitions/InitializationStatusResponse" + 500: + description: "服务器内部错误" + schema: + $ref: "#/definitions/ErrorResponse" + + /setup/initialize: + post: + tags: ["初始化"] + summary: "执行系统初始化" + description: "提交数据库与管理员配置,完成系统初始化流程。未初始化时公开,初始化后需管理员权限。" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "request" + in: "body" + required: true + description: "初始化参数" + schema: + $ref: "#/definitions/SetupInitializeRequest" + responses: + 200: + description: "初始化成功" + schema: + $ref: "#/definitions/SetupInitializeResponse" + 400: + description: "请求参数错误" + schema: + $ref: "#/definitions/ErrorResponse" + 403: + description: "系统已初始化" + schema: + $ref: "#/definitions/ErrorResponse" + 500: + description: "服务器内部错误" + schema: + $ref: "#/definitions/ErrorResponse" + + /setup: + post: + tags: ["初始化"] + summary: "执行系统初始化(兼容入口)" + description: "兼容旧版本 Setup 表单的初始化入口,与 /setup/initialize 行为一致" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "request" + in: "body" + required: true + description: "初始化参数" + schema: + $ref: "#/definitions/SetupInitializeRequest" + responses: + 200: + description: "初始化成功" + schema: + $ref: "#/definitions/SetupInitializeResponse" + 400: + description: "请求参数错误" + schema: + $ref: "#/definitions/ErrorResponse" + 403: + description: "系统已初始化" + schema: + $ref: "#/definitions/ErrorResponse" + 500: + description: "服务器内部错误" + schema: + $ref: "#/definitions/ErrorResponse" + # 分享接口 /share/text/: post: @@ -120,8 +204,6 @@ paths: type: "boolean" default: false description: "是否需要认证" - security: - - BearerAuth: [] responses: 200: description: "分享成功" @@ -171,8 +253,6 @@ paths: type: "boolean" default: false description: "是否需要认证" - security: - - BearerAuth: [] responses: 200: description: "分享成功" @@ -264,8 +344,6 @@ paths: type: "string" required: true description: "分享代码" - security: - - BearerAuth: [] responses: 200: description: "文件下载或文本内容" @@ -303,8 +381,6 @@ paths: description: "上传初始化参数" schema: $ref: "#/definitions/ChunkInitRequest" - security: - - BearerAuth: [] responses: 200: description: "初始化成功" @@ -348,8 +424,6 @@ paths: type: "file" required: true description: "分片文件" - security: - - BearerAuth: [] responses: 200: description: "上传成功" @@ -389,8 +463,6 @@ paths: description: "完成上传参数" schema: $ref: "#/definitions/ChunkCompleteRequest" - security: - - BearerAuth: [] responses: 200: description: "上传完成" @@ -422,8 +494,6 @@ paths: type: "string" required: true description: "上传ID" - security: - - BearerAuth: [] responses: 200: description: "上传状态" @@ -434,6 +504,42 @@ paths: schema: $ref: "#/definitions/ErrorResponse" + /chunk/upload/verify/{upload_id}/{chunk_index}: + post: + tags: ["分片上传"] + summary: "校验分片" + description: "用于断点续传场景,校验指定分片是否已存在" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "upload_id" + in: "path" + type: "string" + required: true + description: "上传ID" + - name: "chunk_index" + in: "path" + type: "integer" + required: true + description: "分片索引" + - name: "request" + in: "body" + required: true + description: "分片校验参数" + schema: + $ref: "#/definitions/ChunkVerifyRequest" + responses: + 200: + description: "分片校验结果" + schema: + $ref: "#/definitions/ChunkVerifyResponse" + 404: + description: "上传ID不存在" + schema: + $ref: "#/definitions/ErrorResponse" + /chunk/upload/cancel/{upload_id}: delete: tags: ["分片上传"] @@ -447,8 +553,6 @@ paths: type: "string" required: true description: "上传ID" - security: - - BearerAuth: [] responses: 200: description: "取消成功" @@ -464,6 +568,40 @@ paths: $ref: "#/definitions/ErrorResponse" # 用户接口 + /user/system-info: + get: + tags: ["用户"] + summary: "获取用户系统状态" + description: "返回用户系统开关、注册是否允许等信息" + produces: + - "application/json" + responses: + 200: + description: "用户系统信息" + schema: + $ref: "#/definitions/UserSystemInfoResponse" + 500: + description: "服务器内部错误" + schema: + $ref: "#/definitions/ErrorResponse" + + /user/check-initialization: + get: + tags: ["用户"] + summary: "检查系统是否已初始化" + description: "与 /check-init 一致,为用户端兼容接口" + produces: + - "application/json" + responses: + 200: + description: "初始化状态" + schema: + $ref: "#/definitions/InitializationStatusResponse" + 500: + description: "服务器内部错误" + schema: + $ref: "#/definitions/ErrorResponse" + /user/register: post: tags: ["用户"] @@ -639,7 +777,7 @@ paths: produces: - "application/json" security: - - ApiKeyAuth: [] + - BearerAuth: [] responses: 200: description: "统计信息" @@ -689,7 +827,7 @@ paths: default: "desc" description: "排序方式" security: - - ApiKeyAuth: [] + - BearerAuth: [] responses: 200: description: "文件列表" @@ -718,7 +856,7 @@ paths: required: true description: "文件ID" security: - - ApiKeyAuth: [] + - BearerAuth: [] responses: 200: description: "删除成功" @@ -745,7 +883,7 @@ paths: produces: - "application/json" security: - - ApiKeyAuth: [] + - BearerAuth: [] responses: 200: description: "系统配置" @@ -775,7 +913,7 @@ paths: schema: $ref: "#/definitions/AdminConfigRequest" security: - - ApiKeyAuth: [] + - BearerAuth: [] responses: 200: description: "更新成功" @@ -812,7 +950,7 @@ paths: schema: $ref: "#/definitions/StorageTestRequest" security: - - ApiKeyAuth: [] + - BearerAuth: [] responses: 200: description: "测试成功" @@ -848,7 +986,7 @@ paths: schema: $ref: "#/definitions/StorageSwitchRequest" security: - - ApiKeyAuth: [] + - BearerAuth: [] responses: 200: description: "切换成功" @@ -894,6 +1032,135 @@ definitions: type: "string" example: "detailed error" + InitializationStatus: + type: "object" + properties: + initialized: + type: "boolean" + description: "系统是否已经完成初始化" + example: true + + InitializationStatusResponse: + allOf: + - $ref: "#/definitions/SuccessResponse" + - type: "object" + properties: + data: + $ref: "#/definitions/InitializationStatus" + + SetupDatabaseConfig: + type: "object" + required: ["type"] + properties: + type: + type: "string" + enum: ["sqlite", "mysql", "postgres"] + description: "数据库类型" + file: + type: "string" + description: "SQLite 数据库文件路径" + host: + type: "string" + description: "数据库主机地址" + port: + type: "integer" + description: "数据库端口" + example: 3306 + user: + type: "string" + description: "数据库用户名" + password: + type: "string" + description: "数据库密码" + database: + type: "string" + description: "数据库名称" + + SetupAdminConfig: + type: "object" + required: ["username", "email", "password"] + properties: + username: + type: "string" + description: "管理员用户名" + example: "admin" + email: + type: "string" + format: "email" + description: "管理员邮箱" + example: "admin@example.com" + nickname: + type: "string" + description: "管理员昵称" + password: + type: "string" + format: "password" + description: "管理员密码" + confirm: + type: "string" + format: "password" + description: "确认密码" + allowUserRegistration: + type: "boolean" + description: "是否允许用户自助注册" + + SetupInitializeRequest: + type: "object" + required: ["database", "admin"] + properties: + database: + $ref: "#/definitions/SetupDatabaseConfig" + admin: + $ref: "#/definitions/SetupAdminConfig" + + SetupInitializeData: + type: "object" + properties: + message: + type: "string" + example: "系统初始化完成" + username: + type: "string" + example: "admin" + database_type: + type: "string" + example: "sqlite" + + SetupInitializeResponse: + allOf: + - $ref: "#/definitions/SuccessResponse" + - type: "object" + properties: + data: + $ref: "#/definitions/SetupInitializeData" + + UserSystemInfo: + type: "object" + properties: + user_system_enabled: + type: "integer" + enum: [0, 1] + description: "用户系统是否启用" + example: 1 + allow_user_registration: + type: "integer" + enum: [0, 1] + description: "是否允许用户注册" + example: 1 + require_email_verification: + type: "integer" + enum: [0, 1] + description: "是否需要邮箱验证" + example: 0 + + UserSystemInfoResponse: + allOf: + - $ref: "#/definitions/SuccessResponse" + - type: "object" + properties: + data: + $ref: "#/definitions/UserSystemInfo" + # 系统配置 SystemConfig: type: "object" @@ -1121,6 +1388,31 @@ definitions: enum: ["pending", "uploading", "completed", "failed"] description: "上传状态" + ChunkVerifyRequest: + type: "object" + required: ["chunk_hash"] + properties: + chunk_hash: + type: "string" + description: "客户端计算的分片哈希" + example: "9b74c9897bac770ffc029102a200c5de" + + ChunkVerifyResult: + type: "object" + properties: + valid: + type: "boolean" + description: "分片是否已存在" + example: true + + ChunkVerifyResponse: + allOf: + - $ref: "#/definitions/SuccessResponse" + - type: "object" + properties: + data: + $ref: "#/definitions/ChunkVerifyResult" + # 用户相关 RegisterRequest: type: "object" diff --git a/docs/swagger.json b/docs/swagger.json index 4363e1e..4cf5b2a 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -13,7 +13,7 @@ "name": "MIT", "url": "https://github.com/zy84338719/filecodebox/blob/main/LICENSE" }, - "version": "1.9.1" + "version": "1.9.8" }, "host": "localhost:12345", "basePath": "/", @@ -553,7 +553,7 @@ }, "version": { "type": "string", - "example": "1.9.1" + "example": "1.9.8" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b4f156d..6c2d95a 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -12,7 +12,7 @@ definitions: example: 2h30m15s type: string version: - example: 1.9.1 + example: 1.9.8 type: string type: object handlers.SystemConfig: @@ -57,7 +57,7 @@ info: url: https://github.com/zy84338719/filecodebox/blob/main/LICENSE termsOfService: http://swagger.io/terms/ title: FileCodeBox API - version: "1.9.1" + version: "1.9.8" paths: /api/config: get: diff --git a/internal/database/database.go b/internal/database/database.go index e1981ff..84b2358 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -47,6 +47,7 @@ func InitWithManager(manager *config.ConfigManager) (*gorm.DB, error) { &models.UserSession{}, &models.TransferLog{}, &models.AdminOperationLog{}, + &models.UserAPIKey{}, ) if err != nil { return nil, fmt.Errorf("数据库自动迁移失败: %w", err) diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 3549923..f231038 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -29,7 +29,7 @@ func NewAPIHandler(manager *config.ConfigManager) *APIHandler { type HealthResponse struct { Status string `json:"status" example:"ok"` Timestamp string `json:"timestamp" example:"2025-09-11T10:00:00Z"` - Version string `json:"version" example:"1.9.1"` + Version string `json:"version" example:"1.9.8"` Uptime string `json:"uptime" example:"2h30m15s"` } diff --git a/internal/handlers/user.go b/internal/handlers/user.go index 1f64102..fb2487d 100644 --- a/internal/handlers/user.go +++ b/internal/handlers/user.go @@ -1,7 +1,9 @@ package handlers import ( + "errors" "strconv" + "strings" "time" "github.com/zy84338719/filecodebox/internal/common" @@ -11,6 +13,7 @@ import ( "github.com/zy84338719/filecodebox/internal/utils" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) // UserHandler 用户处理器 @@ -207,6 +210,100 @@ func (h *UserHandler) ChangePassword(c *gin.Context) { common.SuccessWithMessage(c, "密码修改成功", nil) } +// ListAPIKeys 获取当前用户的全部 API Key 列表 +func (h *UserHandler) ListAPIKeys(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + common.UnauthorizedResponse(c, "用户未登录") + return + } + + keys, err := h.userService.ListUserAPIKeys(userID.(uint)) + if err != nil { + common.InternalServerErrorResponse(c, "获取 API Key 失败: "+err.Error()) + return + } + + resp := make([]web.UserAPIKeyResponse, 0, len(keys)) + for _, key := range keys { + resp = append(resp, web.MakeUserAPIKeyResponse(key)) + } + + common.SuccessResponse(c, web.UserAPIKeyListResponse{Keys: resp}) +} + +// CreateAPIKey 为当前用户生成新的 API Key +func (h *UserHandler) CreateAPIKey(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + common.UnauthorizedResponse(c, "用户未登录") + return + } + + var req web.UserAPIKeyCreateRequest + if !utils.BindJSONWithValidation(c, &req) { + return + } + + var expiresAt *time.Time + if req.ExpiresAt != nil && strings.TrimSpace(*req.ExpiresAt) != "" { + parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(*req.ExpiresAt)) + if err != nil { + common.BadRequestResponse(c, "expires_at 必须为 RFC3339 格式") + return + } + t := parsed.UTC() + expiresAt = &t + } else if req.ExpiresInDays != nil { + if *req.ExpiresInDays <= 0 { + common.BadRequestResponse(c, "expires_in_days 必须大于 0") + return + } + t := time.Now().Add(time.Duration(*req.ExpiresInDays) * 24 * time.Hour) + expiresAt = &t + } + + plainKey, record, err := h.userService.GenerateUserAPIKey(userID.(uint), req.Name, expiresAt) + if err != nil { + common.BadRequestResponse(c, err.Error()) + return + } + + response := web.UserAPIKeyCreateResponse{ + Key: plainKey, + APIKey: web.MakeUserAPIKeyResponse(*record), + } + + common.SuccessWithMessage(c, "API Key 生成成功", response) +} + +// DeleteAPIKey 撤销指定的 API Key +func (h *UserHandler) DeleteAPIKey(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + common.UnauthorizedResponse(c, "用户未登录") + return + } + + idStr := c.Param("id") + keyID, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + common.BadRequestResponse(c, "密钥ID格式错误") + return + } + + if err := h.userService.RevokeUserAPIKey(userID.(uint), uint(keyID)); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + common.NotFoundResponse(c, "API Key 不存在或已撤销") + return + } + common.InternalServerErrorResponse(c, "撤销 API Key 失败: "+err.Error()) + return + } + + common.SuccessWithMessage(c, "API Key 已撤销", nil) +} + // GetUserFiles 获取用户文件列表 func (h *UserHandler) GetUserFiles(c *gin.Context) { userID, exists := c.Get("user_id") diff --git a/internal/middleware/optional_user_auth.go b/internal/middleware/optional_user_auth.go index bab2198..91d8879 100644 --- a/internal/middleware/optional_user_auth.go +++ b/internal/middleware/optional_user_auth.go @@ -11,37 +11,89 @@ import ( // OptionalUserAuth 可选用户认证中间件(支持匿名和登录用户) func OptionalUserAuth(manager *config.ConfigManager, userService interface { ValidateToken(string) (interface{}, error) + AuthenticateAPIKey(string) (*services.APIKeyAuthResult, error) }) gin.HandlerFunc { return func(c *gin.Context) { - authHeader := c.GetHeader("Authorization") - if authHeader == "" { - c.Set("is_anonymous", true) - c.Next() - return - } + c.Set("is_anonymous", true) - tokenParts := strings.SplitN(authHeader, " ", 2) - if len(tokenParts) != 2 || tokenParts[0] != "Bearer" { - c.Set("is_anonymous", true) - c.Next() - return - } - - claimsInterface, err := userService.ValidateToken(tokenParts[1]) - if err != nil { - c.Set("is_anonymous", true) - c.Next() - return - } - - if claims, ok := claimsInterface.(*services.AuthClaims); ok { + setUserFromClaims := func(claims *services.AuthClaims) { + if claims == nil { + return + } c.Set("user_id", claims.UserID) c.Set("username", claims.Username) c.Set("role", claims.Role) c.Set("session_id", claims.SessionID) + c.Set("auth_via_api_key", false) + c.Set("is_anonymous", false) + } + + setUserFromAPIKey := func(result *services.APIKeyAuthResult) { + if result == nil { + return + } + c.Set("user_id", result.UserID) + c.Set("username", result.Username) + c.Set("role", result.Role) + c.Set("api_key_id", result.KeyID) + c.Set("auth_via_api_key", true) c.Set("is_anonymous", false) - } else { - c.Set("is_anonymous", true) + } + + tryBearer := func(token string) bool { + if userService == nil || strings.TrimSpace(token) == "" { + return false + } + claimsInterface, err := userService.ValidateToken(strings.TrimSpace(token)) + if err != nil { + return false + } + if claims, ok := claimsInterface.(*services.AuthClaims); ok { + setUserFromClaims(claims) + return true + } + return false + } + + tryAPIKey := func(key string) bool { + if userService == nil || strings.TrimSpace(key) == "" { + return false + } + result, err := userService.AuthenticateAPIKey(strings.TrimSpace(key)) + if err != nil { + return false + } + setUserFromAPIKey(result) + return true + } + + authHeader := c.GetHeader("Authorization") + if authHeader != "" { + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) == 2 { + scheme := strings.ToLower(strings.TrimSpace(parts[0])) + credentials := parts[1] + switch scheme { + case "bearer": + if tryBearer(credentials) { + c.Next() + return + } + case "apikey": + if tryAPIKey(credentials) { + c.Next() + return + } + } + } + } + + // Fallback to X-API-Key header + if keyHeader := c.GetHeader("X-API-Key"); keyHeader != "" { + if tryAPIKey(keyHeader) { + c.Next() + return + } } c.Next() diff --git a/internal/models/db/user_api_key.go b/internal/models/db/user_api_key.go new file mode 100644 index 0000000..65569cc --- /dev/null +++ b/internal/models/db/user_api_key.go @@ -0,0 +1,31 @@ +package db + +import ( + "time" + + "gorm.io/gorm" +) + +// UserAPIKey 表示用户生成的 API Key 元信息,真实密钥仅在创建时返回 +// KeyHash 存储经过 SHA-256 处理后的摘要,Prefix 便于调试时区分不同密钥 +// Revoked 标记密钥是否已被撤销 +// ExpiresAt 可选过期时间,为空表示长期有效 +// LastUsedAt 记录最近一次使用时间 +// Name 为用户自定义的标识,方便区分多把密钥 + +type UserAPIKey struct { + gorm.Model + UserID uint `gorm:"index"` + Name string `gorm:"size:100"` + Prefix string `gorm:"size:16"` + KeyHash string `gorm:"size:64;uniqueIndex"` + LastUsedAt *time.Time + ExpiresAt *time.Time + RevokedAt *time.Time + Revoked bool `gorm:"default:false"` +} + +// TableName 指定表名 +func (UserAPIKey) TableName() string { + return "user_api_keys" +} diff --git a/internal/models/models.go b/internal/models/models.go index 63b08bd..70d362e 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -18,6 +18,7 @@ type ( TransferLogQuery = db.TransferLogQuery AdminOperationLog = db.AdminOperationLog AdminOperationLogQuery = db.AdminOperationLogQuery + UserAPIKey = db.UserAPIKey // 服务模型别名 BuildInfo = service.BuildInfo diff --git a/internal/models/service/system.go b/internal/models/service/system.go index d99036e..7c62004 100644 --- a/internal/models/service/system.go +++ b/internal/models/service/system.go @@ -19,7 +19,7 @@ var ( GitBranch = "unknown" // Version 应用版本号 - Version = "1.9.1" + Version = "1.9.8" ) // BuildInfo 构建信息结构体 diff --git a/internal/models/web/user_api_key.go b/internal/models/web/user_api_key.go new file mode 100644 index 0000000..526b2c0 --- /dev/null +++ b/internal/models/web/user_api_key.go @@ -0,0 +1,66 @@ +package web + +import ( + "time" + + "github.com/zy84338719/filecodebox/internal/models" +) + +// UserAPIKeyCreateRequest 用户 API Key 创建请求 +// 可以指定名称以及过期时间(expires_in_days 或 RFC3339 格式的 expires_at) +type UserAPIKeyCreateRequest struct { + Name string `json:"name"` + ExpiresInDays *int `json:"expires_in_days"` + ExpiresAt *string `json:"expires_at"` +} + +// UserAPIKeyResponse 用户 API Key 响应体 +// 不返回真实密钥,仅返回元信息 + +type UserAPIKeyResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + LastUsedAt string `json:"last_used_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + RevokedAt string `json:"revoked_at,omitempty"` + Revoked bool `json:"revoked"` +} + +// UserAPIKeyCreateResponse 创建 API Key 的响应,包含明文密钥和元信息 + +type UserAPIKeyCreateResponse struct { + Key string `json:"key"` + APIKey UserAPIKeyResponse `json:"api_key"` +} + +// UserAPIKeyListResponse 用户 API Key 列表响应 +type UserAPIKeyListResponse struct { + Keys []UserAPIKeyResponse `json:"keys"` +} + +// MakeUserAPIKeyResponse 将数据库模型转换为响应结构 +func MakeUserAPIKeyResponse(key models.UserAPIKey) UserAPIKeyResponse { + resp := UserAPIKeyResponse{ + ID: key.ID, + Name: key.Name, + Prefix: key.Prefix, + CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: key.UpdatedAt.UTC().Format(time.RFC3339), + Revoked: key.Revoked, + } + + if key.LastUsedAt != nil { + resp.LastUsedAt = key.LastUsedAt.UTC().Format(time.RFC3339) + } + if key.ExpiresAt != nil { + resp.ExpiresAt = key.ExpiresAt.UTC().Format(time.RFC3339) + } + if key.RevokedAt != nil { + resp.RevokedAt = key.RevokedAt.UTC().Format(time.RFC3339) + } + + return resp +} diff --git a/internal/repository/manager.go b/internal/repository/manager.go index f3483d1..f2e357b 100644 --- a/internal/repository/manager.go +++ b/internal/repository/manager.go @@ -14,6 +14,7 @@ type RepositoryManager struct { Upload *ChunkDAO TransferLog *TransferLogDAO AdminOpLog *AdminOperationLogDAO + UserAPIKey *UserAPIKeyDAO } // NewRepositoryManager 创建新的数据访问管理器 @@ -27,6 +28,7 @@ func NewRepositoryManager(db *gorm.DB) *RepositoryManager { Upload: NewChunkDAO(db), // 别名 TransferLog: NewTransferLogDAO(db), AdminOpLog: NewAdminOperationLogDAO(db), + UserAPIKey: NewUserAPIKeyDAO(db), } } diff --git a/internal/repository/user_api_key.go b/internal/repository/user_api_key.go new file mode 100644 index 0000000..675b658 --- /dev/null +++ b/internal/repository/user_api_key.go @@ -0,0 +1,85 @@ +package repository + +import ( + "time" + + "github.com/zy84338719/filecodebox/internal/models" + "gorm.io/gorm" +) + +// UserAPIKeyDAO 管理用户 API Key 的持久化 +// 所有查询默认只返回未撤销的记录,除非特别说明 + +type UserAPIKeyDAO struct { + db *gorm.DB +} + +// NewUserAPIKeyDAO 创建 DAO +func NewUserAPIKeyDAO(db *gorm.DB) *UserAPIKeyDAO { + return &UserAPIKeyDAO{db: db} +} + +// Create 创建新的 API Key 记录 +func (dao *UserAPIKeyDAO) Create(key *models.UserAPIKey) error { + return dao.db.Create(key).Error +} + +// ListByUser 返回某个用户的所有密钥(包含已撤销),按创建时间倒序 +func (dao *UserAPIKeyDAO) ListByUser(userID uint) ([]models.UserAPIKey, error) { + var keys []models.UserAPIKey + err := dao.db.Where("user_id = ?", userID).Order("created_at DESC").Find(&keys).Error + if err != nil { + return nil, err + } + return keys, nil +} + +// GetActiveByHash 根据哈希获取有效密钥(未撤销且未过期) +func (dao *UserAPIKeyDAO) GetActiveByHash(hash string) (*models.UserAPIKey, error) { + var key models.UserAPIKey + err := dao.db.Where("key_hash = ? AND revoked = ?", hash, false).First(&key).Error + if err != nil { + return nil, err + } + if key.ExpiresAt != nil && key.ExpiresAt.Before(time.Now()) { + return nil, gorm.ErrRecordNotFound + } + return &key, nil +} + +// TouchLastUsed 更新最后使用时间 +func (dao *UserAPIKeyDAO) TouchLastUsed(id uint) error { + now := time.Now() + return dao.db.Model(&models.UserAPIKey{}).Where("id = ?", id).Updates(map[string]interface{}{ + "last_used_at": &now, + "updated_at": now, + }).Error +} + +// RevokeByID 撤销密钥 +func (dao *UserAPIKeyDAO) RevokeByID(userID, id uint) error { + now := time.Now() + res := dao.db.Model(&models.UserAPIKey{}). + Where("id = ? AND user_id = ? AND revoked = ?", id, userID, false). + Updates(map[string]interface{}{ + "revoked": true, + "revoked_at": &now, + "updated_at": now, + }) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +// CountActiveByUser 统计用户有效密钥数量 +func (dao *UserAPIKeyDAO) CountActiveByUser(userID uint) (int64, error) { + var count int64 + err := dao.db.Model(&models.UserAPIKey{}). + Where("user_id = ? AND revoked = ?", userID, false). + Count(&count).Error + return count, err +} diff --git a/internal/routes/chunk.go b/internal/routes/chunk.go index 3cc0fd1..f7be7c8 100644 --- a/internal/routes/chunk.go +++ b/internal/routes/chunk.go @@ -4,6 +4,7 @@ import ( "github.com/zy84338719/filecodebox/internal/config" "github.com/zy84338719/filecodebox/internal/handlers" "github.com/zy84338719/filecodebox/internal/middleware" + "github.com/zy84338719/filecodebox/internal/services" "github.com/gin-gonic/gin" ) @@ -13,10 +14,12 @@ func SetupChunkRoutes( router *gin.Engine, chunkHandler *handlers.ChunkHandler, cfg *config.ConfigManager, + userService *services.UserService, ) { // 分片上传相关路由 chunkGroup := router.Group("/chunk") chunkGroup.Use(middleware.ShareAuth(cfg)) + chunkGroup.Use(middleware.OptionalUserAuth(cfg, userService)) { chunkGroup.POST("/upload/init/", chunkHandler.InitChunkUpload) chunkGroup.POST("/upload/chunk/:upload_id/:chunk_index", chunkHandler.UploadChunk) diff --git a/internal/routes/setup.go b/internal/routes/setup.go index 92ebfe4..a9977f9 100644 --- a/internal/routes/setup.go +++ b/internal/routes/setup.go @@ -288,7 +288,7 @@ func RegisterDynamicRoutes( SetupShareRoutes(router, shareHandler, manager, userService) // Use API-only user routes here to avoid duplicate page route registration SetupUserAPIRoutes(router, userHandler, manager, userService) - SetupChunkRoutes(router, chunkHandler, manager) + SetupChunkRoutes(router, chunkHandler, manager, userService) SetupAdminRoutes(router, adminHandler, storageHandler, manager, userService) // System init routes are no longer needed after DB init } @@ -318,9 +318,7 @@ func SetupAllRoutes( userHandler *handlers.UserHandler, setupHandler *handlers.SetupHandler, manager *config.ConfigManager, - userService interface { - ValidateToken(string) (interface{}, error) - }, + userService *services.UserService, ) { // 设置基础路由 @@ -336,7 +334,7 @@ func SetupAllRoutes( SetupUserRoutes(router, userHandler, manager, userService) // 设置分片上传路由 - SetupChunkRoutes(router, chunkHandler, manager) + SetupChunkRoutes(router, chunkHandler, manager, userService) // 设置管理员路由 SetupAdminRoutes(router, adminHandler, storageHandler, manager, userService) @@ -363,9 +361,7 @@ func SetupRoutes( storageHandler *handlers.StorageHandler, userHandler *handlers.UserHandler, cfg *config.ConfigManager, - userService interface { - ValidateToken(string) (interface{}, error) - }, + userService *services.UserService, ) { // 为兼容性创建一个空的setupHandler setupHandler := &handlers.SetupHandler{} diff --git a/internal/routes/share.go b/internal/routes/share.go index ef25936..9b05e8b 100644 --- a/internal/routes/share.go +++ b/internal/routes/share.go @@ -4,6 +4,7 @@ import ( "github.com/zy84338719/filecodebox/internal/config" "github.com/zy84338719/filecodebox/internal/handlers" "github.com/zy84338719/filecodebox/internal/middleware" + "github.com/zy84338719/filecodebox/internal/services" "github.com/gin-gonic/gin" ) @@ -13,9 +14,7 @@ func SetupShareRoutes( router *gin.Engine, shareHandler *handlers.ShareHandler, cfg *config.ConfigManager, - userService interface { - ValidateToken(string) (interface{}, error) - }, + userService *services.UserService, ) { // 幂等检查:如果 /share/text/ 已注册则跳过(防止重复注册导致 gin panic) for _, r := range router.Routes() { diff --git a/internal/routes/user.go b/internal/routes/user.go index c4a6b43..033807f 100644 --- a/internal/routes/user.go +++ b/internal/routes/user.go @@ -4,6 +4,7 @@ import ( "github.com/zy84338719/filecodebox/internal/config" "github.com/zy84338719/filecodebox/internal/handlers" "github.com/zy84338719/filecodebox/internal/middleware" + "github.com/zy84338719/filecodebox/internal/services" "github.com/zy84338719/filecodebox/internal/static" "github.com/gin-gonic/gin" @@ -14,9 +15,7 @@ func SetupUserRoutes( router *gin.Engine, userHandler *handlers.UserHandler, cfg *config.ConfigManager, - userService interface { - ValidateToken(string) (interface{}, error) - }, + userService *services.UserService, ) { // 注册完整的用户路由(API + 页面) SetupUserAPIRoutes(router, userHandler, cfg, userService) @@ -44,9 +43,7 @@ func SetupUserAPIRoutes( router *gin.Engine, userHandler *handlers.UserHandler, cfg *config.ConfigManager, - userService interface { - ValidateToken(string) (interface{}, error) - }, + userService *services.UserService, ) { // 用户系统路由 userGroup := router.Group("/user") @@ -70,6 +67,9 @@ func SetupUserAPIRoutes( authGroup.GET("/stats", userHandler.GetUserStats) authGroup.GET("/check-auth", userHandler.CheckAuth) authGroup.DELETE("/files/:code", userHandler.DeleteFile) + authGroup.GET("/api-keys", userHandler.ListAPIKeys) + authGroup.POST("/api-keys", userHandler.CreateAPIKey) + authGroup.DELETE("/api-keys/:id", userHandler.DeleteAPIKey) } } } diff --git a/internal/services/services.go b/internal/services/services.go index 2f1bbb3..326439a 100644 --- a/internal/services/services.go +++ b/internal/services/services.go @@ -19,6 +19,7 @@ type AuthService = auth.Service type ChunkService = chunk.Service type ShareService = share.Service type UserService = user.Service +type APIKeyAuthResult = user.APIKeyAuthResult type AdminUserUpdateParams = admin.UserUpdateParams diff --git a/internal/services/user/api_keys.go b/internal/services/user/api_keys.go new file mode 100644 index 0000000..31e8475 --- /dev/null +++ b/internal/services/user/api_keys.go @@ -0,0 +1,145 @@ +package user + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "github.com/zy84338719/filecodebox/internal/models" +) + +const ( + userAPIKeyPrefix = "fcbk_" + userAPIKeyRandomBytes = 32 // 将被编码为64位十六进制字符串 + maxUserAPIKeys = 5 // 每个用户最多保留的有效密钥数量 +) + +// APIKeyAuthResult 表示通过 API Key 验证后的用户信息 +// 在中间件中用于注入用户上下文 + +type APIKeyAuthResult struct { + UserID uint + Username string + Role string + KeyID uint +} + +// GenerateUserAPIKey 为用户生成新的 API Key,并返回明文 Key 及记录 +func (s *Service) GenerateUserAPIKey(userID uint, name string, expiresAt *time.Time) (string, *models.UserAPIKey, error) { + // 限制有效密钥数量,避免滥用 + count, err := s.repositoryManager.UserAPIKey.CountActiveByUser(userID) + if err != nil { + return "", nil, fmt.Errorf("统计用户 API Key 失败: %w", err) + } + if count >= maxUserAPIKeys { + return "", nil, fmt.Errorf("最多只能保留 %d 个有效 API Key,请先删除旧的密钥", maxUserAPIKeys) + } + + // 生成随机 Token + raw, err := s.authService.GenerateRandomToken(userAPIKeyRandomBytes) + if err != nil { + return "", nil, fmt.Errorf("生成随机密钥失败: %w", err) + } + key := userAPIKeyPrefix + raw + hash := hashAPIKey(key) + prefix := keyPrefix(key) + + // 归一化名称 + trimmedName := strings.TrimSpace(name) + if len(trimmedName) > 100 { + trimmedName = trimmedName[:100] + } + + record := &models.UserAPIKey{ + UserID: userID, + Name: trimmedName, + Prefix: prefix, + KeyHash: hash, + ExpiresAt: normalizeExpiry(expiresAt), + } + + if err := s.repositoryManager.UserAPIKey.Create(record); err != nil { + return "", nil, fmt.Errorf("保存 API Key 失败: %w", err) + } + + return key, record, nil +} + +// ListUserAPIKeys 返回用户的全部 API Key(包含已撤销) +func (s *Service) ListUserAPIKeys(userID uint) ([]models.UserAPIKey, error) { + return s.repositoryManager.UserAPIKey.ListByUser(userID) +} + +// RevokeUserAPIKey 撤销指定的 API Key +func (s *Service) RevokeUserAPIKey(userID, id uint) error { + return s.repositoryManager.UserAPIKey.RevokeByID(userID, id) +} + +// AuthenticateAPIKey 校验用户 API Key,返回认证结果 +func (s *Service) AuthenticateAPIKey(key string) (*APIKeyAuthResult, error) { + if key == "" { + return nil, errors.New("api key is empty") + } + + if !strings.HasPrefix(key, userAPIKeyPrefix) { + return nil, errors.New("api key 格式不正确") + } + + hash := hashAPIKey(key) + record, err := s.repositoryManager.UserAPIKey.GetActiveByHash(hash) + if err != nil { + return nil, err + } + + // 检查是否过期 + if record.ExpiresAt != nil && record.ExpiresAt.Before(time.Now()) { + return nil, errors.New("api key 已过期") + } + + user, err := s.repositoryManager.User.GetByID(record.UserID) + if err != nil { + return nil, err + } + if user.Status != "active" { + return nil, errors.New("账号状态异常,无法使用 api key") + } + + // 更新最后使用时间(忽略错误) + _ = s.repositoryManager.UserAPIKey.TouchLastUsed(record.ID) + + return &APIKeyAuthResult{ + UserID: user.ID, + Username: user.Username, + Role: user.Role, + KeyID: record.ID, + }, nil +} + +// hashAPIKey 计算密钥哈希 +func hashAPIKey(key string) string { + sum := sha256.Sum256([]byte(key)) + return hex.EncodeToString(sum[:]) +} + +// keyPrefix 返回展示用前缀 +func keyPrefix(key string) string { + if len(key) <= 12 { + return key + } + return key[:12] +} + +// normalizeExpiry 复制并清理过期时间,确保不会保存过去的时间 +func normalizeExpiry(input *time.Time) *time.Time { + if input == nil { + return nil + } + t := input.UTC() + if t.Before(time.Now().Add(-1 * time.Minute)) { + return nil + } + return &t +} diff --git a/main.go b/main.go index b8b10ba..39b0bf5 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,7 @@ package main // @title FileCodeBox API -// @version 1.9.1 +// @version 1.9.8 // @description FileCodeBox 是一个用于文件分享和代码片段管理的 Web 应用程序 // @termsOfService http://swagger.io/terms/ diff --git a/themes/2025/css/dashboard.css b/themes/2025/css/dashboard.css index fee49aa..563af8a 100644 --- a/themes/2025/css/dashboard.css +++ b/themes/2025/css/dashboard.css @@ -30,7 +30,12 @@ body { margin-bottom: var(--spacing-lg); display: flex; justify-content: space-between; - align-items: center; + margin-bottom: 20px; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md); + flex-wrap: wrap; } .logo { @@ -54,6 +59,19 @@ body { align-items: center; gap: var(--spacing-md); } + .section-actions { + display: flex; + align-items: center; + gap: var(--spacing-sm); + } + + .section-desc { + margin-top: -10px; + margin-bottom: var(--spacing-lg); + color: #6b7280; + font-size: 14px; + line-height: 1.6; + } .header .user-avatar { width: 40px; @@ -169,6 +187,7 @@ body { color: #333; padding-bottom: 10px; border-bottom: 2px solid #667eea; + flex: 1 1 auto; } .section-content { @@ -700,6 +719,177 @@ body { box-shadow: var(--shadow-xl); } +/* API 密钥管理 */ +.api-key-form { + background: white; + border: 1px solid #e5e7eb; + border-radius: var(--radius-lg); + padding: var(--spacing-xl); + box-shadow: var(--shadow-sm); + margin-bottom: var(--spacing-xl); +} + +.api-key-form .form-group small { + display: block; + margin-top: var(--spacing-xs); + color: #9ca3af; + font-size: 13px; +} + +.api-key-form-actions { + display: flex; + align-items: center; + gap: var(--spacing-md); + margin-top: var(--spacing-lg); + flex-wrap: wrap; +} + +.api-key-form-actions .btn { + flex: 0 0 auto; +} + +.api-key-limit { + color: #6b7280; + font-size: 14px; + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + transition: color 0.3s ease, background 0.3s ease; +} + +.api-key-limit.warning { + color: #b45309; + background: #fef3c7; + padding: 4px 10px; + border-radius: var(--radius-sm); + font-weight: 600; +} + +.api-key-result { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: var(--radius-lg); + padding: var(--spacing-xl); + margin-bottom: var(--spacing-xl); + box-shadow: var(--shadow-sm); +} + +.api-key-result-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); +} + +.api-key-result-title { + font-weight: 600; + color: #111827; + font-size: 16px; +} + +.api-key-result-text { + color: #4b5563; + margin-bottom: var(--spacing-sm); + line-height: 1.6; +} + +.api-key-secret { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm); + background: rgba(17, 24, 39, 0.04); + border-radius: var(--radius-sm); + border: 1px dashed #94a3b8; + flex-wrap: wrap; +} + +.api-key-secret code { + font-family: 'SF Mono', 'Cascadia Code', monospace; + font-size: 15px; + color: #1f2937; + flex: 1 1 240px; + word-break: break-all; +} + +.api-key-secret .btn-sm { + white-space: nowrap; +} + +.api-key-result-meta { + margin-top: var(--spacing-sm); + font-size: 13px; + color: #6b7280; + line-height: 1.6; +} + +.api-keys-list { + margin-top: var(--spacing-xl); + overflow-x: auto; +} + +.api-keys-table { + width: 100%; + min-width: 720px; + border-collapse: collapse; + background: white; + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-lg); +} + +.api-keys-table thead { + background: #f9fafb; +} + +.api-keys-table th, +.api-keys-table td { + padding: var(--spacing-md); + text-align: left; + border-bottom: 1px solid #e5e7eb; + font-size: 14px; + color: #374151; +} + +.api-keys-table th { + font-weight: 600; + text-transform: none; + color: #1f2937; + font-size: 13px; +} + +.api-keys-table tbody tr:hover { + background: #f9fafb; +} + +.api-keys-table tbody tr:last-child td { + border-bottom: none; +} + +.api-keys-table .btn-sm { + min-width: 80px; +} + +.api-key-status { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: var(--radius-full); + font-size: 12px; + font-weight: 600; +} + +.api-key-status.active { + background: rgba(34, 197, 94, 0.15); + color: #15803d; +} + +.api-key-status.revoked { + background: rgba(248, 113, 113, 0.18); + color: #b91c1c; +} + /* 管理员按钮控制 */ #admin-btn { display: none; @@ -750,6 +940,34 @@ body { flex-direction: column; gap: var(--spacing-sm); } + + .api-key-form { + padding: var(--spacing-lg); + } + + .api-key-form-actions { + flex-direction: column; + align-items: stretch; + gap: var(--spacing-sm); + } + + .api-key-form-actions .btn { + width: 100%; + } + + .api-key-limit { + justify-content: center; + text-align: center; + } + + .api-key-secret { + flex-direction: column; + align-items: stretch; + } + + .api-key-secret code { + flex: 1 1 auto; + } } /* ===== Migrated overrides from dashboard.html inline styles ===== */ diff --git a/themes/2025/dashboard.html b/themes/2025/dashboard.html index 9826e8b..f93595a 100644 --- a/themes/2025/dashboard.html +++ b/themes/2025/dashboard.html @@ -37,6 +37,7 @@ + @@ -128,6 +129,81 @@ + +
+
+
+
🔑 API 密钥管理
+
+ +
+
+

+ 使用 API 密钥可在命令行或第三方工具中上传 / 下载文件。密钥一旦生成仅在创建时显示明文,请妥善保存。 +

+ +
+
+
+ + + 可选项,建议填写方便识别的备注。 +
+
+ + +
+
+ +
+ + 每个账户最多可保留 5 个有效密钥。 +
+
+ + + +
+ + +
+
+
+
+
diff --git a/themes/2025/js/dashboard.js b/themes/2025/js/dashboard.js index a7c6f12..34b6a5c 100644 --- a/themes/2025/js/dashboard.js +++ b/themes/2025/js/dashboard.js @@ -7,6 +7,12 @@ const Dashboard = { // 分页配置 currentPage: 1, pageSize: 20, + apiKeyLimit: 5, + apiKeyFormInitialized: false, + apiKeysLoaded: false, + apiKeysLoading: false, + apiKeyCache: [], + apiKeyTableClickHandler: null, // Helper: 安全解析 JSON async parseJsonSafe(response) { @@ -78,8 +84,8 @@ const Dashboard = { } // 加载仪表板数据 - this.loadDashboard(); - + await this.loadDashboard(); + // 设置功能模块 this.setupFileUpload(); this.setupForms(); @@ -266,6 +272,10 @@ const Dashboard = { case 'files': this.loadMyFiles(); break; + case 'api-keys': + this.setupAPIKeyForm(); + this.loadAPIKeys(true); + break; case 'profile': this.loadProfile(); break; @@ -644,6 +654,364 @@ const Dashboard = { showNotification('删除失败: ' + error.message, 'error'); } }, + + /** + * 初始化 API Key 表单与相关事件 + */ + setupAPIKeyForm() { + if (this.apiKeyFormInitialized) return; + + const form = document.getElementById('api-key-form'); + if (!form) return; + + const expireTypeSelect = document.getElementById('api-key-expire-type'); + const customFields = document.getElementById('api-key-custom-fields'); + const refreshBtn = document.getElementById('api-key-refresh-btn'); + const closeResultBtn = document.getElementById('api-key-result-close'); + const copyResultBtn = document.getElementById('api-key-result-copy'); + + if (expireTypeSelect) { + expireTypeSelect.addEventListener('change', () => { + this.toggleAPIKeyCustomFields(expireTypeSelect.value === 'custom', customFields); + }); + this.toggleAPIKeyCustomFields(expireTypeSelect.value === 'custom', customFields); + } + + if (refreshBtn) { + refreshBtn.addEventListener('click', () => { + this.loadAPIKeys(true); + }); + } + + if (closeResultBtn) { + closeResultBtn.addEventListener('click', () => { + this.hideAPIKeyResult(); + }); + } + + if (copyResultBtn) { + copyResultBtn.addEventListener('click', () => { + const value = document.getElementById('api-key-result-value')?.textContent || ''; + if (value) { + copyToClipboard(value, copyResultBtn); + } + }); + } + + form.addEventListener('submit', async (event) => { + event.preventDefault(); + await this.createAPIKey(form); + }); + + this.apiKeyFormInitialized = true; + this.bindAPIKeyTableEvents(); + }, + + /** + * 显示/隐藏自定义时间字段 + */ + toggleAPIKeyCustomFields(visible, container) { + if (!container) return; + container.style.display = visible ? 'flex' : 'none'; + if (!visible) { + const daysInput = document.getElementById('api-key-expire-days'); + const atInput = document.getElementById('api-key-expire-at'); + if (daysInput) daysInput.value = ''; + if (atInput) atInput.value = ''; + } + }, + + /** + * 创建新的 API Key + */ + async createAPIKey(form) { + const submitBtn = form.querySelector('button[type="submit"]'); + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.textContent = '生成中...'; + } + + const nameInput = document.getElementById('api-key-name'); + const expireType = document.getElementById('api-key-expire-type'); + const customDays = document.getElementById('api-key-expire-days'); + const customDate = document.getElementById('api-key-expire-at'); + + const payload = {}; + const name = nameInput ? nameInput.value.trim() : ''; + if (name) { + payload.name = name; + } + + const expireValue = expireType ? expireType.value : 'forever'; + try { + if (expireValue === 'forever') { + // 不设置任何过期字段 + } else if (expireValue === 'custom') { + const daysValue = customDays ? parseInt(customDays.value, 10) : NaN; + const dateValue = customDate ? customDate.value.trim() : ''; + + if (!dateValue && (isNaN(daysValue) || daysValue <= 0)) { + showNotification('请设置自定义有效期天数或日期', 'error'); + return; + } + + if (!isNaN(daysValue) && daysValue > 0) { + payload.expires_in_days = daysValue; + } + + if (dateValue) { + const parsed = new Date(dateValue); + if (Number.isNaN(parsed.getTime())) { + showNotification('自定义到期时间格式有误', 'error'); + return; + } + payload.expires_at = parsed.toISOString(); + } + } else { + const presetDays = parseInt(expireValue, 10); + if (!Number.isNaN(presetDays) && presetDays > 0) { + payload.expires_in_days = presetDays; + } + } + + const response = await fetch('/user/api-keys', { + method: 'POST', + headers: UserAuth.getAuthHeaders(), + body: JSON.stringify(payload) + }); + const result = await this.parseJsonSafe(response); + if (this.handleAuthError(result)) return; + + if (result && result.code === 200 && result.data) { + showNotification(result.message || 'API Key 创建成功', 'success'); + this.showAPIKeyResult(result.data); + form.reset(); + const expireTypeSelect = document.getElementById('api-key-expire-type'); + if (expireTypeSelect) { + expireTypeSelect.value = '30'; + this.toggleAPIKeyCustomFields(false, document.getElementById('api-key-custom-fields')); + } + this.apiKeysLoaded = false; + await this.loadAPIKeys(true); + } else { + const message = result && result.message ? result.message : 'API Key 创建失败'; + showNotification(message, 'error'); + } + } catch (error) { + console.error('创建 API Key 失败:', error); + showNotification('创建失败: ' + error.message, 'error'); + } finally { + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.textContent = '生成新的 API 密钥'; + } + } + }, + + /** + * 加载 API Key 列表 + */ + async loadAPIKeys(force = false) { + if (this.apiKeysLoading) return; + if (!force && this.apiKeysLoaded) return; + + const loadingEl = document.getElementById('api-key-loading'); + const emptyEl = document.getElementById('api-key-empty'); + const wrapper = document.getElementById('api-key-table-wrapper'); + + if (loadingEl) loadingEl.style.display = 'block'; + if (emptyEl) emptyEl.style.display = 'none'; + if (wrapper) wrapper.innerHTML = ''; + + this.apiKeysLoading = true; + + try { + const response = await fetch('/user/api-keys', { + headers: UserAuth.getAuthHeaders() + }); + const result = await this.parseJsonSafe(response); + if (this.handleAuthError(result)) return; + + if (result && result.code === 200 && result.data) { + const keys = result.data.keys || []; + this.apiKeyCache = keys; + this.apiKeysLoaded = true; + this.renderAPIKeys(keys); + } else { + const message = result && result.message ? result.message : '获取 API Key 列表失败'; + showNotification(message, 'error'); + } + } catch (error) { + console.error('加载 API Key 列表失败:', error); + showNotification('加载失败: ' + error.message, 'error'); + } finally { + this.apiKeysLoading = false; + if (loadingEl) loadingEl.style.display = 'none'; + } + }, + + /** + * 渲染 API Key 列表 + */ + renderAPIKeys(keys) { + const emptyEl = document.getElementById('api-key-empty'); + const wrapper = document.getElementById('api-key-table-wrapper'); + const limitNote = document.getElementById('api-key-limit-note'); + const activeCount = Array.isArray(keys) ? keys.filter(item => !item.revoked).length : 0; + + if (limitNote) { + limitNote.textContent = `已使用 ${activeCount}/${this.apiKeyLimit} 个有效密钥。`; + limitNote.classList.toggle('warning', activeCount >= this.apiKeyLimit); + } + + if (!wrapper) return; + + if (!keys || keys.length === 0) { + if (emptyEl) emptyEl.style.display = 'block'; + wrapper.innerHTML = ''; + return; + } + + if (emptyEl) emptyEl.style.display = 'none'; + + const rowsHTML = keys.map(key => this.renderAPIKeyRow(key)).join(''); + wrapper.innerHTML = ` + + + + + + + + + + + + + + ${rowsHTML} + +
备注名称密钥前缀状态最后使用到期时间创建时间操作
+ `; + + this.bindAPIKeyTableEvents(); + }, + + /** + * 生成单行 API Key HTML + */ + renderAPIKeyRow(key) { + const status = key.revoked + ? '已撤销' + : '生效中'; + const lastUsed = key.last_used_at ? formatDateTime(key.last_used_at) : '从未使用'; + const expiresAt = key.expires_at ? formatDateTime(key.expires_at) : '长期有效'; + const createdAt = key.created_at ? formatDateTime(key.created_at) : '-'; + const name = key.name ? escapeHtml(key.name) : '未命名密钥'; + const prefix = key.prefix ? escapeHtml(key.prefix) + '…' : '***'; + + const actionBtn = key.revoked + ? '' + : ``; + + return ` + + ${name} + ${prefix} + ${status} + ${lastUsed} + ${expiresAt} + ${createdAt} + ${actionBtn} + + `; + }, + + /** + * 绑定 API Key 列表操作事件 + */ + bindAPIKeyTableEvents() { + const wrapper = document.getElementById('api-key-table-wrapper'); + if (!wrapper) return; + + wrapper.removeEventListener('click', this.apiKeyTableClickHandler); + + this.apiKeyTableClickHandler = async (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + + const action = target.dataset.action; + const id = target.dataset.id; + if (action === 'revoke' && id) { + await this.revokeAPIKey(parseInt(id, 10)); + } + }; + + wrapper.addEventListener('click', this.apiKeyTableClickHandler); + }, + + /** + * 撤销 API Key + */ + async revokeAPIKey(id) { + if (!Number.isInteger(id)) return; + if (!confirm('确定要撤销该 API 密钥吗?撤销后将无法恢复。')) { + return; + } + + try { + const response = await fetch(`/user/api-keys/${id}`, { + method: 'DELETE', + headers: UserAuth.getAuthHeaders() + }); + const result = await this.parseJsonSafe(response); + if (this.handleAuthError(result)) return; + + if (result && result.code === 200) { + showNotification('API 密钥已撤销', 'success'); + this.apiKeysLoaded = false; + await this.loadAPIKeys(true); + } else { + const message = result && result.message ? result.message : '撤销失败'; + showNotification(message, 'error'); + } + } catch (error) { + console.error('撤销 API Key 失败:', error); + showNotification('撤销失败: ' + error.message, 'error'); + } + }, + + /** + * 显示新生成的 API Key + */ + showAPIKeyResult(data) { + const container = document.getElementById('api-key-result'); + const valueEl = document.getElementById('api-key-result-value'); + const metaEl = document.getElementById('api-key-result-meta'); + if (!container || !valueEl || !metaEl) return; + + const key = data.key || ''; + const info = data.api_key || {}; + + valueEl.textContent = key; + + const expireText = info.expires_at ? `到期时间:${formatDateTime(info.expires_at)}` : '长期有效'; + const createdText = info.created_at ? `创建时间:${formatDateTime(info.created_at)}` : ''; + const nameText = info.name ? `备注:${escapeHtml(info.name)}` : ''; + + metaEl.innerHTML = [nameText, expireText, createdText].filter(Boolean).map(item => `
${item}
`).join(''); + + container.style.display = 'block'; + }, + + /** + * 隐藏 API Key 结果面板 + */ + hideAPIKeyResult() { + const container = document.getElementById('api-key-result'); + if (!container) return; + container.style.display = 'none'; + }, /** * 设置文件上传 @@ -777,6 +1145,7 @@ const Dashboard = { this.setupUploadForm(); this.setupProfileForm(); this.setupPasswordForm(); + this.setupAPIKeyForm(); }, /**