mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e93131ab46 | |||
| 161e6c4e86 | |||
| 3dbc7b3045 | |||
| 4a4189705a | |||
| 6aa71a4da8 | |||
| bdc96f6d8e | |||
| 1c1063f448 | |||
| 29fdc378a1 | |||
| bd659d493d | |||
| 6a2a6028c3 | |||
| 6e85f1158b | |||
| e117e314d9 | |||
| fbb0909638 | |||
| 3eecd31868 |
@@ -2,11 +2,27 @@
|
||||
|
||||
本文件是 OpenFlare 的 AI 接手入口,不承载详细设计、规范和计划。接手项目时,请根据以下分层文档指引进行阅读与开发:
|
||||
|
||||
## 1. 核心必读文档(Level 3 & Level 4)- 必须阅读 ⚠️
|
||||
|
||||
### 面向 AI 的开发指导规范 (AI Guidelines) 必须阅读
|
||||
|
||||
为了理解 OpenFlare 的设计理念、产品边界、核心机制以及代码编写的工程约束,**AI 在接手项目时必须首先且完整阅读以下文档**:
|
||||
|
||||
### Level 3: 面向贡献者的参阅文档 (Contributor References)
|
||||
* **[docs/guildline/development-constraints.md](./docs/guildline/development-constraints.md)**
|
||||
*作用:掌握核心后端/Agent/前端分层约束、数据模型规范、数据库迁移升级协议、API 与鉴权设计准则。*
|
||||
* **[docs/guildline/Guidelines.md](./docs/guildline/Guidelines.md)**
|
||||
*作用:通用的 Go 后端开发与高质量编码准则,包括架构、并发、错误处理、安全及工作流程。*
|
||||
* **[docs/guildline/Project.md](./docs/guildline/Project.md)**
|
||||
*作用:针对 OpenFlare 后端特定的控制器参数解析、响应处理、纯净工具类与数据库逻辑完全隔离、Go 泛型切片去重及 JSON 序列化避坑细则。*
|
||||
|
||||
### 系统参阅文档 按需查阅
|
||||
* **[docs/reference/configuration.md](./docs/reference/configuration.md)**
|
||||
*作用:系统启动时支持的所有环境变量、命令行参数、运行时 Option 选项和 Agent 配置文件字段。*
|
||||
* **[docs/reference/cli.md](./docs/reference/cli.md)**
|
||||
*作用:Server 与 Agent 可用的命令行参数、安装/卸载脚本参数等参考。*
|
||||
* **[docs/reference/api.md](./docs/reference/api.md)**
|
||||
*作用:管理端 API 与 Agent API 的响应结构、路径和详细鉴权约定。*
|
||||
|
||||
### 面向开发者的文档 按需查阅
|
||||
* **[docs/design/index.md](./docs/design/index.md)**
|
||||
*作用:理解当前 MVP 的产品范围、系统边界、核心对象和长期约束。*
|
||||
* **[docs/design/architecture.md](./docs/design/architecture.md)**
|
||||
@@ -18,29 +34,7 @@
|
||||
* **[docs/design/repository.md](./docs/design/repository.md)**
|
||||
*作用:熟悉仓库的整体物理结构和各子目录的职责。*
|
||||
|
||||
### Level 4: 面向 AI 的开发指导规范 (AI Guidelines)
|
||||
* **[docs/guildline/development-constraints.md](./docs/guildline/development-constraints.md)**
|
||||
*作用:掌握核心后端/Agent/前端分层约束、数据模型规范、数据库迁移升级协议、API 与鉴权设计准则。*
|
||||
* **[docs/guildline/Guidelines.md](./docs/guildline/Guidelines.md)**
|
||||
*作用:通用的 Go 后端开发与高质量编码准则,包括架构、并发、错误处理、安全及工作流程。*
|
||||
* **[docs/guildline/Project.md](./docs/guildline/Project.md)**
|
||||
*作用:针对 OpenFlare 后端特定的控制器参数解析、响应处理、纯净工具类与数据库逻辑完全隔离、Go 泛型切片去重及 JSON 序列化避坑细则。*
|
||||
|
||||
---
|
||||
|
||||
## 2. 按需查阅文档(Level 2)- 根据需求阅读 💡
|
||||
|
||||
当开发任务涉及具体的系统部署、升级、接口联调或配置字段查阅时,**AI 应当根据需求阅读相应的参考手册**:
|
||||
|
||||
### Level 2: 面对高级用户/开发者的参阅文档 (Reference Manuals)
|
||||
* **[docs/reference/configuration.md](./docs/reference/configuration.md)**
|
||||
*作用:系统启动时支持的所有环境变量、命令行参数、运行时 Option 选项和 Agent 配置文件字段。*
|
||||
* **[docs/reference/cli.md](./docs/reference/cli.md)**
|
||||
*作用:Server 与 Agent 可用的命令行参数、安装/卸载脚本参数等参考。*
|
||||
* **[docs/reference/api.md](./docs/reference/api.md)**
|
||||
*作用:管理端 API 与 Agent API 的响应结构、路径和详细鉴权约定。*
|
||||
|
||||
### Level 2: 部署与升级指南 (Deployment Guides)
|
||||
### 部署与升级指南
|
||||
* **[docs/deployment/deployment.md](./docs/deployment/deployment.md)**
|
||||
*作用:理解 Server 和 Agent 的单机、Docker 部署配置,以及 Agent 接入、升级、卸载和联调步骤。*
|
||||
* **[docs/deployment/server.md](./docs/deployment/server.md)**
|
||||
@@ -52,19 +46,6 @@
|
||||
|
||||
---
|
||||
|
||||
## 3. 新手与业务教程(Level 1)- 体验与排障参考 📘
|
||||
|
||||
如果任务涉及优化最终用户体验、丰富业务能力或排查常见故障,可参阅面向普通用户的指南:
|
||||
|
||||
### Level 1: 面向新手用户的教程文档 (Novice Tutorials)
|
||||
* **[docs/guide/quick-start.md](./docs/guide/quick-start.md)**:五分钟内基于 Docker Compose 快速跑起 Server 和首个 Agent 节点的完整闭环。
|
||||
* **[docs/guide/usage.md](./docs/guide/usage.md)**:反向代理网站、源站、证书托管、配置发布与回滚的常规界面操作与观测功能使用指南。
|
||||
* **[docs/guide/sso.md](./docs/guide/sso.md)**:系统如何配置 GitHub OAuth 及标准 OIDC 第三方登录,以及绑定本地账户的流程。
|
||||
* **[docs/guide/first-site.md](./docs/guide/first-site.md)**:从零开始配置、发布并验证第一个代理网站的完整步骤。
|
||||
* **[docs/guide/troubleshooting.md](./docs/guide/troubleshooting.md)**:常见数据库迁移、节点离线、OpenResty 校验失败、SSL 证书失效等故障的表现症状及标准排障路径。
|
||||
|
||||
---
|
||||
|
||||
## 执行要求
|
||||
|
||||
* 如果实现内容超出 [产品边界](./docs/design/index.md),先修改设计文档,再继续编码。
|
||||
@@ -85,5 +66,5 @@
|
||||
* 产品启动、部署、升级、联调方式变化:更新 `docs/guide/quick-start.md`、`docs/deployment/deployment.md` 和 `README.md`
|
||||
* 用户操作路径、常见场景变化:更新 `docs/guide/usage.md`
|
||||
* 本地开发、测试、构建方式变化:更新 `docs/design/development.md`
|
||||
* 常见故障、排查路径变化:更新 `docs/guide/troubleshooting.md`
|
||||
* 环境变量、命令行参数、运行时配置、Agent 配置变化:更新 `docs/reference/configuration.md`
|
||||
* **任何代码、配置或文档变更完成后:必须在 [`docs/changelog/index.md`](./docs/changelog/index.md) 的 `[Unreleased]` 区块补充对应条目(新增 / 变更 / 修复),格式遵循文件内已有模板。**
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
---
|
||||
sidebar: false
|
||||
---
|
||||
|
||||
# 更新日志
|
||||
|
||||
本文件记录 OpenFlare 每个版本的重要变更。
|
||||
|
||||
格式基于 [Keep a Changelog](http://keepachangelog.com/),版本号遵循 [语义化版本](http://semver.org/)。
|
||||
|
||||
## 重大变更
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 2.3.2 开始使用 JWT_SECRET 环境变量替代 SESSION_SECRET 进行管理端 API 的 JWT 签名密钥管理。SESSION_SECRET 将会在之后的版本中逐步废弃,请务必尽快迁移到 JWT_SECRET。
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 说明
|
||||
|
||||
### 新增
|
||||
|
||||
### 变更
|
||||
|
||||
---
|
||||
|
||||
## [v2.3.2] - 2026-06-04
|
||||
|
||||
### 说明
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 2.3.2 开始使用 JWT_SECRET 环境变量替代 SESSION_SECRET 进行管理端 API 的 JWT 签名密钥管理。SESSION_SECRET 将会在之后的版本中逐步废弃,请务必尽快迁移到 JWT_SECRET。
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增 `JWT_SECRET` 环境变量,专用于管理端 API JWT 签名密钥;生产环境必须显式配置
|
||||
- 新增 VitePress 更新日志页面(`docs/changelog/index.md`),记录所有版本变更历史
|
||||
|
||||
### 变更
|
||||
|
||||
- 管理端 API 鉴权框架迁移至 `gin-jwt`
|
||||
- 认证方式变更为 Headers 认证.
|
||||
- `JWT_SECRET` 优先于 `SESSION_SECRET` 用于 JWT 签名;未配置时回退到 `SESSION_SECRET`,向下兼容
|
||||
- 屏蔽手动升级入口(`/api/update/manual-upload`、`/api/update/manual-upgrade`),前端隐藏对应 UI 组件
|
||||
|
||||
---
|
||||
|
||||
## [v2.3.1] - 2026-06-03
|
||||
|
||||
### 变更
|
||||
|
||||
- 屏蔽手动升级入口,前端隐藏对应 UI 组件
|
||||
- POW 与 WAF 规则合并, 统一逻辑处理
|
||||
|
||||
---
|
||||
|
||||
## [v2.3.0] - 2026-06-03
|
||||
|
||||
### 新增
|
||||
|
||||
- WAF IP 组支持订阅模式,可从远程文本或 JSON 源定时同步
|
||||
- 新增 Pages 静态站点托管,支持 SPA fallback 路由配置
|
||||
- Agent 实现 WebSocket 实时推送,Server 发布配置后立即通知在线 Agent
|
||||
|
||||
### 变更
|
||||
|
||||
- Agent 数据面与 OpenResty 合并为集成镜像部署方式
|
||||
- 访问日志与观测数据支持数据库分片,按 ID 分片替代原有逻辑
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.8] - 2026-06-03
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复多域名部署场景下跨域认证绕过安全漏洞
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.6] - 2026-06-02
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增 Uptime Kuma 集成,支持自动同步监控任务
|
||||
- WAF 新增 PoW(工作量证明)防护能力,可配置有效期
|
||||
|
||||
### 变更
|
||||
|
||||
- 内网穿透支持 TunnelRelay 中继节点(frps),新增 OpenFlared 客户端(frpc)
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.5] - 2026-06-02
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增 WAF 自动 IP 组,支持基于 Expr 规则定时聚合请求日志更新名单
|
||||
- WAF IP 组黑白名单支持直接引用 IP 组对象
|
||||
|
||||
### 变更
|
||||
|
||||
- WAF 规则组与网站解耦,支持全局规则组和自定义规则组独立管理
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.4] - 2026-06-02
|
||||
|
||||
### 新增
|
||||
|
||||
- WAF 规则组新增拦截返回配置 Tab
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 WAF 配置发布后部分规则不生效的问题
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.3] - 2026-06-02
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增 WAF 安全防护模块,支持 IP 黑白名单和地域拦截规则
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.2] - 2026-06-01
|
||||
|
||||
### 变更
|
||||
|
||||
- 观测数据支持按时间窗口自动清理,新增数据库自动清理调度器
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.1] - 2026-06-01
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复仪表板概览数据压缩与规范化问题
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.0] - 2026-06-01
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增 TLS 证书转换为 ACME 托管证书的接口(`/convert-acme`)
|
||||
- 新增 ACME 账号与 DNS 账号管理页面
|
||||
- 支持 Let's Encrypt 自动申请与续期
|
||||
|
||||
---
|
||||
|
||||
## [v2.1.1] - 2026-06-01
|
||||
|
||||
### 变更
|
||||
|
||||
- Agent 架构调整,采用集成镜像方式内置 OpenResty
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.3] - 2026-05-31
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复版本号生成逻辑,确保使用当日最大序列号
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.1] - 2026-05-30
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 GitHub 登录逻辑异常
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.0] - 2026-05-30
|
||||
|
||||
### 新增
|
||||
|
||||
- 全面重构发布模型,引入配置版本不可变快照机制
|
||||
- 支持配置版本回滚(重新激活旧版本)
|
||||
- 新增 `source_config_json` 与 `support_files` 供 Agent 获取完整配置包
|
||||
- 新增节点专属 Agent Token 与 Discovery Token 双轨鉴权
|
||||
|
||||
### 变更
|
||||
|
||||
- 数据库迁移框架切换至 goose,统一管理版本升级步骤
|
||||
- Agent API 与管理端 API 鉴权完全分离
|
||||
|
||||
---
|
||||
|
||||
## [v1.9.3] - 2026-05-30
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复节点 IP 自动探测逻辑,优先使用公网地址
|
||||
|
||||
---
|
||||
|
||||
## [v1.9.2] - 2026-05-29
|
||||
|
||||
### 变更
|
||||
|
||||
- Agent 心跳超时后自动退回 HTTP 轮询模式
|
||||
|
||||
---
|
||||
|
||||
## [v1.9.1] - 2026-05-29
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 Agent WebSocket 升级失败时的重连逻辑
|
||||
|
||||
---
|
||||
|
||||
## [v1.9.0] - 2026-05-29
|
||||
|
||||
### 新增
|
||||
|
||||
- Agent 支持 WebSocket 长连接,Server 发布后实时推送配置变更
|
||||
|
||||
---
|
||||
|
||||
## [v1.8.0] - 2026-05-26
|
||||
|
||||
### 新增
|
||||
|
||||
- 支持自定义 DNS 解析器(`OpenRestyResolvers`)
|
||||
- 新增历史配置快照清理功能
|
||||
|
||||
### 变更
|
||||
|
||||
- CORS 配置支持动态源与凭证
|
||||
- 上游统一渲染为命名 `upstream` 并启用 keepalive
|
||||
|
||||
---
|
||||
|
||||
## [v1.7.0] - 2026-05-25
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增 ACME 和 DNS 账号管理功能,支持证书申请与续期
|
||||
|
||||
### 变更
|
||||
|
||||
- 移除新用户注册功能
|
||||
- 更新 Go 版本要求至 1.25+
|
||||
|
||||
---
|
||||
|
||||
## [v1.6.1] - 2026-05-13
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复个人设置页无法查看第三方认证源及解绑功能
|
||||
|
||||
---
|
||||
|
||||
## [v1.6.0] - 2026-05-13
|
||||
|
||||
### 新增
|
||||
|
||||
- 支持 OIDC 单点登录(SSO)
|
||||
|
||||
---
|
||||
|
||||
## [v1.5.0] - 2026-04-25
|
||||
|
||||
### 新增
|
||||
|
||||
- 集成 PoW(Anubis)防护,支持有效期配置
|
||||
|
||||
---
|
||||
|
||||
## [v1.4.0] - 2026-04-01
|
||||
|
||||
### 新增
|
||||
|
||||
- 支持域名级别独立绑定 TLS 证书,每个域名可单独选择证书
|
||||
- 新增批量更新配置项接口
|
||||
- 新增 Agent 卸载脚本
|
||||
|
||||
### 变更
|
||||
|
||||
- 禁用新用户自助注册
|
||||
- 默认服务器块新增 HTTPS 握手拒绝支持
|
||||
|
||||
---
|
||||
|
||||
## [v1.3.2] - 2026-03-30
|
||||
|
||||
### 新增
|
||||
|
||||
- 网站配置支持多域名绑定与共享设置
|
||||
- 新增抽屉式规则创建组件
|
||||
|
||||
---
|
||||
|
||||
## [v1.3.1] - 2026-03-20
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增源站管理功能,支持源站创建、更新与删除
|
||||
|
||||
### 变更
|
||||
|
||||
- 重构代理路由页面,优化输入组件与样式
|
||||
|
||||
---
|
||||
|
||||
## [v1.3.0] - 2026-03-19
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增数据库观测数据手动和自动清理策略
|
||||
- 节点访问日志支持数据库分片,按 ID 分片
|
||||
|
||||
### 变更
|
||||
|
||||
- 数据库版本管理与迁移逻辑重构
|
||||
|
||||
---
|
||||
|
||||
## [v1.2.0] - 2026-03-19
|
||||
|
||||
### 新增
|
||||
|
||||
- 支持多上游地址负载均衡
|
||||
- 新增缓存策略配置(路径前缀、精确路径)
|
||||
- 节点健康事件清理功能
|
||||
|
||||
### 变更
|
||||
|
||||
- 上游渲染改为命名 upstream 并启用 keepalive
|
||||
- 更新 HTTPS 配置,启用 reuseport 与 epoll 事件模型
|
||||
|
||||
---
|
||||
|
||||
## [v1.1.2] - 2026-03-18
|
||||
|
||||
### 变更
|
||||
|
||||
- HTTPS 启用 HTTP/2 支持
|
||||
|
||||
---
|
||||
|
||||
## [v1.1.1] - 2026-03-18
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增获取配置版本详情 API
|
||||
|
||||
### 变更
|
||||
|
||||
- 仪表板概览数据结构优化,添加压缩与规范化
|
||||
|
||||
---
|
||||
|
||||
## [v1.1.0] - 2026-03-18
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增应用日志分页查询与清理功能
|
||||
- 新增访问日志 IP 汇总与趋势查询
|
||||
- 新增 OpenResty DNS 解析器指令支持
|
||||
- Docker 部署支持在运行中容器内执行 reload
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复应用结果警告逻辑
|
||||
- Lua 和证书文件管理重构,优化文件同步与清理机制
|
||||
|
||||
---
|
||||
|
||||
## [v1.0.2] - 2026-03-17
|
||||
|
||||
### 新增
|
||||
|
||||
- 支持 PostgreSQL 数据库,添加数据库迁移逻辑
|
||||
- 新增 Docker Compose 配置,支持 PostgreSQL 联动部署
|
||||
|
||||
### 变更
|
||||
|
||||
- 多个管理端 API 请求方法从 PUT/DELETE 统一改为 POST
|
||||
|
||||
---
|
||||
|
||||
## [v1.0.1] - 2026-03-16
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增 `origin_host` 字段,支持覆盖回源请求的 Host 头
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复代理配置中 SSL 服务器名称和主机头覆盖逻辑
|
||||
|
||||
---
|
||||
|
||||
## [v1.0.0] - 2026-03-15
|
||||
|
||||
OpenFlare 首个正式版本发布。
|
||||
|
||||
### 新增
|
||||
|
||||
- 管理端 UI、管理 API、Agent API 基础功能
|
||||
- 反向代理配置管理与 OpenResty 配置渲染
|
||||
- 配置版本发布与 Agent 同步
|
||||
- TLS 证书导入与管理
|
||||
- 节点注册、心跳与状态观测
|
||||
- SQLite 数据库支持
|
||||
+4
-2
@@ -10,7 +10,8 @@ export default defineAdditionalConfig({
|
||||
sidebar: {
|
||||
'/guide/': { base: '/guide/', items: sidebarGuide() },
|
||||
'/reference/': { base: '/reference/', items: sidebarReference() },
|
||||
'/design/': { base: '/design/', items: sidebarDesign() }
|
||||
'/design/': { base: '/design/', items: sidebarDesign() },
|
||||
'/changelog/': false
|
||||
},
|
||||
|
||||
editLink: {
|
||||
@@ -57,7 +58,8 @@ function nav(): DefaultTheme.NavItem[] {
|
||||
return [
|
||||
{ text: '指南', link: '/guide/', activeMatch: '/guide/' },
|
||||
{ text: '参考', link: '/reference/', activeMatch: '/reference/' },
|
||||
{ text: '设计', link: '/design/', activeMatch: '/design/' }
|
||||
{ text: '设计', link: '/design/', activeMatch: '/design/' },
|
||||
{ text: '更新日志', link: '/changelog/', activeMatch: '/changelog/' }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
你会学到:OpenFlare 的推荐部署方式、Server 与 Agent 的运行要求、源码启动方式、联调步骤、升级与卸载入口。
|
||||
|
||||
生产环境建议使用 PostgreSQL 作为 Server 数据库,并为 Server 显式配置 `SESSION_SECRET`。Agent 部署方式推荐为 Docker 部署(即直接使用内置 OpenResty 的 Agent 镜像);亦支持通过安装脚本或手动本地运行。
|
||||
生产环境建议使用 PostgreSQL 作为 Server 数据库,并为 Server 显式配置 `JWT_SECRET`。Agent 部署方式推荐为 Docker 部署(即直接使用内置 OpenResty 的 Agent 镜像);亦支持通过安装脚本或手动本地运行。
|
||||
|
||||
## 部署拓扑
|
||||
|
||||
@@ -107,7 +107,7 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
SESSION_SECRET: replace-with-a-long-random-string
|
||||
JWT_SECRET: replace-with-a-long-random-string
|
||||
DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable
|
||||
GIN_MODE: release
|
||||
LOG_LEVEL: info
|
||||
@@ -144,7 +144,7 @@ pnpm build
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='replace-with-a-long-random-string'
|
||||
export JWT_SECRET='replace-with-a-long-random-string'
|
||||
export SQLITE_PATH='./openflare.db'
|
||||
export LOG_LEVEL='info'
|
||||
# 可选:设置后优先使用 PostgreSQL。
|
||||
|
||||
@@ -13,7 +13,7 @@ OpenFlare Server 是 Gin + GORM 单体控制面,负责管理端 UI、管理 AP
|
||||
| pnpm | 推荐通过 `corepack enable` 使用项目声明的 pnpm |
|
||||
| 数据库 | SQLite 文件目录可写,或可访问的 PostgreSQL 实例 |
|
||||
|
||||
生产环境建议显式配置 `SESSION_SECRET`,并优先使用 PostgreSQL。
|
||||
生产环境必须显式配置 `JWT_SECRET`,并优先使用 PostgreSQL。
|
||||
|
||||
## 构建管理端前端
|
||||
|
||||
@@ -38,7 +38,7 @@ pnpm test
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='replace-with-a-long-random-string'
|
||||
export JWT_SECRET='replace-with-a-long-random-string'
|
||||
export SQLITE_PATH='./openflare.db'
|
||||
export LOG_LEVEL='info'
|
||||
go run .
|
||||
@@ -54,7 +54,7 @@ http://localhost:3000
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='replace-with-a-long-random-string'
|
||||
export JWT_SECRET='replace-with-a-long-random-string'
|
||||
export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
|
||||
export LOG_LEVEL='info'
|
||||
go run .
|
||||
@@ -81,7 +81,7 @@ docker run -d \
|
||||
--name openflare-server \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/openflare-data:/data \
|
||||
-e SESSION_SECRET='replace-with-a-long-random-string' \
|
||||
-e JWT_SECRET='replace-with-a-long-random-string' \
|
||||
-e SQLITE_PATH='/data/openflare.db' \
|
||||
-e GIN_MODE='release' \
|
||||
-e LOG_LEVEL='info' \
|
||||
@@ -91,7 +91,7 @@ docker run -d \
|
||||
启动参数说明:
|
||||
* **`-p 3000:3000`**:映射宿主机 `3000` 端口到容器内 `3000` 端口。
|
||||
* **`-v $(pwd)/openflare-data:/data`**:挂载本地目录到容器的 `/data`,确保数据库文件 `openflare.db` 在重启或重建容器时不丢失。
|
||||
* **`SESSION_SECRET`**:必须配置的 Session 密钥签名哈希。
|
||||
* **`JWT_SECRET`**:管理端 API 登录令牌的 JWT 签名密钥,生产环境必须配置,避免重启后已登录令牌全部失效。
|
||||
|
||||
---
|
||||
|
||||
@@ -127,7 +127,7 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
SESSION_SECRET: replace-with-random-string
|
||||
JWT_SECRET: replace-with-random-string
|
||||
SQLITE_PATH: /data/openflare.db
|
||||
DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable
|
||||
GIN_MODE: release
|
||||
|
||||
@@ -79,7 +79,7 @@ Agent 本地 Pages 部署目录
|
||||
|
||||
* Gin 提供 HTTP 服务。
|
||||
* GORM 访问 SQLite 或 PostgreSQL。
|
||||
* 现有登录体系提供管理端 Session。
|
||||
* 现有登录体系签发管理端用户 Token,管理端 API 通过 `OPENFLARE_TOKEN` 请求头鉴权。
|
||||
* 认证源与外部账号绑定支持 GitHub OAuth 和标准 OIDC。
|
||||
* Go Server 托管 `openflare_server/web` 静态构建产物。
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ SQLite 模式:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='dev-session-secret'
|
||||
export JWT_SECRET='dev-jwt-secret'
|
||||
export SQLITE_PATH='./openflare-dev.db'
|
||||
export LOG_LEVEL='debug'
|
||||
go run .
|
||||
@@ -49,7 +49,7 @@ PostgreSQL 模式:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='dev-session-secret'
|
||||
export JWT_SECRET='dev-jwt-secret'
|
||||
export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
|
||||
export LOG_LEVEL='debug'
|
||||
go run .
|
||||
|
||||
@@ -54,7 +54,7 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
SESSION_SECRET: replace-with-a-long-random-string
|
||||
JWT_SECRET: replace-with-a-long-random-string
|
||||
DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable
|
||||
GIN_MODE: release
|
||||
LOG_LEVEL: info
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| 现象 | 先看哪里 |
|
||||
| --- | --- |
|
||||
| 管理端打不开 | Server 容器或进程日志、端口监听 |
|
||||
| 登录异常 | 默认账号、Session Secret、浏览器请求、Server 日志 |
|
||||
| 登录异常 | 默认账号、OPENFLARE_TOKEN、浏览器请求、Server 日志 |
|
||||
| 数据无法保存 | 数据库连接、SQLite 文件权限、PostgreSQL 健康状态 |
|
||||
| Agent 离线 | Agent 日志、Token、Server 地址、网络连通性 |
|
||||
| 发布后节点未更新 | 激活版本、节点 heartbeat、应用记录 |
|
||||
@@ -85,8 +85,8 @@ NEXT_DEV_BACKEND_URL=http://127.0.0.1:3000 pnpm dev
|
||||
|
||||
1. 确认连接的是预期数据库,避免 `SQLITE_PATH` 或 `DSN` 指向了另一个环境。
|
||||
2. 查看 Server 日志中使用的是 `sqlite` 还是 `postgres`。
|
||||
3. 如果部署在多副本或反向代理后,确认 `SESSION_SECRET` 固定且各实例一致。
|
||||
4. 清理浏览器 Cookie 后重新登录。
|
||||
3. 在浏览器开发者工具中确认管理端 API 请求携带 `OPENFLARE_TOKEN` 请求头。
|
||||
4. 清理浏览器本地存储中的旧 `openflare_token` 后重新登录。
|
||||
|
||||
### 应急重置管理员密码
|
||||
|
||||
|
||||
+1
-1
@@ -164,7 +164,7 @@ WAF 规则组、网站绑定或 PoW 配置修改后,需要重新发布并激
|
||||
|
||||
## 推荐实践
|
||||
|
||||
* 生产环境显式配置 `SESSION_SECRET`,并优先使用 PostgreSQL。
|
||||
* 生产环境必须显式配置 `JWT_SECRET`,并优先使用 PostgreSQL。
|
||||
* 修改网站配置后先看预览或 diff,再发布。
|
||||
* 每次发布后检查节点详情与应用记录。
|
||||
* 多节点部署时保持 Agent 到 Server 的网络路径稳定。
|
||||
|
||||
@@ -167,19 +167,19 @@ v1-v7 视为历史初始基线,不再维护逐版本升级文件。v8-v17 是
|
||||
- Client 心跳返回 tunnel 配置版本摘要。
|
||||
- Client 可拉取完整配置(relay 列表 + frpc 代理定义)。
|
||||
- Client 上报配置应用结果。
|
||||
* **Admin Tunnel 管理 API** - `/api/tunnels/*`,要求 Admin Session。
|
||||
* **Admin Tunnel 管理 API** - `/api/tunnels/*`,要求管理端 `OPENFLARE_TOKEN`。
|
||||
- CRUD tunnel 实体(创建、查询、更新、删除)。
|
||||
- Token 管理(生成、轮换)。
|
||||
- 强制同步(触发 Client 立即拉取新配置)。
|
||||
* **Admin Pages 管理 API** - `/api/pages/*`,要求 Admin Session。
|
||||
* **Admin Pages 管理 API** - `/api/pages/*`,要求管理端 `OPENFLARE_TOKEN`。
|
||||
- CRUD Pages 项目,包括 SPA fallback 启用状态与回退路径。
|
||||
- 上传 zip 部署包、查看部署历史、激活部署、删除非激活部署。
|
||||
* **Agent Pages 下载 API** - `/api/agent/pages/*`,使用 `X-Agent-Token` 认证。
|
||||
- Agent 仅能按激活配置引用的部署 ID 拉取静态部署包,不提供任意文件读取或远程命令入口。
|
||||
* 总览与节点详情优先使用专用聚合接口。
|
||||
* 管理端变更类接口统一使用 `POST`;只读接口使用 `GET`。
|
||||
* 管理端继续复用现有登录、角色与 Session。
|
||||
* 第三方登录统一通过认证源 API 进入,认证源管理接口必须要求 Root Session。
|
||||
* 管理端登录成功后返回用户 token;管理端 API 只允许从 `OPENFLARE_TOKEN` 请求头读取登录凭证,不得通过 Cookie Session 放行。
|
||||
* 第三方登录统一通过认证源 API 进入,认证源管理接口必须要求 Root 级 `OPENFLARE_TOKEN`。
|
||||
* `/api/status` 只能返回已启用认证源的公开字段,不得返回 Client Secret。
|
||||
* 第三方账号未绑定且注册关闭时,应提供绑定已有账号流程,不得自动创建用户。
|
||||
* Agent/Relay/Client 正式请求统一使用对应的专属 token(`agent_token` / `relay_token`(即 agent_token) / `tunnel_token`)。
|
||||
|
||||
@@ -20,7 +20,7 @@ OpenFlare 的管理端 API 与 Agent API 都使用 JSON。
|
||||
|
||||
| 类型 | 约定 |
|
||||
| --- | --- |
|
||||
| 管理端 API | 由管理端 Session 鉴权 |
|
||||
| 管理端 API | 由 `OPENFLARE_TOKEN` 请求头鉴权 |
|
||||
| Agent API | 固定放在 `/api/agent/*` |
|
||||
| Relay API | 固定放在 `/api/relay/*`,使用 `X-Agent-Token` 鉴权(与 Agent 复用同一 token) |
|
||||
| OpenFlared API | 固定放在 `/api/flared/*`,使用 `X-Tunnel-Token` 鉴权(独立的 tunnel_token) |
|
||||
@@ -29,7 +29,7 @@ OpenFlare 的管理端 API 与 Agent API 都使用 JSON。
|
||||
|
||||
## WAF IP 组接口
|
||||
|
||||
管理端 WAF IP 组接口统一要求管理端 Session 鉴权:
|
||||
管理端 WAF IP 组接口统一要求管理端 `OPENFLARE_TOKEN` 鉴权:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
@@ -47,7 +47,13 @@ IP 组 `type` 支持 `manual`、`automatic`、`subscription`。自动型 IP 组
|
||||
|
||||
## 鉴权
|
||||
|
||||
管理端继续复用现有登录、角色与 Session。
|
||||
管理端登录成功后返回用户 token,后续所有管理端 API 必须在请求头中携带:
|
||||
|
||||
```http
|
||||
OPENFLARE_TOKEN: <token>
|
||||
```
|
||||
|
||||
Server 只从 `OPENFLARE_TOKEN` 读取管理端登录凭证,不再通过 Cookie Session 放行管理端 API。角色和用户状态仍以数据库中的当前用户记录为准。
|
||||
|
||||
Agent 正式请求统一使用节点专属 `agent_token`,首次接入可使用全局 `discovery_token`。Agent 请求头固定为:
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='replace-with-random-string'
|
||||
export JWT_SECRET='replace-with-random-string'
|
||||
export SQLITE_PATH='./openflare.db'
|
||||
export LOG_LEVEL='info'
|
||||
go run .
|
||||
|
||||
@@ -64,7 +64,7 @@ go run . --port 3000 --log-dir ./logs
|
||||
| `PORT` | Server 监听端口 | `3000` |
|
||||
| `GIN_MODE` | Gin 运行模式 | 非 `debug` 时按 release |
|
||||
| `LOG_LEVEL` | 日志等级 | `info` |
|
||||
| `SESSION_SECRET` | Session 签名密钥 | 启动时随机生成 |
|
||||
| `JWT_SECRET` | 管理端 API 登录令牌的 JWT 签名密钥,生产环境必须显式配置 | 启动时随机生成 |
|
||||
| `SQLITE_PATH` | SQLite 数据库文件路径 | `openflare.db` |
|
||||
| `DSN` | PostgreSQL DSN,设置后优先于 SQLite | 空 |
|
||||
| `SQL_DSN` | 兼容旧命名的 PostgreSQL DSN,优先级低于 `DSN` | 空 |
|
||||
@@ -76,7 +76,7 @@ go run . --port 3000 --log-dir ./logs
|
||||
* `DSN` 与 `SQL_DSN` 同时存在时优先使用 `DSN`。
|
||||
* `DSN` 或 `SQL_DSN` 与 `SQLITE_PATH` 同时存在时优先使用 PostgreSQL。
|
||||
* 当目标 PostgreSQL 数据库为空且本地 `SQLITE_PATH` 文件存在时,Server 启动阶段会自动迁移 SQLite 数据,并在日志中输出按表迁移进度。
|
||||
* `SESSION_SECRET` 生产环境必须显式配置。
|
||||
* `JWT_SECRET` 用于管理端 API 登录令牌的签名与验证,生产环境必须显式配置,避免重启后所有已登录令牌失效。
|
||||
* `REDIS_CONN_STRING` 未配置时,相关能力回退为进程内实现。
|
||||
|
||||
## 运行时 Option
|
||||
@@ -295,7 +295,7 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前
|
||||
### 生产 Server + PostgreSQL
|
||||
|
||||
```bash
|
||||
export SESSION_SECRET='replace-with-a-long-random-string'
|
||||
export JWT_SECRET='replace-with-a-long-random-string'
|
||||
export DSN='postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable'
|
||||
export GIN_MODE='release'
|
||||
export LOG_LEVEL='info'
|
||||
@@ -304,7 +304,7 @@ export LOG_LEVEL='info'
|
||||
### 本地 Server + SQLite
|
||||
|
||||
```bash
|
||||
export SESSION_SECRET='dev-session-secret'
|
||||
export JWT_SECRET='dev-jwt-secret'
|
||||
export SQLITE_PATH='./openflare-dev.db'
|
||||
export LOG_LEVEL='debug'
|
||||
go run .
|
||||
|
||||
+10
-10
@@ -8,27 +8,27 @@ require openflare v0.0.0-00010101000000-000000000000
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/snowflake v0.3.0 // indirect
|
||||
github.com/bytedance/sonic v1.11.2 // indirect
|
||||
github.com/bytedance/sonic v1.12.9 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/expr-lang/expr v1.17.8 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.9.1 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/gin-gonic/gin v1.10.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||
github.com/go-acme/lego/v4 v4.35.2 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.23.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.25.0 // indirect
|
||||
github.com/go-redis/redis/v8 v8.11.5 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -37,7 +37,7 @@ require (
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/longbridgeapp/sqlparser v0.3.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
@@ -47,14 +47,14 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pressly/goose/v3 v3.27.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.7.0 // indirect
|
||||
golang.org/x/arch v0.14.0 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
|
||||
+24
-28
@@ -2,21 +2,18 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
|
||||
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A=
|
||||
github.com/bytedance/sonic v1.11.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/bytedance/sonic v1.12.9 h1:Od1BvK55NnewtGaJsTDeAOSnLVO2BTSLOe0+ooKokmQ=
|
||||
github.com/bytedance/sonic v1.12.9/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0=
|
||||
github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
|
||||
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
@@ -35,10 +32,10 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
@@ -53,16 +50,16 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
|
||||
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
|
||||
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
|
||||
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -87,8 +84,8 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
@@ -122,8 +119,8 @@ github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
|
||||
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -138,12 +135,14 @@ github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLy
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
@@ -152,9 +151,8 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
|
||||
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4=
|
||||
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
@@ -165,7 +163,6 @@ golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
@@ -225,4 +222,3 @@ modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -17,6 +17,7 @@ var HomePageLink = ""
|
||||
// Any options with "Secret", "Token" in its key won't be return by GetOptions
|
||||
|
||||
var SessionSecret = uuid.New().String()
|
||||
var JWTSecret = "" // if empty, falls back to SessionSecret; set via JWT_SECRET env var
|
||||
var SQLitePath = "openflare.db"
|
||||
var SQLDSN = ""
|
||||
|
||||
|
||||
@@ -48,6 +48,9 @@ func ParseFlags() {
|
||||
if os.Getenv("SESSION_SECRET") != "" {
|
||||
SessionSecret = os.Getenv("SESSION_SECRET")
|
||||
}
|
||||
if os.Getenv("JWT_SECRET") != "" {
|
||||
JWTSecret = os.Getenv("JWT_SECRET")
|
||||
}
|
||||
if os.Getenv("SQLITE_PATH") != "" {
|
||||
SQLitePath = os.Getenv("SQLITE_PATH")
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
// @Summary List access logs
|
||||
// @Tags AccessLogs
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param node_id query string false "Node ID"
|
||||
// @Param remote_addr query string false "Remote address"
|
||||
// @Param host query string false "Host"
|
||||
@@ -35,7 +35,7 @@ func GetAccessLogs(c *gin.Context) {
|
||||
// @Summary List folded access logs
|
||||
// @Tags AccessLogs
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param node_id query string false "Node ID"
|
||||
// @Param remote_addr query string false "Remote address"
|
||||
// @Param host query string false "Host"
|
||||
@@ -62,7 +62,7 @@ func GetFoldedAccessLogs(c *gin.Context) {
|
||||
// @Summary List folded access log IP summaries
|
||||
// @Tags AccessLogs
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param node_id query string false "Node ID"
|
||||
// @Param remote_addr query string false "Remote address"
|
||||
// @Param host query string false "Host"
|
||||
@@ -99,7 +99,7 @@ func GetFoldedAccessLogIPs(c *gin.Context) {
|
||||
// @Summary List access log IP summaries
|
||||
// @Tags AccessLogs
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param node_id query string false "Node ID"
|
||||
// @Param remote_addr query string false "Remote address"
|
||||
// @Param host query string false "Host"
|
||||
@@ -130,7 +130,7 @@ func GetAccessLogIPSummaries(c *gin.Context) {
|
||||
// @Summary Get access log IP trend
|
||||
// @Tags AccessLogs
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param node_id query string false "Node ID"
|
||||
// @Param remote_addr query string true "Remote address"
|
||||
// @Param host query string false "Host"
|
||||
@@ -158,7 +158,7 @@ func GetAccessLogIPTrend(c *gin.Context) {
|
||||
// @Tags AccessLogs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/access-logs/cleanup [post]
|
||||
func CleanupAccessLogs(c *gin.Context) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// @Summary Get default ACME account
|
||||
// @Tags AcmeAccounts
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/acme-accounts/default [get]
|
||||
func GetDefaultAcmeAccount(c *gin.Context) {
|
||||
|
||||
@@ -296,7 +296,7 @@ func handleAgentWSStatus(c *gin.Context, node *model.Node, message service.Agent
|
||||
// @Summary List nodes
|
||||
// @Tags Nodes
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/nodes/ [get]
|
||||
func GetNodes(c *gin.Context) {
|
||||
@@ -312,7 +312,7 @@ func GetNodes(c *gin.Context) {
|
||||
// @Summary List apply logs
|
||||
// @Tags ApplyLogs
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param node_id query string false "Node ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/apply-logs/ [get]
|
||||
@@ -334,7 +334,7 @@ func GetApplyLogs(c *gin.Context) {
|
||||
// @Tags ApplyLogs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/apply-logs/cleanup [post]
|
||||
func CleanupApplyLogs(c *gin.Context) {
|
||||
|
||||
@@ -199,10 +199,8 @@ func OAuthCallback(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var currentUserID *int
|
||||
if value := session.Get("id"); value != nil {
|
||||
if idValue, ok := value.(int); ok {
|
||||
currentUserID = &idValue
|
||||
}
|
||||
if currentUser := currentUserFromOpenFlareToken(c); currentUser != nil {
|
||||
currentUserID = ¤tUser.Id
|
||||
}
|
||||
result, pending, err := service.CompleteOAuthLogin(source, profile, currentUserID)
|
||||
if err != nil {
|
||||
@@ -224,7 +222,7 @@ func OAuthCallback(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if result.User != nil {
|
||||
cleanUser, err := setLoginSession(result.User, c)
|
||||
cleanUser, err := setLoginToken(result.User)
|
||||
if err != nil {
|
||||
respondFailure(c, "无法保存会话信息,请重试")
|
||||
return
|
||||
@@ -261,7 +259,7 @@ func LinkExistingOAuthAccount(c *gin.Context) {
|
||||
respondFailure(c, "无法更新会话信息,请重试")
|
||||
return
|
||||
}
|
||||
cleanUser, err := setLoginSession(user, c)
|
||||
cleanUser, err := setLoginToken(user)
|
||||
if err != nil {
|
||||
respondFailure(c, "无法保存会话信息,请重试")
|
||||
return
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// @Summary List config versions
|
||||
// @Tags ConfigVersions
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/config-versions/ [get]
|
||||
func GetConfigVersions(c *gin.Context) {
|
||||
@@ -26,7 +26,7 @@ func GetConfigVersions(c *gin.Context) {
|
||||
// @Summary Get config version detail
|
||||
// @Tags ConfigVersions
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Version ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
@@ -48,7 +48,7 @@ func GetConfigVersion(c *gin.Context) {
|
||||
// @Summary Get active config version
|
||||
// @Tags ConfigVersions
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/config-versions/active [get]
|
||||
func GetActiveConfigVersion(c *gin.Context) {
|
||||
@@ -64,7 +64,7 @@ func GetActiveConfigVersion(c *gin.Context) {
|
||||
// @Summary Preview config rendering
|
||||
// @Tags ConfigVersions
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/config-versions/preview [get]
|
||||
func PreviewConfigVersion(c *gin.Context) {
|
||||
@@ -80,7 +80,7 @@ func PreviewConfigVersion(c *gin.Context) {
|
||||
// @Summary Diff current draft against active version
|
||||
// @Tags ConfigVersions
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/config-versions/diff [get]
|
||||
func DiffConfigVersion(c *gin.Context) {
|
||||
@@ -96,7 +96,7 @@ func DiffConfigVersion(c *gin.Context) {
|
||||
// @Summary Publish a new config version
|
||||
// @Tags ConfigVersions
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/config-versions/publish [post]
|
||||
func PublishConfigVersion(c *gin.Context) {
|
||||
@@ -114,7 +114,7 @@ func PublishConfigVersion(c *gin.Context) {
|
||||
// @Summary Activate an existing config version
|
||||
// @Tags ConfigVersions
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Version ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
@@ -140,7 +140,7 @@ type CleanupConfigVersionRequest struct {
|
||||
// @Summary Cleanup old config versions
|
||||
// @Tags ConfigVersions
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param request body CleanupConfigVersionRequest true "Cleanup request"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
|
||||
@@ -33,7 +33,7 @@ type dashboardTrendsPayload struct {
|
||||
// @Summary Get dashboard overview
|
||||
// @Tags Dashboard
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/dashboard/overview [get]
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
// @Tags Options
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/option/database/cleanup [post]
|
||||
func CleanupDatabaseObservability(c *gin.Context) {
|
||||
|
||||
@@ -16,7 +16,7 @@ type DnsAccountInput struct {
|
||||
// @Summary List DNS accounts
|
||||
// @Tags DnsAccounts
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/dns-accounts/ [get]
|
||||
func GetDnsAccounts(c *gin.Context) {
|
||||
@@ -33,7 +33,7 @@ func GetDnsAccounts(c *gin.Context) {
|
||||
// @Tags DnsAccounts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param payload body DnsAccountInput true "DNS account payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/dns-accounts/ [post]
|
||||
@@ -62,7 +62,7 @@ func CreateDnsAccount(c *gin.Context) {
|
||||
// @Tags DnsAccounts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "DNS Account ID"
|
||||
// @Param payload body DnsAccountInput true "DNS account payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
@@ -100,7 +100,7 @@ func UpdateDnsAccount(c *gin.Context) {
|
||||
// @Summary Delete DNS account
|
||||
// @Tags DnsAccounts
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "DNS Account ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/dns-accounts/{id}/delete [post]
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"openflare/model"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -79,9 +78,7 @@ func getGitHubUserInfoByCode(code string) (*GitHubUser, error) {
|
||||
}
|
||||
|
||||
func GitHubOAuth(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
username := session.Get("username")
|
||||
if username != nil {
|
||||
if currentUserFromOpenFlareToken(c) != nil {
|
||||
GitHubBind(c)
|
||||
return
|
||||
}
|
||||
@@ -135,10 +132,12 @@ func GitHubBind(c *gin.Context) {
|
||||
respondFailure(c, "该 GitHub 账户已被绑定")
|
||||
return
|
||||
}
|
||||
session := sessions.Default(c)
|
||||
id := session.Get("id")
|
||||
// id := c.GetInt("id") // critical bug!
|
||||
user.Id = id.(int)
|
||||
currentUser := currentUserFromOpenFlareToken(c)
|
||||
if currentUser == nil {
|
||||
respondFailure(c, "无权进行此操作,未登录或 token 无效")
|
||||
return
|
||||
}
|
||||
user.Id = currentUser.Id
|
||||
err = user.FillUserById()
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
// @Summary List managed domains
|
||||
// @Tags ManagedDomains
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/managed-domains/ [get]
|
||||
func GetManagedDomains(c *gin.Context) {
|
||||
@@ -28,7 +28,7 @@ func GetManagedDomains(c *gin.Context) {
|
||||
// @Tags ManagedDomains
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param payload body service.ManagedDomainInput true "Managed domain payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
@@ -51,7 +51,7 @@ func CreateManagedDomain(c *gin.Context) {
|
||||
// @Tags ManagedDomains
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Managed domain ID"
|
||||
// @Param payload body service.ManagedDomainInput true "Managed domain payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
@@ -78,7 +78,7 @@ func UpdateManagedDomain(c *gin.Context) {
|
||||
// @Summary Delete managed domain
|
||||
// @Tags ManagedDomains
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Managed domain ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
@@ -99,7 +99,7 @@ func DeleteManagedDomain(c *gin.Context) {
|
||||
// @Summary Match certificate for domain
|
||||
// @Tags ManagedDomains
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param domain query string true "Domain"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/managed-domains/match [get]
|
||||
|
||||
@@ -21,7 +21,7 @@ type nodeObservabilityQuery struct {
|
||||
// @Tags Nodes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param payload body service.NodeInput true "Node payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
@@ -44,7 +44,7 @@ func CreateNode(c *gin.Context) {
|
||||
// @Summary Get global discovery token
|
||||
// @Tags Nodes
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/nodes/bootstrap-token [get]
|
||||
func GetNodeBootstrapToken(c *gin.Context) {
|
||||
@@ -60,7 +60,7 @@ func GetNodeBootstrapToken(c *gin.Context) {
|
||||
// @Summary Rotate global discovery token
|
||||
// @Tags Nodes
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/nodes/bootstrap-token/rotate [post]
|
||||
func RotateNodeBootstrapToken(c *gin.Context) {
|
||||
@@ -77,7 +77,7 @@ func RotateNodeBootstrapToken(c *gin.Context) {
|
||||
// @Tags Nodes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Node ID"
|
||||
// @Param payload body service.NodeInput true "Node payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
@@ -106,7 +106,7 @@ func UpdateNode(c *gin.Context) {
|
||||
// @Summary Delete node
|
||||
// @Tags Nodes
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Node ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
@@ -128,7 +128,7 @@ func DeleteNode(c *gin.Context) {
|
||||
// @Summary Request agent self-update on node
|
||||
// @Tags Nodes
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Node ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
@@ -162,7 +162,7 @@ func RequestNodeAgentUpdate(c *gin.Context) {
|
||||
// @Summary Request openresty restart on node
|
||||
// @Tags Nodes
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Node ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
@@ -185,7 +185,7 @@ func RequestNodeOpenrestyRestart(c *gin.Context) {
|
||||
// @Summary Request force sync config on node
|
||||
// @Tags Nodes
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Node ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
@@ -208,7 +208,7 @@ func RequestNodeForceSync(c *gin.Context) {
|
||||
// @Summary Check latest agent release for node
|
||||
// @Tags Nodes
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Node ID"
|
||||
// @Param channel query string false "stable or preview"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
@@ -232,7 +232,7 @@ func GetNodeAgentRelease(c *gin.Context) {
|
||||
// @Summary Get node observability details
|
||||
// @Tags Nodes
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Node ID"
|
||||
// @Param hours query int false "Lookback window in hours"
|
||||
// @Param limit query int false "Max records per section"
|
||||
@@ -266,7 +266,7 @@ func GetNodeObservability(c *gin.Context) {
|
||||
// @Summary Cleanup node health events
|
||||
// @Tags Nodes
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Node ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
|
||||
@@ -93,7 +93,13 @@ func UploadPagesDeployment(c *gin.Context) {
|
||||
respondBadRequest(c, "缺少 Pages 部署包")
|
||||
return
|
||||
}
|
||||
deployment, err := service.UploadPagesDeployment(id, file, c.PostForm("entry_file"), c.GetString("username"))
|
||||
deployment, err := service.UploadPagesDeployment(
|
||||
id,
|
||||
file,
|
||||
c.PostForm("root_dir"),
|
||||
c.PostForm("entry_file"),
|
||||
c.GetString("username"),
|
||||
)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// @Summary List proxy routes
|
||||
// @Tags ProxyRoutes
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/proxy-routes/ [get]
|
||||
func GetProxyRoutes(c *gin.Context) {
|
||||
@@ -26,7 +26,7 @@ func GetProxyRoutes(c *gin.Context) {
|
||||
// @Summary Get proxy route detail
|
||||
// @Tags ProxyRoutes
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Route ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
@@ -49,7 +49,7 @@ func GetProxyRoute(c *gin.Context) {
|
||||
// @Tags ProxyRoutes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param payload body service.ProxyRouteInput true "Proxy route payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
@@ -72,7 +72,7 @@ func CreateProxyRoute(c *gin.Context) {
|
||||
// @Tags ProxyRoutes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Route ID"
|
||||
// @Param payload body service.ProxyRouteInput true "Proxy route payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
@@ -99,7 +99,7 @@ func UpdateProxyRoute(c *gin.Context) {
|
||||
// @Summary Delete proxy route
|
||||
// @Tags ProxyRoutes
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Route ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// @Summary List TLS certificates
|
||||
// @Tags TLSCertificates
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/tls-certificates/ [get]
|
||||
func GetTLSCertificates(c *gin.Context) {
|
||||
@@ -26,7 +26,7 @@ func GetTLSCertificates(c *gin.Context) {
|
||||
// @Summary Get TLS certificate detail
|
||||
// @Tags TLSCertificates
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Certificate ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
@@ -49,7 +49,7 @@ func GetTLSCertificate(c *gin.Context) {
|
||||
// @Summary Get TLS certificate PEM content
|
||||
// @Tags TLSCertificates
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Certificate ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
@@ -73,7 +73,7 @@ func GetTLSCertificateContent(c *gin.Context) {
|
||||
// @Tags TLSCertificates
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param payload body service.TLSCertificateInput true "TLS certificate payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
@@ -96,7 +96,7 @@ func CreateTLSCertificate(c *gin.Context) {
|
||||
// @Tags TLSCertificates
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Certificate ID"
|
||||
// @Param payload body service.TLSCertificateInput true "TLS certificate payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
@@ -126,7 +126,7 @@ func UpdateTLSCertificate(c *gin.Context) {
|
||||
// @Tags TLSCertificates
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param name formData string true "Certificate name"
|
||||
// @Param remark formData string false "Remark"
|
||||
// @Param cert_file formData file true "Certificate file"
|
||||
@@ -159,7 +159,7 @@ func ImportTLSCertificateFile(c *gin.Context) {
|
||||
// @Summary Delete TLS certificate
|
||||
// @Tags TLSCertificates
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Certificate ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
@@ -181,7 +181,7 @@ func DeleteTLSCertificate(c *gin.Context) {
|
||||
// @Tags TLSCertificates
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param payload body service.TLSApplyInput true "TLS apply payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
@@ -204,7 +204,7 @@ func ApplyTLSCertificate(c *gin.Context) {
|
||||
// @Tags TLSCertificates
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Certificate ID"
|
||||
// @Param payload body service.TLSApplyInput true "TLS apply payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
@@ -233,7 +233,7 @@ func UpdateAcmeCertificate(c *gin.Context) {
|
||||
// @Tags TLSCertificates
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Certificate ID"
|
||||
// @Param payload body service.TLSApplyInput true "TLS apply payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
@@ -261,7 +261,7 @@ func ConvertTLSCertificateToAcme(c *gin.Context) {
|
||||
// @Summary Renew TLS certificate
|
||||
// @Tags TLSCertificates
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Param id path int true "Certificate ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
|
||||
@@ -2,7 +2,6 @@ package controller
|
||||
|
||||
import (
|
||||
"openflare/service"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -21,7 +20,7 @@ type serverUpgradeRequest struct {
|
||||
// @Summary Get latest GitHub release
|
||||
// @Tags Update
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/update/latest-release [get]
|
||||
func GetLatestRelease(c *gin.Context) {
|
||||
@@ -102,35 +101,38 @@ func StreamServerUpgradeLogs(c *gin.Context) {
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/update/manual-upload [post]
|
||||
func UploadManualServerBinary(c *gin.Context) {
|
||||
fileHeader, err := c.FormFile("binary")
|
||||
if err != nil {
|
||||
respondFailure(c, "请先选择要上传的服务端二进制文件。")
|
||||
return
|
||||
}
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
respondFailure(c, "读取上传文件失败。")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
info, err := service.UploadManualServerBinary(c.Request.Context(), fileHeader.Filename, file)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(info.ComparisonMessage)
|
||||
if message == "" {
|
||||
message = "已完成上传并检查升级包版本。"
|
||||
}
|
||||
|
||||
respondSuccessWithExtras(c, info, gin.H{
|
||||
"message": message,
|
||||
})
|
||||
respondFailure(c, "手动升级功能已禁用")
|
||||
return
|
||||
//
|
||||
//fileHeader, err := c.FormFile("binary")
|
||||
//if err != nil {
|
||||
// respondFailure(c, "请先选择要上传的服务端二进制文件。")
|
||||
// return
|
||||
//}
|
||||
//
|
||||
//file, err := fileHeader.Open()
|
||||
//if err != nil {
|
||||
// respondFailure(c, "读取上传文件失败。")
|
||||
// return
|
||||
//}
|
||||
//defer func() {
|
||||
// _ = file.Close()
|
||||
//}()
|
||||
//
|
||||
//info, err := service.UploadManualServerBinary(c.Request.Context(), fileHeader.Filename, file)
|
||||
//if err != nil {
|
||||
// respondFailure(c, err.Error())
|
||||
// return
|
||||
//}
|
||||
//
|
||||
//message := strings.TrimSpace(info.ComparisonMessage)
|
||||
//if message == "" {
|
||||
// message = "已完成上传并检查升级包版本。"
|
||||
//}
|
||||
//
|
||||
//respondSuccessWithExtras(c, info, gin.H{
|
||||
// "message": message,
|
||||
//})
|
||||
}
|
||||
|
||||
// ConfirmManualServerUpgrade godoc
|
||||
@@ -141,18 +143,21 @@ func UploadManualServerBinary(c *gin.Context) {
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/update/manual-upgrade [post]
|
||||
func ConfirmManualServerUpgrade(c *gin.Context) {
|
||||
var request confirmManualUpgradeRequest
|
||||
if !bindJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
|
||||
info, err := service.ConfirmManualServerUpgrade(request.UploadToken)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondSuccessWithExtras(c, info, gin.H{
|
||||
"message": "服务升级任务已启动,确认无误后将自动重启。",
|
||||
})
|
||||
respondFailure(c, "手动升级功能已禁用")
|
||||
return
|
||||
//
|
||||
//var request confirmManualUpgradeRequest
|
||||
//if !bindJSON(c, &request) {
|
||||
// return
|
||||
//}
|
||||
//
|
||||
//info, err := service.ConfirmManualServerUpgrade(request.UploadToken)
|
||||
//if err != nil {
|
||||
// respondFailure(c, err.Error())
|
||||
// return
|
||||
//}
|
||||
//
|
||||
//respondSuccessWithExtras(c, info, gin.H{
|
||||
// "message": "服务升级任务已启动,确认无误后将自动重启。",
|
||||
//})
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
// @Tags UptimeKuma
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Security OpenFlareTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/uptimekuma/sync [post]
|
||||
func SyncUptimeKuma(c *gin.Context) {
|
||||
|
||||
@@ -2,15 +2,13 @@ package controller
|
||||
|
||||
import (
|
||||
"openflare/common"
|
||||
"openflare/middleware"
|
||||
"openflare/model"
|
||||
"openflare/utils/security"
|
||||
"openflare/utils/validation"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
@@ -45,29 +43,30 @@ func Login(c *gin.Context) {
|
||||
setupLogin(&user, c)
|
||||
}
|
||||
|
||||
// setup session & cookies and then return user info
|
||||
func setLoginSession(user *model.User, c *gin.Context) (*model.User, error) {
|
||||
session := sessions.Default(c)
|
||||
session.Set("id", user.Id)
|
||||
session.Set("username", user.Username)
|
||||
session.Set("role", user.Role)
|
||||
session.Set("status", user.Status)
|
||||
err := session.Save()
|
||||
// setup token and then return user info
|
||||
func setLoginToken(user *model.User) (*model.User, error) {
|
||||
// Generate a signed JWT using gin-jwt middleware
|
||||
tokenString, _, err := middleware.JWTMiddleware.TokenGenerator(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Persist JWT in DB so we can invalidate it on logout
|
||||
if err := model.DB.Model(user).Update("token", tokenString).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cleanUser := &model.User{
|
||||
Id: user.Id,
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
Role: user.Role,
|
||||
Status: user.Status,
|
||||
Token: tokenString,
|
||||
}
|
||||
return cleanUser, nil
|
||||
}
|
||||
|
||||
func setupLogin(user *model.User, c *gin.Context) {
|
||||
cleanUser, err := setLoginSession(user, c)
|
||||
cleanUser, err := setLoginToken(user)
|
||||
if err != nil {
|
||||
respondFailure(c, "无法保存会话信息,请重试")
|
||||
return
|
||||
@@ -76,16 +75,27 @@ func setupLogin(user *model.User, c *gin.Context) {
|
||||
}
|
||||
|
||||
func Logout(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
session.Clear()
|
||||
err := session.Save()
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
token := c.GetHeader("OpenFlare-Token")
|
||||
if token != "" {
|
||||
user := model.ValidateUserToken(token)
|
||||
if user != nil && user.Id != 0 {
|
||||
if err := model.DB.Model(user).Update("token", "").Error; err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
respondSuccessMessage(c, "")
|
||||
}
|
||||
|
||||
func currentUserFromOpenFlareToken(c *gin.Context) *model.User {
|
||||
token := c.GetHeader("OpenFlare-Token")
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
return model.ValidateUserToken(token)
|
||||
}
|
||||
|
||||
func Register(c *gin.Context) {
|
||||
respondFailure(c, "非法请求")
|
||||
}
|
||||
@@ -138,19 +148,17 @@ func GenerateToken(c *gin.Context) {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
user.Token = uuid.New().String()
|
||||
user.Token = strings.Replace(user.Token, "-", "", -1)
|
||||
|
||||
if model.DB.Where("token = ?", user.Token).First(user).RowsAffected != 0 {
|
||||
respondFailure(c, "请重试,系统生成的 UUID 竟然重复了!")
|
||||
// Generate a fresh JWT for the user
|
||||
tokenString, _, err := middleware.JWTMiddleware.TokenGenerator(user)
|
||||
if err != nil {
|
||||
respondFailure(c, "生成 Token 失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user.Token = tokenString
|
||||
if err := user.Update(false); err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondSuccess(c, user.Token)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -94,7 +94,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -122,7 +122,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -203,7 +203,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -292,7 +292,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -361,7 +361,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -419,7 +419,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -667,7 +667,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -700,7 +700,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -728,7 +728,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -753,7 +753,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -778,7 +778,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -821,7 +821,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -846,7 +846,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -871,7 +871,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -896,7 +896,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -937,7 +937,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -978,7 +978,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1010,7 +1010,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1033,7 +1033,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -1072,7 +1072,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1106,7 +1106,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -1276,7 +1276,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1299,7 +1299,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -1345,7 +1345,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1379,7 +1379,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1420,7 +1420,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -1473,7 +1473,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1496,7 +1496,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -1542,7 +1542,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1567,7 +1567,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1592,7 +1592,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1639,7 +1639,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1680,7 +1680,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1721,7 +1721,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1762,7 +1762,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1815,7 +1815,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1856,7 +1856,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1897,7 +1897,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -1970,7 +1970,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -2121,7 +2121,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -2144,7 +2144,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -2190,7 +2190,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -2231,7 +2231,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -2272,7 +2272,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -2405,7 +2405,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -2428,7 +2428,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -2474,7 +2474,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -2520,7 +2520,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -2584,7 +2584,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -2625,7 +2625,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -2666,7 +2666,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -2719,7 +2719,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -2760,7 +2760,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -2801,7 +2801,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -2854,7 +2854,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -2907,7 +2907,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -3761,10 +3761,10 @@ const docTemplate = `{
|
||||
"name": "X-Agent-Token",
|
||||
"in": "header"
|
||||
},
|
||||
"BearerAuth": {
|
||||
"description": "管理端可使用 Bearer Token,例如:Bearer \u003ctoken\u003e",
|
||||
"OpenFlareTokenAuth": {
|
||||
"description": "管理端 API 使用登录后返回的用户 Token",
|
||||
"type": "apiKey",
|
||||
"name": "Authorization",
|
||||
"name": "OPENFLARE_TOKEN",
|
||||
"in": "header"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -91,7 +91,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -119,7 +119,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -200,7 +200,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -289,7 +289,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -358,7 +358,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -416,7 +416,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -664,7 +664,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -697,7 +697,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -725,7 +725,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -750,7 +750,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -775,7 +775,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -818,7 +818,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -843,7 +843,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -868,7 +868,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -893,7 +893,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -934,7 +934,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -975,7 +975,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1007,7 +1007,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1030,7 +1030,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -1069,7 +1069,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1103,7 +1103,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -1273,7 +1273,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1296,7 +1296,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -1342,7 +1342,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1376,7 +1376,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1417,7 +1417,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -1470,7 +1470,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1493,7 +1493,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -1539,7 +1539,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1564,7 +1564,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1589,7 +1589,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1636,7 +1636,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1677,7 +1677,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1718,7 +1718,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1759,7 +1759,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1812,7 +1812,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1853,7 +1853,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -1894,7 +1894,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -1967,7 +1967,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -2118,7 +2118,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -2141,7 +2141,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -2187,7 +2187,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -2228,7 +2228,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -2269,7 +2269,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -2402,7 +2402,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -2425,7 +2425,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -2471,7 +2471,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -2517,7 +2517,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -2581,7 +2581,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -2622,7 +2622,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -2663,7 +2663,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -2716,7 +2716,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -2757,7 +2757,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -2798,7 +2798,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -2851,7 +2851,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -2904,7 +2904,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
"OpenFlareTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -3758,10 +3758,10 @@
|
||||
"name": "X-Agent-Token",
|
||||
"in": "header"
|
||||
},
|
||||
"BearerAuth": {
|
||||
"description": "管理端可使用 Bearer Token,例如:Bearer \u003ctoken\u003e",
|
||||
"OpenFlareTokenAuth": {
|
||||
"description": "管理端 API 使用登录后返回的用户 Token",
|
||||
"type": "apiKey",
|
||||
"name": "Authorization",
|
||||
"name": "OPENFLARE_TOKEN",
|
||||
"in": "header"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,7 +541,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: List access logs
|
||||
tags:
|
||||
- AccessLogs
|
||||
@@ -558,7 +558,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Cleanup access logs by retention days
|
||||
tags:
|
||||
- AccessLogs
|
||||
@@ -610,7 +610,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: List folded access logs
|
||||
tags:
|
||||
- AccessLogs
|
||||
@@ -668,7 +668,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: List folded access log IP summaries
|
||||
tags:
|
||||
- AccessLogs
|
||||
@@ -712,7 +712,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: List access log IP summaries
|
||||
tags:
|
||||
- AccessLogs
|
||||
@@ -749,7 +749,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Get access log IP trend
|
||||
tags:
|
||||
- AccessLogs
|
||||
@@ -764,7 +764,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Get default ACME account
|
||||
tags:
|
||||
- AcmeAccounts
|
||||
@@ -923,7 +923,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: List apply logs
|
||||
tags:
|
||||
- ApplyLogs
|
||||
@@ -940,7 +940,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Cleanup apply logs
|
||||
tags:
|
||||
- ApplyLogs
|
||||
@@ -955,7 +955,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: List config versions
|
||||
tags:
|
||||
- ConfigVersions
|
||||
@@ -981,7 +981,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Get config version detail
|
||||
tags:
|
||||
- ConfigVersions
|
||||
@@ -1007,7 +1007,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Activate an existing config version
|
||||
tags:
|
||||
- ConfigVersions
|
||||
@@ -1022,7 +1022,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Get active config version
|
||||
tags:
|
||||
- ConfigVersions
|
||||
@@ -1049,7 +1049,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Cleanup old config versions
|
||||
tags:
|
||||
- ConfigVersions
|
||||
@@ -1064,7 +1064,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Diff current draft against active version
|
||||
tags:
|
||||
- ConfigVersions
|
||||
@@ -1079,7 +1079,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Preview config rendering
|
||||
tags:
|
||||
- ConfigVersions
|
||||
@@ -1094,7 +1094,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Publish a new config version
|
||||
tags:
|
||||
- ConfigVersions
|
||||
@@ -1114,7 +1114,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Get dashboard overview
|
||||
tags:
|
||||
- Dashboard
|
||||
@@ -1129,7 +1129,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: List DNS accounts
|
||||
tags:
|
||||
- DnsAccounts
|
||||
@@ -1152,7 +1152,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Create DNS account
|
||||
tags:
|
||||
- DnsAccounts
|
||||
@@ -1173,7 +1173,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Delete DNS account
|
||||
tags:
|
||||
- DnsAccounts
|
||||
@@ -1202,7 +1202,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Update DNS account
|
||||
tags:
|
||||
- DnsAccounts
|
||||
@@ -1293,7 +1293,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: List managed domains
|
||||
tags:
|
||||
- ManagedDomains
|
||||
@@ -1321,7 +1321,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Create managed domain
|
||||
tags:
|
||||
- ManagedDomains
|
||||
@@ -1347,7 +1347,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Delete managed domain
|
||||
tags:
|
||||
- ManagedDomains
|
||||
@@ -1381,7 +1381,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Update managed domain
|
||||
tags:
|
||||
- ManagedDomains
|
||||
@@ -1402,7 +1402,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Match certificate for domain
|
||||
tags:
|
||||
- ManagedDomains
|
||||
@@ -1417,7 +1417,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: List nodes
|
||||
tags:
|
||||
- Nodes
|
||||
@@ -1445,7 +1445,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Create node
|
||||
tags:
|
||||
- Nodes
|
||||
@@ -1475,7 +1475,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Check latest agent release for node
|
||||
tags:
|
||||
- Nodes
|
||||
@@ -1501,7 +1501,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Request agent self-update on node
|
||||
tags:
|
||||
- Nodes
|
||||
@@ -1527,7 +1527,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Delete node
|
||||
tags:
|
||||
- Nodes
|
||||
@@ -1553,7 +1553,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Request force sync config on node
|
||||
tags:
|
||||
- Nodes
|
||||
@@ -1587,7 +1587,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Get node observability details
|
||||
tags:
|
||||
- Nodes
|
||||
@@ -1613,7 +1613,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Cleanup node health events
|
||||
tags:
|
||||
- Nodes
|
||||
@@ -1639,7 +1639,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Request openresty restart on node
|
||||
tags:
|
||||
- Nodes
|
||||
@@ -1673,7 +1673,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Update node
|
||||
tags:
|
||||
- Nodes
|
||||
@@ -1688,7 +1688,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Get global discovery token
|
||||
tags:
|
||||
- Nodes
|
||||
@@ -1703,7 +1703,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Rotate global discovery token
|
||||
tags:
|
||||
- Nodes
|
||||
@@ -1733,7 +1733,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Cleanup observability tables
|
||||
tags:
|
||||
- Options
|
||||
@@ -1829,7 +1829,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: List proxy routes
|
||||
tags:
|
||||
- ProxyRoutes
|
||||
@@ -1857,7 +1857,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Create proxy route
|
||||
tags:
|
||||
- ProxyRoutes
|
||||
@@ -1883,7 +1883,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Get proxy route detail
|
||||
tags:
|
||||
- ProxyRoutes
|
||||
@@ -1909,7 +1909,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Delete proxy route
|
||||
tags:
|
||||
- ProxyRoutes
|
||||
@@ -1943,7 +1943,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Update proxy route
|
||||
tags:
|
||||
- ProxyRoutes
|
||||
@@ -2008,7 +2008,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: List TLS certificates
|
||||
tags:
|
||||
- TLSCertificates
|
||||
@@ -2036,7 +2036,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Create TLS certificate from PEM
|
||||
tags:
|
||||
- TLSCertificates
|
||||
@@ -2062,7 +2062,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Get TLS certificate detail
|
||||
tags:
|
||||
- TLSCertificates
|
||||
@@ -2088,7 +2088,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Get TLS certificate PEM content
|
||||
tags:
|
||||
- TLSCertificates
|
||||
@@ -2122,7 +2122,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Convert uploaded TLS certificate to ACME managed certificate
|
||||
tags:
|
||||
- TLSCertificates
|
||||
@@ -2148,7 +2148,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Delete TLS certificate
|
||||
tags:
|
||||
- TLSCertificates
|
||||
@@ -2174,7 +2174,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Renew TLS certificate
|
||||
tags:
|
||||
- TLSCertificates
|
||||
@@ -2208,7 +2208,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Update TLS certificate from PEM
|
||||
tags:
|
||||
- TLSCertificates
|
||||
@@ -2242,7 +2242,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Update ACME TLS certificate
|
||||
tags:
|
||||
- TLSCertificates
|
||||
@@ -2271,7 +2271,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Apply TLS certificate via ACME
|
||||
tags:
|
||||
- TLSCertificates
|
||||
@@ -2313,7 +2313,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Import TLS certificate from files
|
||||
tags:
|
||||
- TLSCertificates
|
||||
@@ -2328,7 +2328,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- OpenFlareTokenAuth: []
|
||||
summary: Get latest GitHub release
|
||||
tags:
|
||||
- Update
|
||||
@@ -2390,9 +2390,9 @@ securityDefinitions:
|
||||
in: header
|
||||
name: X-Agent-Token
|
||||
type: apiKey
|
||||
BearerAuth:
|
||||
description: 管理端可使用 Bearer Token,例如:Bearer <token>
|
||||
OpenFlareTokenAuth:
|
||||
description: 管理端 API 使用登录后返回的用户 Token
|
||||
in: header
|
||||
name: Authorization
|
||||
name: OPENFLARE_TOKEN
|
||||
type: apiKey
|
||||
swagger: "2.0"
|
||||
|
||||
+13
-8
@@ -9,10 +9,10 @@ require (
|
||||
github.com/gin-contrib/cors v1.6.0
|
||||
github.com/gin-contrib/sessions v0.0.5
|
||||
github.com/gin-contrib/static v0.0.1
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-acme/lego/v4 v4.35.2
|
||||
github.com/go-playground/validator/v10 v10.23.0
|
||||
github.com/go-playground/validator/v10 v10.25.0
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/oschwald/maxminddb-golang v1.13.1
|
||||
@@ -32,16 +32,19 @@ require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/appleboy/gin-jwt/v2 v2.10.3 // indirect
|
||||
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect
|
||||
github.com/bytedance/sonic v1.11.2 // indirect
|
||||
github.com/bytedance/sonic v1.12.9 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
@@ -50,7 +53,8 @@ require (
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/gomodule/redigo v2.0.0+incompatible // indirect
|
||||
github.com/gorilla/context v1.1.1 // indirect
|
||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
@@ -63,7 +67,7 @@ require (
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/longbridgeapp/sqlparser v0.3.1 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
@@ -73,13 +77,14 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.7.0 // indirect
|
||||
golang.org/x/arch v0.14.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
|
||||
@@ -6,6 +6,8 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/appleboy/gin-jwt/v2 v2.10.3 h1:KNcPC+XPRNpuoBh+j+rgs5bQxN+SwG/0tHbIqpRoBGc=
|
||||
github.com/appleboy/gin-jwt/v2 v2.10.3/go.mod h1:LDUaQ8mF2W6LyXIbd5wqlV2SFebuyYs4RDwqMNgpsp8=
|
||||
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff h1:RmdPFa+slIr4SCBg4st/l/vZWVe9QJKMXGO60Bxbe04=
|
||||
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
|
||||
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
|
||||
@@ -14,6 +16,11 @@ github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A=
|
||||
github.com/bytedance/sonic v1.11.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/bytedance/sonic v1.12.9 h1:Od1BvK55NnewtGaJsTDeAOSnLVO2BTSLOe0+ooKokmQ=
|
||||
github.com/bytedance/sonic v1.12.9/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0=
|
||||
github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
@@ -25,6 +32,9 @@ github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpV
|
||||
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
|
||||
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -52,11 +62,15 @@ github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfm
|
||||
github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-contrib/static v0.0.1 h1:JVxuvHPuUfkoul12N7dtQw7KRn/pSMq7Ue1Va9Swm1U=
|
||||
github.com/gin-contrib/static v0.0.1/go.mod h1:CSxeF+wep05e0kCOsqWdAWbSszmc31zTIbD8TvWl7Hs=
|
||||
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
@@ -87,6 +101,8 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91
|
||||
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
|
||||
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
|
||||
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
|
||||
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
@@ -95,6 +111,10 @@ github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
|
||||
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0=
|
||||
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
|
||||
@@ -134,6 +154,8 @@ github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
@@ -180,6 +202,8 @@ github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -196,6 +220,7 @@ github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLy
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -204,6 +229,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
@@ -218,12 +244,16 @@ github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVM
|
||||
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
|
||||
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4=
|
||||
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
|
||||
@@ -33,10 +33,10 @@ var indexPage []byte
|
||||
// @description OpenFlare Server 管理端与 Agent API 文档。
|
||||
// @BasePath /
|
||||
// @schemes http https
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @securityDefinitions.apikey OpenFlareTokenAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description 管理端可使用 Bearer Token,例如:Bearer <token>
|
||||
// @name OpenFlare-Token
|
||||
// @description 管理端 API 使用登录后返回的用户 Token
|
||||
// @securityDefinitions.apikey AccessTokenAuth
|
||||
// @in header
|
||||
// @name X-Agent-Token
|
||||
@@ -73,6 +73,7 @@ func main() {
|
||||
|
||||
// Initialize options
|
||||
model.InitOptionMap()
|
||||
middleware.InitJWTMiddleware()
|
||||
geoip.InitGeoIP(common.GeoIPProvider)
|
||||
backgroundCtx, cancelBackgroundTasks := context.WithCancel(context.Background())
|
||||
defer cancelBackgroundTasks()
|
||||
|
||||
@@ -1,49 +1,70 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
|
||||
jwt "github.com/appleboy/gin-jwt/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const OpenFlareTokenHeader = "OpenFlare-Token"
|
||||
|
||||
func authHelper(c *gin.Context, minRole int) {
|
||||
session := sessions.Default(c)
|
||||
username := session.Get("username")
|
||||
role := session.Get("role")
|
||||
id := session.Get("id")
|
||||
status := session.Get("status")
|
||||
authByToken := false
|
||||
if username == nil {
|
||||
// Check token
|
||||
token := c.Request.Header.Get("Authorization")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,未登录或 token 无效",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
user := model.ValidateUserToken(token)
|
||||
if user != nil && user.Username != "" {
|
||||
// Token is valid
|
||||
username = user.Username
|
||||
role = user.Role
|
||||
id = user.Id
|
||||
status = user.Status
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,token 无效",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
authByToken = true
|
||||
tokenStr := c.GetHeader(OpenFlareTokenHeader)
|
||||
if tokenStr == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,未登录或 token 无效",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if status.(int) == common.UserStatusDisabled {
|
||||
|
||||
token, err := JWTMiddleware.ParseTokenString(tokenStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,token 无效: " + err.Error(),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims := jwt.ExtractClaimsFromToken(token)
|
||||
id, ok := claims["id"].(float64)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,token 格式错误",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
dbUser := &model.User{}
|
||||
dbErr := model.DB.Select([]string{"id", "username", "display_name", "role", "status", "token"}).
|
||||
First(dbUser, "id = ?", int(id)).Error
|
||||
if dbErr != nil || dbUser.Username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,用户不存在",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if dbUser.Token != tokenStr {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,token 已失效或已登出",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if dbUser.Status == common.UserStatusDisabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "用户已被封禁",
|
||||
@@ -51,7 +72,8 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if role.(int) < minRole {
|
||||
|
||||
if int(dbUser.Role) < minRole {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,权限不足",
|
||||
@@ -59,10 +81,11 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("username", username)
|
||||
c.Set("role", role)
|
||||
c.Set("id", id)
|
||||
c.Set("authByToken", authByToken)
|
||||
|
||||
c.Set("username", dbUser.Username)
|
||||
c.Set("role", dbUser.Role)
|
||||
c.Set("id", dbUser.Id)
|
||||
c.Set("authByToken", true)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
@@ -84,34 +107,16 @@ func RootAuth() func(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// NoTokenAuth You should always use this after normal auth middlewares.
|
||||
// NoTokenAuth is kept as a compatibility no-op because admin APIs now always use OPENFLARE_TOKEN.
|
||||
func NoTokenAuth() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
authByToken := c.GetBool("authByToken")
|
||||
if authByToken {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "本接口不支持使用 token 进行验证",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// TokenOnlyAuth You should always use this after normal auth middlewares.
|
||||
// TokenOnlyAuth is kept as a compatibility no-op because admin APIs now always use OPENFLARE_TOKEN.
|
||||
func TokenOnlyAuth() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
authByToken := c.GetBool("authByToken")
|
||||
if !authByToken {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "本接口仅支持使用 token 进行验证",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func CORS() gin.HandlerFunc {
|
||||
config := cors.DefaultConfig()
|
||||
config.AllowCredentials = true
|
||||
config.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization", "X-Agent-Token", "Accept"}
|
||||
config.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization", "OpenFlare-Token", "X-Agent-Token", "Accept"}
|
||||
config.AllowOriginFunc = func(origin string) bool {
|
||||
serverAddr := strings.TrimRight(common.ServerAddress, "/")
|
||||
if serverAddr == "" {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"time"
|
||||
|
||||
jwt "github.com/appleboy/gin-jwt/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var JWTMiddleware *jwt.GinJWTMiddleware
|
||||
|
||||
// jwtSigningKey returns JWT_SECRET when set, falling back to SESSION_SECRET
|
||||
// for backward compatibility with deployments that only configure SESSION_SECRET.
|
||||
func jwtSigningKey() []byte {
|
||||
if common.JWTSecret != "" {
|
||||
return []byte(common.JWTSecret)
|
||||
}
|
||||
return []byte(common.SessionSecret)
|
||||
}
|
||||
|
||||
func InitJWTMiddleware() {
|
||||
var err error
|
||||
JWTMiddleware, err = jwt.New(&jwt.GinJWTMiddleware{
|
||||
Realm: "openflare",
|
||||
Key: jwtSigningKey(),
|
||||
Timeout: 24 * time.Hour,
|
||||
MaxRefresh: 24 * time.Hour,
|
||||
IdentityKey: "identity",
|
||||
PayloadFunc: func(data interface{}) jwt.MapClaims {
|
||||
if v, ok := data.(*model.User); ok {
|
||||
return jwt.MapClaims{
|
||||
"id": v.Id,
|
||||
"username": v.Username,
|
||||
"role": v.Role,
|
||||
}
|
||||
}
|
||||
return jwt.MapClaims{}
|
||||
},
|
||||
IdentityHandler: func(c *gin.Context) interface{} {
|
||||
claims := jwt.ExtractClaims(c)
|
||||
id, ok := claims["id"].(float64)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
username, _ := claims["username"].(string)
|
||||
role, _ := claims["role"].(float64)
|
||||
return &model.User{
|
||||
Id: int(id),
|
||||
Username: username,
|
||||
Role: int(role),
|
||||
}
|
||||
},
|
||||
Authorizator: func(data interface{}, c *gin.Context) bool {
|
||||
return data != nil
|
||||
},
|
||||
Unauthorized: func(c *gin.Context, code int, message string) {
|
||||
c.JSON(code, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,未登录或 token 无效: " + message,
|
||||
})
|
||||
},
|
||||
TokenLookup: "header: OpenFlare-Token",
|
||||
TokenHeadName: "", // Empty for raw token value directly
|
||||
SendCookie: false,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("JWT Init Error: %s", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,17 @@ func ValidateRegisteredSchema(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
func EnsureDatabaseSchemaUpToDate(db *gorm.DB, backend string, ctx BridgeContext) error {
|
||||
var startDesc string
|
||||
legacyVer, hasLegacy, _ := loadLegacyDatabaseSchemaVersion(db)
|
||||
gooseVer, hasGoose, _ := LoadDatabaseVersion(db)
|
||||
if hasGoose {
|
||||
startDesc = fmt.Sprintf("goose version %d", gooseVer)
|
||||
} else if hasLegacy {
|
||||
startDesc = fmt.Sprintf("legacy version %d", legacyVer)
|
||||
} else {
|
||||
startDesc = "none (fresh database)"
|
||||
}
|
||||
|
||||
state, err := detectSchemaState(db, ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -219,5 +230,15 @@ func EnsureDatabaseSchemaUpToDate(db *gorm.DB, backend string, ctx BridgeContext
|
||||
if err := ctx.ValidateCurrentDatabaseSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return ValidateRegisteredSchema(db)
|
||||
if err := ValidateRegisteredSchema(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
endVer, _, _ := LoadDatabaseVersion(db)
|
||||
if hasGoose && int64(gooseVer) == int64(endVer) {
|
||||
slog.Info("database schema is already up to date", "version", endVer)
|
||||
} else {
|
||||
slog.Info("database migration completed successfully", "from", startDesc, "to", fmt.Sprintf("goose version %d", endVer))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func validatePagesStaticHosting(db *gorm.DB) error {
|
||||
return fmt.Errorf("column pages_projects.%s is missing", column)
|
||||
}
|
||||
}
|
||||
for _, column := range []string{"project_id", "checksum", "artifact_path", "entry_file"} {
|
||||
for _, column := range []string{"project_id", "checksum", "artifact_path"} {
|
||||
if !db.Migrator().HasColumn("pages_deployments", column) {
|
||||
return fmt.Errorf("column pages_deployments.%s is missing", column)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package goose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
presslygoose "github.com/pressly/goose/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const versionPagesFeaturesAndCleanup int64 = 202606040004
|
||||
|
||||
// migration202606040004 merges migrations 202606030004, 202606040001, 202606040002, and 202606040003.
|
||||
// It adds Pages API proxying fields and RootDir/EntryFile to Pages projects,
|
||||
// backfills default entry_file to 'index.html', and ensures unused fields (root_dir, entry_file)
|
||||
// are dropped from Pages deployments.
|
||||
func migration202606040004(backend string, ctx Context) *presslygoose.Migration {
|
||||
return newGORMMigration(
|
||||
versionPagesFeaturesAndCleanup,
|
||||
"202606040004_add_pages_features_and_cleanup.go",
|
||||
backend,
|
||||
ctx,
|
||||
migratePagesFeaturesAndCleanup,
|
||||
)
|
||||
}
|
||||
|
||||
func migratePagesFeaturesAndCleanup(ctx Context, db *gorm.DB, backend string) error {
|
||||
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. Verify Pages projects columns
|
||||
cols := []string{
|
||||
"api_proxy_enabled", "api_proxy_path", "api_proxy_pass", "api_proxy_rewrite",
|
||||
"root_dir", "entry_file",
|
||||
}
|
||||
for _, col := range cols {
|
||||
if !db.Migrator().HasColumn("pages_projects", col) {
|
||||
return fmt.Errorf("column pages_projects.%s is missing", col)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Backfill pages_projects.entry_file to 'index.html' if empty
|
||||
type PagesProject struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
EntryFile string `gorm:"size:512;not null;default:'index.html'"`
|
||||
}
|
||||
if err := db.Model(&PagesProject{}).Where("entry_file = '' OR entry_file IS NULL").Update("entry_file", "index.html").Error; err != nil {
|
||||
return fmt.Errorf("failed to backfill pages_projects.entry_file: %w", err)
|
||||
}
|
||||
|
||||
// 3. Drop unused fields root_dir and entry_file from pages_deployments if they exist
|
||||
if db.Migrator().HasColumn("pages_deployments", "root_dir") {
|
||||
if err := db.Exec("ALTER TABLE pages_deployments DROP COLUMN root_dir").Error; err != nil {
|
||||
return fmt.Errorf("failed to drop pages_deployments.root_dir: %w", err)
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasColumn("pages_deployments", "entry_file") {
|
||||
if err := db.Exec("ALTER TABLE pages_deployments DROP COLUMN entry_file").Error; err != nil {
|
||||
return fmt.Errorf("failed to drop pages_deployments.entry_file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -44,6 +44,7 @@ func registeredMigrations(backend string, ctx Context) []*presslygoose.Migration
|
||||
migration202606030001(backend, ctx),
|
||||
migration202606030002(backend, ctx),
|
||||
migration202606030003(backend, ctx),
|
||||
migration202606040004(backend, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -830,3 +830,57 @@ func TestAllGORMModelsAreRegistered(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDatabaseSchemaUpToDateDropsPagesDeploymentUnusedFields(t *testing.T) {
|
||||
db := openBareTestSQLiteDB(t, "drop-pages-deployment-unused-fields.db")
|
||||
if err := registerSharding(db, "sqlite"); err != nil {
|
||||
t.Fatalf("register sharding: %v", err)
|
||||
}
|
||||
|
||||
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
|
||||
t.Fatalf("first ensureDatabaseSchemaUpToDate: %v", err)
|
||||
}
|
||||
|
||||
// Verify columns do not exist
|
||||
if db.Migrator().HasColumn("pages_deployments", "root_dir") {
|
||||
t.Fatal("expected root_dir column to be absent initially")
|
||||
}
|
||||
if db.Migrator().HasColumn("pages_deployments", "entry_file") {
|
||||
t.Fatal("expected entry_file column to be absent initially")
|
||||
}
|
||||
|
||||
// Manually add columns to simulate old state
|
||||
if err := db.Exec("ALTER TABLE pages_deployments ADD COLUMN root_dir TEXT").Error; err != nil {
|
||||
t.Fatalf("failed to add root_dir column: %v", err)
|
||||
}
|
||||
if err := db.Exec("ALTER TABLE pages_deployments ADD COLUMN entry_file TEXT").Error; err != nil {
|
||||
t.Fatalf("failed to add entry_file column: %v", err)
|
||||
}
|
||||
|
||||
// Verify columns were added
|
||||
if !db.Migrator().HasColumn("pages_deployments", "root_dir") {
|
||||
t.Fatal("expected root_dir column to be present after manual add")
|
||||
}
|
||||
if !db.Migrator().HasColumn("pages_deployments", "entry_file") {
|
||||
t.Fatal("expected entry_file column to be present after manual add")
|
||||
}
|
||||
|
||||
// Remove the migration record from goose_db_version table
|
||||
const versionToRerun = 202606040004
|
||||
if err := db.Exec("DELETE FROM goose_db_version WHERE version_id = ?", versionToRerun).Error; err != nil {
|
||||
t.Fatalf("failed to delete migration record: %v", err)
|
||||
}
|
||||
|
||||
// Run migration again
|
||||
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
|
||||
t.Fatalf("second ensureDatabaseSchemaUpToDate: %v", err)
|
||||
}
|
||||
|
||||
// Verify columns were dropped successfully
|
||||
if db.Migrator().HasColumn("pages_deployments", "root_dir") {
|
||||
t.Fatal("expected root_dir column to be dropped after migration rerun")
|
||||
}
|
||||
if db.Migrator().HasColumn("pages_deployments", "entry_file") {
|
||||
t.Fatal("expected entry_file column to be dropped after migration rerun")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,13 @@ type PagesProject struct {
|
||||
Enabled bool `json:"enabled" gorm:"not null;default:true"`
|
||||
SPAFallbackEnabled bool `json:"spa_fallback_enabled" gorm:"not null;default:false"`
|
||||
SPAFallbackPath string `json:"spa_fallback_path" gorm:"size:512;not null;default:'/index.html'"`
|
||||
APIProxyEnabled bool `json:"api_proxy_enabled" gorm:"not null;default:false"`
|
||||
APIProxyPath string `json:"api_proxy_path" gorm:"size:255;not null;default:''"`
|
||||
APIProxyPass string `json:"api_proxy_pass" gorm:"size:2048;not null;default:''"`
|
||||
APIProxyRewrite string `json:"api_proxy_rewrite" gorm:"size:255;not null;default:''"`
|
||||
ActiveDeploymentID *uint `json:"active_deployment_id" gorm:"index"`
|
||||
RootDir string `json:"root_dir" gorm:"size:512;not null;default:''"`
|
||||
EntryFile string `json:"entry_file" gorm:"size:512;not null;default:'index.html'"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -29,7 +35,6 @@ type PagesDeployment struct {
|
||||
ArtifactPath string `json:"artifact_path" gorm:"size:2048;not null"`
|
||||
FileCount int `json:"file_count" gorm:"not null;default:0"`
|
||||
TotalSize int64 `json:"total_size" gorm:"not null;default:0"`
|
||||
EntryFile string `json:"entry_file" gorm:"size:512;not null;default:'index.html'"`
|
||||
CreatedBy string `json:"created_by" gorm:"size:64;not null;default:''"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ActivatedAt *time.Time `json:"activated_at"`
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"openflare/common"
|
||||
"openflare/utils/security"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// User if you add sensitive fields, don't forget to clean them in setupLogin function.
|
||||
@@ -150,11 +149,13 @@ func (user *User) FillUserByUsername() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateUserToken looks up a user by their stored JWT token string.
|
||||
// JWT signature verification is handled by middleware/auth.go; this
|
||||
// function is used by Logout to find and clear the token from DB.
|
||||
func ValidateUserToken(token string) (user *User) {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
token = strings.Replace(token, "Bearer ", "", 1)
|
||||
user = &User{}
|
||||
if DB.Where("token = ?", token).First(user).RowsAffected == 1 {
|
||||
return user
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"openflare/common"
|
||||
"openflare/middleware"
|
||||
"openflare/model"
|
||||
"openflare/router"
|
||||
"openflare/service"
|
||||
@@ -95,7 +96,7 @@ func TestPhase1PublishLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
repeatPublishReq := httptest.NewRequest(http.MethodPost, "/api/config-versions/publish", nil)
|
||||
repeatPublishReq.Header.Set("Authorization", "Bearer "+token)
|
||||
repeatPublishReq.Header.Set("OpenFlare-Token", token)
|
||||
repeatPublishRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(repeatPublishRecorder, repeatPublishReq)
|
||||
if repeatPublishRecorder.Code != http.StatusOK {
|
||||
@@ -485,6 +486,7 @@ func setupTestDB(t *testing.T) {
|
||||
if err := model.InitDB(); err != nil {
|
||||
t.Fatalf("failed to init db: %v", err)
|
||||
}
|
||||
middleware.InitJWTMiddleware()
|
||||
t.Cleanup(func() {
|
||||
if err := model.CloseDB(); err != nil {
|
||||
t.Fatalf("failed to close db: %v", err)
|
||||
@@ -498,11 +500,15 @@ func prepareRootToken(t *testing.T) string {
|
||||
if err := user.FillUserByUsername(); err != nil {
|
||||
t.Fatalf("failed to load root user: %v", err)
|
||||
}
|
||||
user.Token = "phase1-test-token"
|
||||
if err := model.DB.Model(user).Update("token", user.Token).Error; err != nil {
|
||||
// Generate a proper JWT so auth middleware can validate it
|
||||
tokenString, _, err := middleware.JWTMiddleware.TokenGenerator(user)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate JWT for root user: %v", err)
|
||||
}
|
||||
if err := model.DB.Model(user).Update("token", tokenString).Error; err != nil {
|
||||
t.Fatalf("failed to set root token: %v", err)
|
||||
}
|
||||
return user.Token
|
||||
return tokenString
|
||||
}
|
||||
|
||||
func performJSONRequest(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse {
|
||||
@@ -519,7 +525,7 @@ func performJSONRequest(t *testing.T, engine http.Handler, token string, method
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("OpenFlare-Token", token)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
@@ -549,7 +555,7 @@ func performJSONRequestNoFatal(t *testing.T, engine http.Handler, token string,
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("OpenFlare-Token", token)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusOK && recorder.Code != http.StatusBadRequest {
|
||||
@@ -596,7 +602,7 @@ func performMultipartRequest(t *testing.T, engine http.Handler, token string, pa
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, path, &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("OpenFlare-Token", token)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
|
||||
@@ -127,7 +127,7 @@ func TestPhase2BatchOptionUpdateIsAtomic(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/option/update-batch", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(loginCookie)
|
||||
req.Header.Set("OpenFlare-Token", loginCookie)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
@@ -320,7 +320,7 @@ func TestExternalAccountBindingsCanBeListedAndDeleted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func loginAsRoot(t *testing.T, engine http.Handler) *http.Cookie {
|
||||
func loginAsRoot(t *testing.T, engine http.Handler) string {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"username": "root",
|
||||
@@ -346,16 +346,17 @@ func loginAsRoot(t *testing.T, engine http.Handler) *http.Cookie {
|
||||
t.Fatalf("root login failed: %s", resp.Message)
|
||||
}
|
||||
|
||||
for _, cookie := range recorder.Result().Cookies() {
|
||||
if cookie.Name == "session" {
|
||||
return cookie
|
||||
}
|
||||
var user model.User
|
||||
if err = json.Unmarshal(resp.Data, &user); err != nil {
|
||||
t.Fatalf("failed to decode login user: %v", err)
|
||||
}
|
||||
t.Fatal("expected session cookie after root login")
|
||||
return nil
|
||||
if user.Token == "" {
|
||||
t.Fatal("expected OpenFlare-Token after root login")
|
||||
}
|
||||
return user.Token
|
||||
}
|
||||
|
||||
func performSessionJSONRequest(t *testing.T, engine http.Handler, sessionCookie *http.Cookie, method string, path string, body any) apiResponse {
|
||||
func performSessionJSONRequest(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse {
|
||||
t.Helper()
|
||||
var payload []byte
|
||||
var err error
|
||||
@@ -370,7 +371,7 @@ func performSessionJSONRequest(t *testing.T, engine http.Handler, sessionCookie
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.AddCookie(sessionCookie)
|
||||
req.Header.Set("OpenFlare-Token", token)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
|
||||
@@ -133,7 +133,7 @@ func TestUptimeKumaSyncDisabled(t *testing.T) {
|
||||
|
||||
// Request sync, should fail
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/uptimekuma/sync", nil)
|
||||
req.AddCookie(loginCookie)
|
||||
req.Header.Set("OpenFlare-Token", loginCookie)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
|
||||
@@ -265,7 +265,7 @@ func TestUptimeKumaSyncSuccess(t *testing.T) {
|
||||
loginCookie := loginAsRoot(t, engine)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/uptimekuma/sync", nil)
|
||||
req.AddCookie(loginCookie)
|
||||
req.Header.Set("OpenFlare-Token", loginCookie)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
|
||||
@@ -405,7 +405,7 @@ func TestUptimeKumaSyncSelectedScope(t *testing.T) {
|
||||
loginCookie := loginAsRoot(t, engine)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/uptimekuma/sync", nil)
|
||||
req.AddCookie(loginCookie)
|
||||
req.Header.Set("OpenFlare-Token", loginCookie)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
|
||||
|
||||
@@ -77,13 +77,22 @@ func TestLatestReleaseProxy(t *testing.T) {
|
||||
if loginRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected login status code: %d", loginRecorder.Code)
|
||||
}
|
||||
loginResult := loginRecorder.Result()
|
||||
defer loginResult.Body.Close()
|
||||
var loginResp apiResponse
|
||||
if err = json.Unmarshal(loginRecorder.Body.Bytes(), &loginResp); err != nil {
|
||||
t.Fatalf("failed to decode login response: %v", err)
|
||||
}
|
||||
var loginUser struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err = json.Unmarshal(loginResp.Data, &loginUser); err != nil {
|
||||
t.Fatalf("failed to decode login user: %v", err)
|
||||
}
|
||||
if loginUser.Token == "" {
|
||||
t.Fatal("expected OpenFlare-Token after login")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/update/latest-release", nil)
|
||||
for _, cookieValue := range loginResult.Cookies() {
|
||||
req.AddCookie(cookieValue)
|
||||
}
|
||||
req.Header.Set("OpenFlare-Token", loginUser.Token)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
@@ -111,7 +120,7 @@ func TestLatestReleaseProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func loginRootAndBuildEngine(t *testing.T) (*gin.Engine, []*http.Cookie) {
|
||||
func loginRootAndBuildEngine(t *testing.T) (*gin.Engine, string) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.RedisEnabled = false
|
||||
@@ -135,10 +144,21 @@ func loginRootAndBuildEngine(t *testing.T) (*gin.Engine, []*http.Cookie) {
|
||||
if loginRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected login status code: %d", loginRecorder.Code)
|
||||
}
|
||||
loginResult := loginRecorder.Result()
|
||||
defer loginResult.Body.Close()
|
||||
var loginResp apiResponse
|
||||
if err = json.Unmarshal(loginRecorder.Body.Bytes(), &loginResp); err != nil {
|
||||
t.Fatalf("failed to decode login response: %v", err)
|
||||
}
|
||||
var loginUser struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err = json.Unmarshal(loginResp.Data, &loginUser); err != nil {
|
||||
t.Fatalf("failed to decode login user: %v", err)
|
||||
}
|
||||
if loginUser.Token == "" {
|
||||
t.Fatal("expected OpenFlare-Token after login")
|
||||
}
|
||||
|
||||
return engine, loginResult.Cookies()
|
||||
return engine, loginUser.Token
|
||||
}
|
||||
|
||||
func fakeManualServerBinary(version string) (string, []byte) {
|
||||
@@ -157,7 +177,7 @@ func TestManualUploadRoute(t *testing.T) {
|
||||
service.SetServerUpgradeDispatchDelayForTest(500 * time.Millisecond)
|
||||
})
|
||||
|
||||
engine, cookies := loginRootAndBuildEngine(t)
|
||||
engine, token := loginRootAndBuildEngine(t)
|
||||
fileName, content := fakeManualServerBinary("v0.5.0")
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
@@ -175,9 +195,7 @@ func TestManualUploadRoute(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/update/manual-upload", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
for _, cookieValue := range cookies {
|
||||
req.AddCookie(cookieValue)
|
||||
}
|
||||
req.Header.Set("OpenFlare-Token", token)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
@@ -189,96 +207,28 @@ func TestManualUploadRoute(t *testing.T) {
|
||||
if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("expected success response, got message: %s", resp.Message)
|
||||
if resp.Success {
|
||||
t.Fatal("expected failure response for disabled manual upload feature")
|
||||
}
|
||||
|
||||
var data map[string]any
|
||||
if err = json.Unmarshal(resp.Data, &data); err != nil {
|
||||
t.Fatalf("failed to decode response data: %v", err)
|
||||
}
|
||||
if data["detected_version"] != "v0.5.0" {
|
||||
t.Fatalf("unexpected detected_version: %#v", data["detected_version"])
|
||||
}
|
||||
if data["ready_to_upgrade"] != true {
|
||||
t.Fatalf("expected ready_to_upgrade to be true: %#v", data["ready_to_upgrade"])
|
||||
}
|
||||
if data["upload_token"] == "" {
|
||||
t.Fatal("expected upload_token to be returned")
|
||||
if resp.Message != "手动升级功能已禁用" {
|
||||
t.Fatalf("unexpected failure message: %s", resp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualUpgradeConfirmRoute(t *testing.T) {
|
||||
originalVersion := common.Version
|
||||
originalExecutor := service.ServerBinaryUpgradeExecutorForTest()
|
||||
originalDelay := service.ServerUpgradeDispatchDelayForTest()
|
||||
common.Version = "v0.4.0"
|
||||
called := make(chan string, 1)
|
||||
service.SetServerBinaryUpgradeExecutorForTest(func(execPath string, tempPath string) error {
|
||||
called <- tempPath
|
||||
return nil
|
||||
})
|
||||
service.SetServerUpgradeDispatchDelayForTest(0)
|
||||
t.Cleanup(func() {
|
||||
common.Version = originalVersion
|
||||
service.SetServerBinaryUpgradeExecutorForTest(originalExecutor)
|
||||
service.SetServerUpgradeDispatchDelayForTest(originalDelay)
|
||||
})
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.RedisEnabled = false
|
||||
setupTestDB(t)
|
||||
|
||||
engine, cookies := loginRootAndBuildEngine(t)
|
||||
fileName, content := fakeManualServerBinary("v0.5.0")
|
||||
engine, token := loginRootAndBuildEngine(t)
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("binary", fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create form file: %v", err)
|
||||
}
|
||||
if _, err = part.Write(content); err != nil {
|
||||
t.Fatalf("failed to write upload content: %v", err)
|
||||
}
|
||||
if err = writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
uploadReq := httptest.NewRequest(http.MethodPost, "/api/update/manual-upload", body)
|
||||
uploadReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
for _, cookieValue := range cookies {
|
||||
uploadReq.AddCookie(cookieValue)
|
||||
}
|
||||
|
||||
uploadRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(uploadRecorder, uploadReq)
|
||||
if uploadRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected upload status code: %d", uploadRecorder.Code)
|
||||
}
|
||||
|
||||
var uploadResp apiResponse
|
||||
if err = json.Unmarshal(uploadRecorder.Body.Bytes(), &uploadResp); err != nil {
|
||||
t.Fatalf("failed to decode upload response: %v", err)
|
||||
}
|
||||
if !uploadResp.Success {
|
||||
t.Fatalf("expected upload success, got message: %s", uploadResp.Message)
|
||||
}
|
||||
|
||||
var uploadData map[string]any
|
||||
if err = json.Unmarshal(uploadResp.Data, &uploadData); err != nil {
|
||||
t.Fatalf("failed to decode upload response data: %v", err)
|
||||
}
|
||||
uploadToken, _ := uploadData["upload_token"].(string)
|
||||
if uploadToken == "" {
|
||||
t.Fatal("expected upload token in upload response")
|
||||
}
|
||||
|
||||
confirmBody, err := json.Marshal(map[string]string{"upload_token": uploadToken})
|
||||
confirmBody, err := json.Marshal(map[string]string{"upload_token": "fake-token"})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal confirm body: %v", err)
|
||||
}
|
||||
confirmReq := httptest.NewRequest(http.MethodPost, "/api/update/manual-upgrade", bytes.NewReader(confirmBody))
|
||||
confirmReq.Header.Set("Content-Type", "application/json")
|
||||
for _, cookieValue := range cookies {
|
||||
confirmReq.AddCookie(cookieValue)
|
||||
}
|
||||
confirmReq.Header.Set("OpenFlare-Token", token)
|
||||
|
||||
confirmRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(confirmRecorder, confirmReq)
|
||||
@@ -290,16 +240,10 @@ func TestManualUpgradeConfirmRoute(t *testing.T) {
|
||||
if err = json.Unmarshal(confirmRecorder.Body.Bytes(), &confirmResp); err != nil {
|
||||
t.Fatalf("failed to decode confirm response: %v", err)
|
||||
}
|
||||
if !confirmResp.Success {
|
||||
t.Fatalf("expected confirm success, got message: %s", confirmResp.Message)
|
||||
if confirmResp.Success {
|
||||
t.Fatal("expected failure response for disabled manual upgrade feature")
|
||||
}
|
||||
|
||||
select {
|
||||
case tempPath := <-called:
|
||||
if tempPath == "" {
|
||||
t.Fatal("expected manual upgrade executor to receive temp path")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected manual upgrade executor to be called")
|
||||
if confirmResp.Message != "手动升级功能已禁用" {
|
||||
t.Fatalf("unexpected failure message: %s", confirmResp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
openrestyrender "openflare/utils/render/openresty"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -104,6 +105,10 @@ type snapshotPagesDeployment struct {
|
||||
EntryFile string `json:"entry_file"`
|
||||
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
|
||||
SPAFallbackPath string `json:"spa_fallback_path"`
|
||||
APIProxyEnabled bool `json:"api_proxy_enabled"`
|
||||
APIProxyPath string `json:"api_proxy_path"`
|
||||
APIProxyPass string `json:"api_proxy_pass"`
|
||||
APIProxyRewrite string `json:"api_proxy_rewrite"`
|
||||
LocalRoot string `json:"local_root"`
|
||||
}
|
||||
|
||||
@@ -606,16 +611,25 @@ func buildSnapshotPagesDeployment(projectID *uint) (*snapshotPagesDeployment, er
|
||||
if deployment.ProjectID != project.ID {
|
||||
return nil, errors.New("Pages 激活部署不属于当前项目")
|
||||
}
|
||||
localRoot := fmt.Sprintf("%s/deployments/%d/current", openrestyrender.PagesDirPlaceholder, deployment.ID)
|
||||
cleanedRootDir := strings.TrimSpace(project.RootDir)
|
||||
if cleanedRootDir != "" {
|
||||
localRoot = path.Join(localRoot, cleanedRootDir)
|
||||
}
|
||||
return &snapshotPagesDeployment{
|
||||
ProjectID: project.ID,
|
||||
ProjectSlug: project.Slug,
|
||||
DeploymentID: deployment.ID,
|
||||
DeploymentNumber: deployment.DeploymentNumber,
|
||||
Checksum: deployment.Checksum,
|
||||
EntryFile: deployment.EntryFile,
|
||||
EntryFile: project.EntryFile,
|
||||
SPAFallbackEnabled: project.SPAFallbackEnabled,
|
||||
SPAFallbackPath: normalizeStoredPagesFallbackPath(project.SPAFallbackPath),
|
||||
LocalRoot: fmt.Sprintf("%s/deployments/%d/current", openrestyrender.PagesDirPlaceholder, deployment.ID),
|
||||
APIProxyEnabled: project.APIProxyEnabled,
|
||||
APIProxyPath: project.APIProxyPath,
|
||||
APIProxyPass: project.APIProxyPass,
|
||||
APIProxyRewrite: project.APIProxyRewrite,
|
||||
LocalRoot: localRoot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/url"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"os"
|
||||
@@ -36,6 +37,12 @@ type PagesProjectInput struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
|
||||
SPAFallbackPath string `json:"spa_fallback_path"`
|
||||
APIProxyEnabled bool `json:"api_proxy_enabled"`
|
||||
APIProxyPath string `json:"api_proxy_path"`
|
||||
APIProxyPass string `json:"api_proxy_pass"`
|
||||
APIProxyRewrite string `json:"api_proxy_rewrite"`
|
||||
RootDir string `json:"root_dir"`
|
||||
EntryFile string `json:"entry_file"`
|
||||
}
|
||||
|
||||
type PagesProjectView struct {
|
||||
@@ -46,6 +53,12 @@ type PagesProjectView struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
|
||||
SPAFallbackPath string `json:"spa_fallback_path"`
|
||||
APIProxyEnabled bool `json:"api_proxy_enabled"`
|
||||
APIProxyPath string `json:"api_proxy_path"`
|
||||
APIProxyPass string `json:"api_proxy_pass"`
|
||||
APIProxyRewrite string `json:"api_proxy_rewrite"`
|
||||
RootDir string `json:"root_dir"`
|
||||
EntryFile string `json:"entry_file"`
|
||||
ActiveDeploymentID *uint `json:"active_deployment_id"`
|
||||
ActiveDeployment *PagesDeploymentView `json:"active_deployment,omitempty"`
|
||||
DeploymentCount int64 `json:"deployment_count"`
|
||||
@@ -61,7 +74,6 @@ type PagesDeploymentView struct {
|
||||
Status string `json:"status"`
|
||||
FileCount int `json:"file_count"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
EntryFile string `json:"entry_file"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ActivatedAt *time.Time `json:"activated_at"`
|
||||
@@ -137,6 +149,12 @@ func UpdatePagesProject(id uint, input PagesProjectInput) (*PagesProjectView, er
|
||||
"enabled": project.Enabled,
|
||||
"spa_fallback_enabled": project.SPAFallbackEnabled,
|
||||
"spa_fallback_path": project.SPAFallbackPath,
|
||||
"api_proxy_enabled": project.APIProxyEnabled,
|
||||
"api_proxy_path": project.APIProxyPath,
|
||||
"api_proxy_pass": project.APIProxyPass,
|
||||
"api_proxy_rewrite": project.APIProxyRewrite,
|
||||
"root_dir": project.RootDir,
|
||||
"entry_file": project.EntryFile,
|
||||
}).Error; err != nil {
|
||||
if model.IsUniqueConstraintError(err) {
|
||||
return nil, errors.New("Pages 项目标识已存在")
|
||||
@@ -216,7 +234,7 @@ func ListPagesDeploymentFiles(deploymentID uint) ([]*PagesDeploymentFileView, er
|
||||
return views, nil
|
||||
}
|
||||
|
||||
func UploadPagesDeployment(projectID uint, fileHeader *multipart.FileHeader, entryFile string, createdBy string) (*PagesDeploymentView, error) {
|
||||
func UploadPagesDeployment(projectID uint, fileHeader *multipart.FileHeader, rootDir string, entryFile string, createdBy string) (*PagesDeploymentView, error) {
|
||||
project, err := model.GetPagesProjectByID(projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -227,13 +245,17 @@ func UploadPagesDeployment(projectID uint, fileHeader *multipart.FileHeader, ent
|
||||
if !strings.EqualFold(filepath.Ext(fileHeader.Filename), ".zip") {
|
||||
return nil, errors.New("Pages 部署包必须是 .zip 文件")
|
||||
}
|
||||
entryFile = normalizePagesEntryFile(entryFile)
|
||||
rootDir, err = validateAndNormalizePagesRootDir(project.RootDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entryFile = normalizePagesEntryFile(project.EntryFile)
|
||||
tempPath, checksum, err := persistPagesUploadTemp(fileHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.Remove(tempPath)
|
||||
manifest, err := inspectPagesZip(tempPath, entryFile)
|
||||
manifest, err := inspectPagesZip(tempPath, rootDir, entryFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -264,7 +286,6 @@ func UploadPagesDeployment(projectID uint, fileHeader *multipart.FileHeader, ent
|
||||
ArtifactPath: artifactPath,
|
||||
FileCount: manifest.FileCount,
|
||||
TotalSize: manifest.TotalSize,
|
||||
EntryFile: manifest.EntryFile,
|
||||
CreatedBy: strings.TrimSpace(createdBy),
|
||||
}
|
||||
if err := tx.Create(deployment).Error; err != nil {
|
||||
@@ -287,6 +308,34 @@ func UploadPagesDeployment(projectID uint, fileHeader *multipart.FileHeader, ent
|
||||
return buildPagesDeploymentView(deployment), nil
|
||||
}
|
||||
|
||||
func validateAndNormalizePagesRootDir(raw string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(value) > 512 {
|
||||
return "", errors.New("Pages 根目录长度不能超过 512")
|
||||
}
|
||||
if strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") {
|
||||
return "", errors.New("Pages 根目录包含不支持的字符")
|
||||
}
|
||||
for _, r := range value {
|
||||
if r <= 0x20 || r == 0x7f {
|
||||
return "", errors.New("Pages 根目录不能包含空白或控制字符")
|
||||
}
|
||||
}
|
||||
cleaned := path.Clean(filepath.ToSlash(value))
|
||||
if cleaned == "." || cleaned == "/" {
|
||||
return "", nil
|
||||
}
|
||||
for _, segment := range strings.Split(cleaned, "/") {
|
||||
if segment == "." || segment == ".." {
|
||||
return "", errors.New("Pages 根目录不能包含 . 或 .. 路径段")
|
||||
}
|
||||
}
|
||||
return strings.TrimPrefix(cleaned, "/"), nil
|
||||
}
|
||||
|
||||
func ActivatePagesDeployment(projectID uint, deploymentID uint) (*PagesProjectView, error) {
|
||||
project, err := model.GetPagesProjectByID(projectID)
|
||||
if err != nil {
|
||||
@@ -413,6 +462,38 @@ func buildPagesProject(project *model.PagesProject, input PagesProjectInput) (*m
|
||||
return nil, err
|
||||
}
|
||||
project.SPAFallbackPath = fallbackPath
|
||||
|
||||
project.APIProxyEnabled = input.APIProxyEnabled
|
||||
apiProxyPath := strings.TrimSpace(input.APIProxyPath)
|
||||
apiProxyPass := strings.TrimSpace(input.APIProxyPass)
|
||||
apiProxyRewrite := strings.TrimSpace(input.APIProxyRewrite)
|
||||
|
||||
if project.APIProxyEnabled {
|
||||
if apiProxyPath == "" {
|
||||
return nil, errors.New("启用 API 反代时,匹配路径不能为空")
|
||||
}
|
||||
if !strings.HasPrefix(apiProxyPath, "/") {
|
||||
return nil, errors.New("API 反代匹配路径必须以 '/' 开头")
|
||||
}
|
||||
if apiProxyPass == "" {
|
||||
return nil, errors.New("启用 API 反代时,后端服务地址不能为空")
|
||||
}
|
||||
parsedURL, err := url.Parse(apiProxyPass)
|
||||
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" {
|
||||
return nil, errors.New("API 反代后端服务地址必须是有效的 HTTP/HTTPS URL")
|
||||
}
|
||||
}
|
||||
project.APIProxyPath = apiProxyPath
|
||||
project.APIProxyPass = apiProxyPass
|
||||
project.APIProxyRewrite = apiProxyRewrite
|
||||
|
||||
rootDir, err := validateAndNormalizePagesRootDir(input.RootDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
project.RootDir = rootDir
|
||||
project.EntryFile = normalizePagesEntryFile(input.EntryFile)
|
||||
|
||||
return project, nil
|
||||
}
|
||||
|
||||
@@ -428,6 +509,12 @@ func buildPagesProjectView(project *model.PagesProject) (*PagesProjectView, erro
|
||||
Enabled: project.Enabled,
|
||||
SPAFallbackEnabled: project.SPAFallbackEnabled,
|
||||
SPAFallbackPath: normalizeStoredPagesFallbackPath(project.SPAFallbackPath),
|
||||
APIProxyEnabled: project.APIProxyEnabled,
|
||||
APIProxyPath: project.APIProxyPath,
|
||||
APIProxyPass: project.APIProxyPass,
|
||||
APIProxyRewrite: project.APIProxyRewrite,
|
||||
RootDir: project.RootDir,
|
||||
EntryFile: project.EntryFile,
|
||||
ActiveDeploymentID: project.ActiveDeploymentID,
|
||||
CreatedAt: project.CreatedAt,
|
||||
UpdatedAt: project.UpdatedAt,
|
||||
@@ -456,7 +543,6 @@ func buildPagesDeploymentView(deployment *model.PagesDeployment) *PagesDeploymen
|
||||
Status: deployment.Status,
|
||||
FileCount: deployment.FileCount,
|
||||
TotalSize: deployment.TotalSize,
|
||||
EntryFile: deployment.EntryFile,
|
||||
CreatedBy: deployment.CreatedBy,
|
||||
CreatedAt: deployment.CreatedAt,
|
||||
ActivatedAt: deployment.ActivatedAt,
|
||||
@@ -602,7 +688,7 @@ func findCommonRootPrefix(files []*zip.File) (string, error) {
|
||||
return commonPrefix, nil
|
||||
}
|
||||
|
||||
func inspectPagesZip(zipPath string, entryFile string) (*pagesDeploymentManifest, error) {
|
||||
func inspectPagesZip(zipPath string, rootDir string, entryFile string) (*pagesDeploymentManifest, error) {
|
||||
reader, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return nil, errors.New("Pages 部署包不是有效 zip 文件")
|
||||
@@ -618,6 +704,10 @@ func inspectPagesZip(zipPath string, entryFile string) (*pagesDeploymentManifest
|
||||
Files: []model.PagesDeploymentFile{},
|
||||
EntryFile: entryFile,
|
||||
}
|
||||
targetEntryPath := entryFile
|
||||
if rootDir != "" {
|
||||
targetEntryPath = path.Join(rootDir, entryFile)
|
||||
}
|
||||
entrySeen := false
|
||||
for _, item := range reader.File {
|
||||
normalizedPath, skip, err := normalizePagesZipPath(item.Name)
|
||||
@@ -650,7 +740,7 @@ func inspectPagesZip(zipPath string, entryFile string) (*pagesDeploymentManifest
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if normalizedPath == entryFile {
|
||||
if normalizedPath == targetEntryPath {
|
||||
entrySeen = true
|
||||
}
|
||||
manifest.Files = append(manifest.Files, model.PagesDeploymentFile{
|
||||
@@ -663,7 +753,7 @@ func inspectPagesZip(zipPath string, entryFile string) (*pagesDeploymentManifest
|
||||
return nil, errors.New("Pages 部署包不能为空")
|
||||
}
|
||||
if !entrySeen {
|
||||
return nil, fmt.Errorf("Pages 部署包缺少入口文件 %s", entryFile)
|
||||
return nil, fmt.Errorf("Pages 部署包缺少入口文件 %s", targetEntryPath)
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http/httptest"
|
||||
"openflare/model"
|
||||
@@ -28,7 +29,7 @@ func TestPagesUploadActivateAndPublishStaticRoute(t *testing.T) {
|
||||
"assets/app.js": "console.log('pages')",
|
||||
"assets/style.css": "body{color:#111}",
|
||||
}))
|
||||
deployment, err := UploadPagesDeployment(project.ID, uploadHeader, "index.html", "root")
|
||||
deployment, err := UploadPagesDeployment(project.ID, uploadHeader, "", "index.html", "root")
|
||||
if err != nil {
|
||||
t.Fatalf("UploadPagesDeployment failed: %v", err)
|
||||
}
|
||||
@@ -109,7 +110,7 @@ func TestUploadPagesDeploymentRejectsZipSlip(t *testing.T) {
|
||||
_, err = UploadPagesDeployment(project.ID, multipartFileHeader(t, "bad.zip", testPagesZip(t, map[string]string{
|
||||
"../escape.html": "bad",
|
||||
"index.html": "ok",
|
||||
})), "index.html", "root")
|
||||
})), "", "index.html", "root")
|
||||
if err == nil || !strings.Contains(err.Error(), "逃逸目录") {
|
||||
t.Fatalf("expected zip-slip rejection, got %v", err)
|
||||
}
|
||||
@@ -145,7 +146,7 @@ func TestPagesDeploymentPackageRequiresActiveConfigSnapshot(t *testing.T) {
|
||||
}
|
||||
deployment, err := UploadPagesDeployment(project.ID, multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "ok",
|
||||
})), "index.html", "root")
|
||||
})), "", "index.html", "root")
|
||||
if err != nil {
|
||||
t.Fatalf("UploadPagesDeployment failed: %v", err)
|
||||
}
|
||||
@@ -228,7 +229,7 @@ func TestDeletePagesDeploymentRejectsActiveDeployment(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePagesProject failed: %v", err)
|
||||
}
|
||||
deployment, err := UploadPagesDeployment(project.ID, multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{"index.html": "ok"})), "index.html", "root")
|
||||
deployment, err := UploadPagesDeployment(project.ID, multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{"index.html": "ok"})), "", "index.html", "root")
|
||||
if err != nil {
|
||||
t.Fatalf("UploadPagesDeployment failed: %v", err)
|
||||
}
|
||||
@@ -260,14 +261,159 @@ func TestUploadPagesDeploymentWithTopLevelFolder(t *testing.T) {
|
||||
"Speed-Test-source/index.html": "<h1>Hello Pages</h1>",
|
||||
"Speed-Test-source/assets/app.js": "console.log('pages')",
|
||||
}))
|
||||
deployment, err := UploadPagesDeployment(project.ID, uploadHeader, "index.html", "root")
|
||||
deployment, err := UploadPagesDeployment(project.ID, uploadHeader, "", "index.html", "root")
|
||||
if err != nil {
|
||||
t.Fatalf("UploadPagesDeployment with folder failed: %v", err)
|
||||
}
|
||||
if deployment.FileCount != 2 {
|
||||
t.Fatalf("expected 2 files, got %d", deployment.FileCount)
|
||||
}
|
||||
if deployment.EntryFile != "index.html" {
|
||||
t.Fatalf("expected EntryFile to be index.html, got %q", deployment.EntryFile)
|
||||
if project.EntryFile != "index.html" {
|
||||
t.Fatalf("expected EntryFile to be index.html, got %q", project.EntryFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagesProjectAPIProxyValidation(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
// 1. Invalid configuration: enabled but empty fields
|
||||
_, err := CreatePagesProject(PagesProjectInput{
|
||||
Name: "API Proxy 1",
|
||||
Enabled: true,
|
||||
APIProxyEnabled: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "匹配路径不能为空") {
|
||||
t.Fatalf("expected error for empty match path, got: %v", err)
|
||||
}
|
||||
|
||||
// 2. Invalid path: must start with '/'
|
||||
_, err = CreatePagesProject(PagesProjectInput{
|
||||
Name: "API Proxy 2",
|
||||
Enabled: true,
|
||||
APIProxyEnabled: true,
|
||||
APIProxyPath: "api",
|
||||
APIProxyPass: "http://127.0.0.1:8080",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "必须以 '/' 开头") {
|
||||
t.Fatalf("expected error for path not starting with /, got: %v", err)
|
||||
}
|
||||
|
||||
// 3. Invalid target URL
|
||||
_, err = CreatePagesProject(PagesProjectInput{
|
||||
Name: "API Proxy 3",
|
||||
Enabled: true,
|
||||
APIProxyEnabled: true,
|
||||
APIProxyPath: "/api",
|
||||
APIProxyPass: "127.0.0.1:8080",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "有效的 HTTP/HTTPS URL") {
|
||||
t.Fatalf("expected error for invalid pass URL, got: %v", err)
|
||||
}
|
||||
|
||||
// 4. Valid configuration
|
||||
project, err := CreatePagesProject(PagesProjectInput{
|
||||
Name: "API Proxy Valid",
|
||||
Enabled: true,
|
||||
APIProxyEnabled: true,
|
||||
APIProxyPath: "/api",
|
||||
APIProxyPass: "http://127.0.0.1:8080",
|
||||
APIProxyRewrite: "/",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error creating valid project: %v", err)
|
||||
}
|
||||
if !project.APIProxyEnabled || project.APIProxyPath != "/api" || project.APIProxyPass != "http://127.0.0.1:8080" || project.APIProxyRewrite != "/" {
|
||||
t.Fatalf("unexpected project state: %+v", project)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadPagesDeploymentWithRootDir(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
project, err := CreatePagesProject(PagesProjectInput{
|
||||
Name: "App Site",
|
||||
Slug: "app-site",
|
||||
Enabled: true,
|
||||
RootDir: "build",
|
||||
EntryFile: "index.html",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePagesProject failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Upload a zip with files inside a subfolder.
|
||||
uploadHeader := multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{
|
||||
"build/index.html": "<h1>App Root</h1>",
|
||||
"build/static/bundle.js": "console.log('app')",
|
||||
"README.md": "README info",
|
||||
}))
|
||||
deployment, err := UploadPagesDeployment(project.ID, uploadHeader, "build", "index.html", "root")
|
||||
if err != nil {
|
||||
t.Fatalf("UploadPagesDeployment with rootDir failed: %v", err)
|
||||
}
|
||||
if deployment.FileCount != 3 {
|
||||
t.Fatalf("expected 3 files, got %d", deployment.FileCount)
|
||||
}
|
||||
if project.RootDir != "build" {
|
||||
t.Fatalf("expected RootDir to be 'build', got %q", project.RootDir)
|
||||
}
|
||||
if project.EntryFile != "index.html" {
|
||||
t.Fatalf("expected EntryFile to be 'index.html', got %q", project.EntryFile)
|
||||
}
|
||||
|
||||
// 2. Update project configuration to a wrong entry file relative to root directory, upload should fail
|
||||
project, err = UpdatePagesProject(project.ID, PagesProjectInput{
|
||||
Name: "App Site",
|
||||
Slug: "app-site",
|
||||
Enabled: true,
|
||||
RootDir: "build",
|
||||
EntryFile: "missing.html",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePagesProject failed: %v", err)
|
||||
}
|
||||
_, err = UploadPagesDeployment(project.ID, uploadHeader, "build", "missing.html", "root")
|
||||
if err == nil || !strings.Contains(err.Error(), "缺少入口文件") {
|
||||
t.Fatalf("expected failure for missing entry file, got %v", err)
|
||||
}
|
||||
|
||||
// Revert to correct config for snapshot check
|
||||
project, err = UpdatePagesProject(project.ID, PagesProjectInput{
|
||||
Name: "App Site",
|
||||
Slug: "app-site",
|
||||
Enabled: true,
|
||||
RootDir: "build",
|
||||
EntryFile: "index.html",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePagesProject failed: %v", err)
|
||||
}
|
||||
|
||||
// 3. Test config snapshot LocalRoot path rendering
|
||||
project, err = ActivatePagesDeployment(project.ID, deployment.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ActivatePagesDeployment failed: %v", err)
|
||||
}
|
||||
_, err = CreateProxyRoute(ProxyRouteInput{
|
||||
Domain: "app.example.com",
|
||||
Enabled: true,
|
||||
UpstreamType: "pages",
|
||||
PagesProjectID: &project.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProxyRoute failed: %v", err)
|
||||
}
|
||||
result, err := PublishConfigVersion("root", false)
|
||||
if err != nil {
|
||||
t.Fatalf("PublishConfigVersion failed: %v", err)
|
||||
}
|
||||
// Verify LocalRoot contains the rootDir
|
||||
expectedLocalRoot := fmt.Sprintf("deployments/%d/current/build", deployment.ID)
|
||||
if !strings.Contains(result.Version.SnapshotJSON, expectedLocalRoot) {
|
||||
t.Fatalf("expected snapshot JSON to include %q, got %s", expectedLocalRoot, result.Version.SnapshotJSON)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.Version.RenderedConfig, "current/build") {
|
||||
t.Fatalf("expected rendered config to point to current/build, got:\n%s", result.Version.RenderedConfig)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,8 +421,50 @@ func renderHTTPProxyServer(serverNames string, siteName string, originURL string
|
||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
|
||||
}
|
||||
|
||||
func renderPagesAPIProxyLocationBlock(deployment *PagesDeployment) string {
|
||||
if deployment == nil || !deployment.APIProxyEnabled {
|
||||
return ""
|
||||
}
|
||||
path := strings.TrimSpace(deployment.APIProxyPath)
|
||||
pass := strings.TrimSpace(deployment.APIProxyPass)
|
||||
rewrite := strings.TrimSpace(deployment.APIProxyRewrite)
|
||||
if path == "" || pass == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
cleanPath := strings.TrimSuffix(path, "/")
|
||||
|
||||
var builder strings.Builder
|
||||
builder.WriteString(fmt.Sprintf("\n location %s {\n", cleanPath))
|
||||
if rewrite != "" {
|
||||
if !strings.HasPrefix(rewrite, "/") {
|
||||
rewrite = "/" + rewrite
|
||||
}
|
||||
cleanRewrite := strings.TrimSuffix(rewrite, "/")
|
||||
if cleanRewrite == "" {
|
||||
builder.WriteString(fmt.Sprintf(" rewrite ^%s/(.*)$ /$1 break;\n", regexp.QuoteMeta(cleanPath)))
|
||||
builder.WriteString(fmt.Sprintf(" rewrite ^%s$ / break;\n", regexp.QuoteMeta(cleanPath)))
|
||||
} else {
|
||||
builder.WriteString(fmt.Sprintf(" rewrite ^%s/(.*)$ %s/$1 break;\n", regexp.QuoteMeta(cleanPath), cleanRewrite))
|
||||
builder.WriteString(fmt.Sprintf(" rewrite ^%s$ %s break;\n", regexp.QuoteMeta(cleanPath), cleanRewrite))
|
||||
}
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf(" proxy_pass %s;\n", pass))
|
||||
builder.WriteString(" proxy_http_version 1.1;\n")
|
||||
builder.WriteString(" proxy_set_header Host $http_host;\n")
|
||||
builder.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n")
|
||||
builder.WriteString(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n")
|
||||
builder.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n")
|
||||
builder.WriteString(" proxy_set_header Upgrade $http_upgrade;\n")
|
||||
builder.WriteString(" proxy_set_header Connection $connection_upgrade;\n")
|
||||
builder.WriteString(" }\n")
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func renderHTTPPagesServer(serverNames string, siteName string, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string) string {
|
||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s root %s;\n index %s;\n\n location / {\n%s%s }\n%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled))
|
||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s root %s;\n index %s;%s\n\n location / {\n%s%s }\n%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderPagesAPIProxyLocationBlock(deployment), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled))
|
||||
}
|
||||
|
||||
func renderHTTPRedirectServer(serverNames string) string {
|
||||
@@ -450,7 +492,7 @@ func renderHTTPSPagesServer(serverNames string, siteName string, certificateID u
|
||||
h3Listen = " listen 443 quic;\n"
|
||||
h3Header = " add_header Alt-Svc 'h3=\":443\"; ma=86400';\n"
|
||||
}
|
||||
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s root %s;\n index %s;\n\n location / {\n%s%s }\n%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled))
|
||||
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s root %s;\n index %s;%s\n\n location / {\n%s%s }\n%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderPagesAPIProxyLocationBlock(deployment), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled))
|
||||
}
|
||||
|
||||
func renderPagesLocationBlock(deployment *PagesDeployment, limitConfig routeLimitConfig) string {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package openresty
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderPagesAPIProxyLocationBlock(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deployment *PagesDeployment
|
||||
expected []string
|
||||
unexpected []string
|
||||
}{
|
||||
{
|
||||
name: "nil deployment",
|
||||
deployment: nil,
|
||||
expected: []string{""},
|
||||
},
|
||||
{
|
||||
name: "disabled proxy",
|
||||
deployment: &PagesDeployment{
|
||||
APIProxyEnabled: false,
|
||||
APIProxyPath: "/api",
|
||||
APIProxyPass: "http://127.0.0.1:8080",
|
||||
},
|
||||
expected: []string{""},
|
||||
},
|
||||
{
|
||||
name: "enabled proxy without rewrite",
|
||||
deployment: &PagesDeployment{
|
||||
APIProxyEnabled: true,
|
||||
APIProxyPath: "/api",
|
||||
APIProxyPass: "http://127.0.0.1:8080",
|
||||
APIProxyRewrite: "",
|
||||
},
|
||||
expected: []string{
|
||||
"location /api {",
|
||||
"proxy_pass http://127.0.0.1:8080;",
|
||||
"proxy_http_version 1.1;",
|
||||
"proxy_set_header Host $http_host;",
|
||||
},
|
||||
unexpected: []string{
|
||||
"rewrite",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "enabled proxy with rewrite to root",
|
||||
deployment: &PagesDeployment{
|
||||
APIProxyEnabled: true,
|
||||
APIProxyPath: "/api",
|
||||
APIProxyPass: "http://127.0.0.1:8080",
|
||||
APIProxyRewrite: "/",
|
||||
},
|
||||
expected: []string{
|
||||
"location /api {",
|
||||
"rewrite ^/api/(.*)$ /$1 break;",
|
||||
"rewrite ^/api$ / break;",
|
||||
"proxy_pass http://127.0.0.1:8080;",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "enabled proxy with rewrite to subpath",
|
||||
deployment: &PagesDeployment{
|
||||
APIProxyEnabled: true,
|
||||
APIProxyPath: "/api",
|
||||
APIProxyPass: "http://127.0.0.1:8080",
|
||||
APIProxyRewrite: "/v2",
|
||||
},
|
||||
expected: []string{
|
||||
"location /api {",
|
||||
"rewrite ^/api/(.*)$ /v2/$1 break;",
|
||||
"rewrite ^/api$ /v2 break;",
|
||||
"proxy_pass http://127.0.0.1:8080;",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := renderPagesAPIProxyLocationBlock(tt.deployment)
|
||||
if len(tt.expected) == 1 && tt.expected[0] == "" {
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty output, got: %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, exp := range tt.expected {
|
||||
if !strings.Contains(got, exp) {
|
||||
t.Errorf("expected output to contain %q, but got:\n%s", exp, got)
|
||||
}
|
||||
}
|
||||
for _, unexp := range tt.unexpected {
|
||||
if strings.Contains(got, unexp) {
|
||||
t.Errorf("expected output NOT to contain %q, but got:\n%s", unexp, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,10 @@ type PagesDeployment struct {
|
||||
EntryFile string `json:"entry_file"`
|
||||
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
|
||||
SPAFallbackPath string `json:"spa_fallback_path"`
|
||||
APIProxyEnabled bool `json:"api_proxy_enabled"`
|
||||
APIProxyPath string `json:"api_proxy_path"`
|
||||
APIProxyPass string `json:"api_proxy_pass"`
|
||||
APIProxyRewrite string `json:"api_proxy_rewrite"`
|
||||
LocalRoot string `json:"local_root"`
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +184,6 @@ button {
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description }: EmptyStateProps) {
|
||||
export function EmptyState({ title, description, children }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] px-5 py-6 text-sm">
|
||||
<p className="text-base font-semibold text-[var(--foreground-primary)]">
|
||||
@@ -14,6 +17,7 @@ export function EmptyState({ title, description }: EmptyStateProps) {
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
{children ? <div className="mt-4">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ function SidebarContent({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto no-scrollbar pr-1 flex flex-col gap-2">
|
||||
<nav className="no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto pr-1">
|
||||
{dashboardNavigation.map((item) => (
|
||||
<SidebarNavItem
|
||||
key={item.href}
|
||||
|
||||
@@ -214,10 +214,7 @@ export function DashboardTopbar() {
|
||||
socket.onmessage = (event) => {
|
||||
const snapshot = parseUpgradeStreamSnapshot(String(event.data));
|
||||
if (snapshot) {
|
||||
if (
|
||||
snapshot.in_progress ||
|
||||
snapshot.upgrade_status === 'succeeded'
|
||||
) {
|
||||
if (snapshot.in_progress || snapshot.upgrade_status === 'succeeded') {
|
||||
upgradeRefreshPendingRef.current = true;
|
||||
}
|
||||
if (snapshot.upgrade_status === 'failed') {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
logout as logoutRequest,
|
||||
getCurrentUser,
|
||||
} from '@/features/auth/api/auth';
|
||||
import { clearStoredOpenFlareToken } from '@/lib/api/auth-token';
|
||||
import type { AuthUser } from '@/types/auth';
|
||||
|
||||
interface AuthContextValue {
|
||||
@@ -41,6 +42,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
setUserState(nextUser);
|
||||
return nextUser;
|
||||
} catch {
|
||||
clearStoredOpenFlareToken();
|
||||
setUserState(null);
|
||||
return null;
|
||||
} finally {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
import {
|
||||
clearStoredOpenFlareToken,
|
||||
setStoredOpenFlareToken,
|
||||
} from '@/lib/api/auth-token';
|
||||
import type {
|
||||
AuthUser,
|
||||
LoginPayload,
|
||||
@@ -14,11 +18,18 @@ export function login(payload: LoginPayload) {
|
||||
return apiRequest<AuthUser>('/user/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}).then((user) => {
|
||||
if (user.token) {
|
||||
setStoredOpenFlareToken(user.token);
|
||||
}
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return apiRequest<void>('/user/logout');
|
||||
return apiRequest<void>('/user/logout').finally(() => {
|
||||
clearStoredOpenFlareToken();
|
||||
});
|
||||
}
|
||||
|
||||
export function register(payload: RegisterPayload) {
|
||||
@@ -48,7 +59,14 @@ export function resetPassword(payload: PasswordResetRequestPayload) {
|
||||
}
|
||||
|
||||
export function exchangeGitHubCode(code: string) {
|
||||
return apiRequest<AuthUser>(`/oauth/github?code=${encodeURIComponent(code)}`);
|
||||
return apiRequest<AuthUser>(
|
||||
`/oauth/github?code=${encodeURIComponent(code)}`,
|
||||
).then((user) => {
|
||||
if (user.token) {
|
||||
setStoredOpenFlareToken(user.token);
|
||||
}
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
export interface OAuthAuthorizeResult {
|
||||
@@ -79,12 +97,22 @@ export function exchangeOAuthCode(
|
||||
const searchParams = new URLSearchParams({ code, state });
|
||||
return apiRequest<OAuthCallbackResult>(
|
||||
`/oauth/${encodeURIComponent(String(source))}/callback?${searchParams.toString()}`,
|
||||
);
|
||||
).then((result) => {
|
||||
if (result.user?.token) {
|
||||
setStoredOpenFlareToken(result.user.token);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export function linkExistingOAuthAccount(payload: LinkExistingOAuthPayload) {
|
||||
return apiRequest<OAuthCallbackResult>('/oauth/link-existing', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}).then((result) => {
|
||||
if (result.user?.token) {
|
||||
setStoredOpenFlareToken(result.user.token);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,10 +31,7 @@ import {
|
||||
updateNode,
|
||||
} from '@/features/nodes/api/nodes';
|
||||
import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal';
|
||||
import type {
|
||||
NodeItem,
|
||||
NodeAgentReleaseInfo,
|
||||
} from '@/features/nodes/types';
|
||||
import type { NodeItem, NodeAgentReleaseInfo } from '@/features/nodes/types';
|
||||
import {
|
||||
CodeBlock,
|
||||
DangerButton,
|
||||
@@ -691,7 +688,9 @@ function EdgeNodeDetailPage({ node }: { node: NodeItem }) {
|
||||
总内存
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{formatBytes(observability.profile.total_memory_bytes)}
|
||||
{formatBytes(
|
||||
observability.profile.total_memory_bytes,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -735,7 +734,9 @@ function EdgeNodeDetailPage({ node }: { node: NodeItem }) {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<MetricBar
|
||||
label="CPU"
|
||||
value={formatPercent(latestMetricSnapshot.cpu_usage_percent)}
|
||||
value={formatPercent(
|
||||
latestMetricSnapshot.cpu_usage_percent,
|
||||
)}
|
||||
progress={latestMetricSnapshot.cpu_usage_percent}
|
||||
hint={
|
||||
isMeaningfulTime(latestMetricSnapshot.captured_at)
|
||||
@@ -794,7 +795,9 @@ function EdgeNodeDetailPage({ node }: { node: NodeItem }) {
|
||||
/>
|
||||
<StatusBadge
|
||||
label={getOpenrestyStatusLabel(node.openresty_status)}
|
||||
variant={getOpenrestyStatusVariant(node.openresty_status)}
|
||||
variant={getOpenrestyStatusVariant(
|
||||
node.openresty_status,
|
||||
)}
|
||||
/>
|
||||
<StatusBadge
|
||||
label={
|
||||
@@ -802,7 +805,9 @@ function EdgeNodeDetailPage({ node }: { node: NodeItem }) {
|
||||
? `${activeHealthEvents.length} 个活动异常`
|
||||
: '无活动异常'
|
||||
}
|
||||
variant={activeHealthEvents.length ? 'warning' : 'success'}
|
||||
variant={
|
||||
activeHealthEvents.length ? 'warning' : 'success'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -852,7 +857,9 @@ function EdgeNodeDetailPage({ node }: { node: NodeItem }) {
|
||||
</p>
|
||||
<p className="mt-3 text-2xl font-semibold text-[var(--foreground-primary)]">
|
||||
{trafficSummary
|
||||
? trafficSummary.request_count.toLocaleString('zh-CN')
|
||||
? trafficSummary.request_count.toLocaleString(
|
||||
'zh-CN',
|
||||
)
|
||||
: '—'}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
|
||||
@@ -1172,8 +1179,12 @@ function EdgeNodeDetailPage({ node }: { node: NodeItem }) {
|
||||
variant={getHealthEventVariant(event)}
|
||||
/>
|
||||
<StatusBadge
|
||||
label={event.status === 'active' ? '活动中' : '已恢复'}
|
||||
variant={event.status === 'active' ? 'warning' : 'success'}
|
||||
label={
|
||||
event.status === 'active' ? '活动中' : '已恢复'
|
||||
}
|
||||
variant={
|
||||
event.status === 'active' ? 'warning' : 'success'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-[var(--foreground-secondary)]">
|
||||
|
||||
@@ -257,12 +257,30 @@ function toPayload(values: NodeEditorValues): NodeMutationPayload {
|
||||
ip: values.ip.trim(),
|
||||
ip_manual_override: values.ip_manual_override,
|
||||
auto_update_enabled: values.auto_update_enabled,
|
||||
relay_bind_port: values.type === 'tunnel_relay' ? Number(values.relay_bind_port) : undefined,
|
||||
relay_vhost_http_port: values.type === 'tunnel_relay' ? Number(values.relay_vhost_http_port) : undefined,
|
||||
relay_client_access_addr: values.type === 'tunnel_relay' ? values.relay_client_access_addr.trim() : undefined,
|
||||
relay_agent_access_addr: values.type === 'tunnel_relay' ? values.relay_agent_access_addr.trim() : undefined,
|
||||
relay_client_proxy_url: values.type === 'tunnel_relay' ? values.relay_client_proxy_url.trim() : undefined,
|
||||
relay_web_server_enabled: values.type === 'tunnel_relay' ? values.relay_web_server_enabled : undefined,
|
||||
relay_bind_port:
|
||||
values.type === 'tunnel_relay'
|
||||
? Number(values.relay_bind_port)
|
||||
: undefined,
|
||||
relay_vhost_http_port:
|
||||
values.type === 'tunnel_relay'
|
||||
? Number(values.relay_vhost_http_port)
|
||||
: undefined,
|
||||
relay_client_access_addr:
|
||||
values.type === 'tunnel_relay'
|
||||
? values.relay_client_access_addr.trim()
|
||||
: undefined,
|
||||
relay_agent_access_addr:
|
||||
values.type === 'tunnel_relay'
|
||||
? values.relay_agent_access_addr.trim()
|
||||
: undefined,
|
||||
relay_client_proxy_url:
|
||||
values.type === 'tunnel_relay'
|
||||
? values.relay_client_proxy_url.trim()
|
||||
: undefined,
|
||||
relay_web_server_enabled:
|
||||
values.type === 'tunnel_relay'
|
||||
? values.relay_web_server_enabled
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (!values.geo_manual_override) {
|
||||
@@ -389,30 +407,39 @@ export function NodeEditorModal({
|
||||
</ResourceField>
|
||||
|
||||
{watchedType === 'tunnel_relay' && (
|
||||
<div className="space-y-5 rounded-lg border border-border p-4 bg-muted/50">
|
||||
<div className="border-border bg-muted/50 space-y-5 rounded-lg border p-4">
|
||||
<h4 className="text-sm font-medium">中继配置 (Relay Config)</h4>
|
||||
<ResourceField
|
||||
label="中继绑定端口 (Bind Port)"
|
||||
hint="中继服务端在此端口监听并接受 Flared 客户端连接。"
|
||||
error={form.formState.errors.relay_bind_port?.message}
|
||||
>
|
||||
<ResourceInput placeholder="7000" {...form.register('relay_bind_port')} />
|
||||
<ResourceInput
|
||||
placeholder="7000"
|
||||
{...form.register('relay_bind_port')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
|
||||
<ResourceField
|
||||
label="中继 Vhost HTTP 端口 (Vhost HTTP Port)"
|
||||
hint="中继服务端在此端口监听 HTTP 虚拟主机流量,用于 Openresty 转发到中继。"
|
||||
error={form.formState.errors.relay_vhost_http_port?.message}
|
||||
>
|
||||
<ResourceInput placeholder="8080" {...form.register('relay_vhost_http_port')} />
|
||||
<ResourceInput
|
||||
placeholder="8080"
|
||||
{...form.register('relay_vhost_http_port')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="边缘节点接入地址 (Edge Node Access Addr)"
|
||||
hint="用于 Edge Node 转发请求。默认与 Client Access Addr 或 IP + 绑定端口一致。"
|
||||
error={form.formState.errors.relay_agent_access_addr?.message}
|
||||
label="边缘节点接入地址 (Edge Node Access Addr)"
|
||||
hint="用于 Edge Node 转发请求。默认与 Client Access Addr 或 IP + 绑定端口一致。"
|
||||
error={form.formState.errors.relay_agent_access_addr?.message}
|
||||
>
|
||||
<ResourceInput placeholder="例如: 10.0.0.1:7000" {...form.register('relay_agent_access_addr')} />
|
||||
<ResourceInput
|
||||
placeholder="例如: 10.0.0.1:7000"
|
||||
{...form.register('relay_agent_access_addr')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
@@ -420,7 +447,10 @@ export function NodeEditorModal({
|
||||
hint="如果不填默认使用节点的 IP + 绑定端口。"
|
||||
error={form.formState.errors.relay_client_access_addr?.message}
|
||||
>
|
||||
<ResourceInput placeholder="例如: relay.example.com:7000" {...form.register('relay_client_access_addr')} />
|
||||
<ResourceInput
|
||||
placeholder="例如: relay.example.com:7000"
|
||||
{...form.register('relay_client_access_addr')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
@@ -428,7 +458,10 @@ export function NodeEditorModal({
|
||||
hint="可选,下发给客户端。当客户端连接中继需要经过 HTTP 代理时使用。"
|
||||
error={form.formState.errors.relay_client_proxy_url?.message}
|
||||
>
|
||||
<ResourceInput placeholder="例如: http://10.0.0.1:3128" {...form.register('relay_client_proxy_url')} />
|
||||
<ResourceInput
|
||||
placeholder="例如: http://10.0.0.1:3128"
|
||||
{...form.register('relay_client_proxy_url')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ToggleField
|
||||
|
||||
@@ -1,461 +1,483 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useSearchParams} from 'next/navigation';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {useMemo, useState} from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import {EmptyState} from '@/components/feedback/empty-state';
|
||||
import {ErrorState} from '@/components/feedback/error-state';
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {PageHeader} from '@/components/layout/page-header';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {StatusBadge} from '@/components/ui/status-badge';
|
||||
import {getConfigVersions} from '@/features/config-versions/api/config-versions';
|
||||
import {createNode, deleteNode, getNodes, updateNode,} from '@/features/nodes/api/nodes';
|
||||
import {NodeEditorModal} from '@/features/nodes/components/node-editor-modal';
|
||||
import type {NodeItem, NodeMutationPayload} from '@/features/nodes/types';
|
||||
import {DangerButton, SecondaryButton,} from '@/features/shared/components/resource-primitives';
|
||||
import {formatDateTime, formatRelativeTime} from '@/lib/utils/date';
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { getConfigVersions } from '@/features/config-versions/api/config-versions';
|
||||
import {
|
||||
getApplyLabel,
|
||||
getApplyVariant,
|
||||
getNodeStatusLabel,
|
||||
getNodeStatusVariant,
|
||||
getOpenrestyStatusLabel,
|
||||
getOpenrestyStatusVariant,
|
||||
getRelayStatusLabel,
|
||||
getRelayStatusVariant,
|
||||
isMeaningfulTime,
|
||||
isNodeAbnormal,
|
||||
isWSConnectedLastSeen,
|
||||
createNode,
|
||||
deleteNode,
|
||||
getNodes,
|
||||
updateNode,
|
||||
} from '@/features/nodes/api/nodes';
|
||||
import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal';
|
||||
import type { NodeItem, NodeMutationPayload } from '@/features/nodes/types';
|
||||
import {
|
||||
DangerButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||
import {
|
||||
getApplyLabel,
|
||||
getApplyVariant,
|
||||
getNodeStatusLabel,
|
||||
getNodeStatusVariant,
|
||||
getOpenrestyStatusLabel,
|
||||
getOpenrestyStatusVariant,
|
||||
getRelayStatusLabel,
|
||||
getRelayStatusVariant,
|
||||
isMeaningfulTime,
|
||||
isNodeAbnormal,
|
||||
isWSConnectedLastSeen,
|
||||
} from '@/features/nodes/utils';
|
||||
|
||||
const nodesQueryKey = ['nodes'];
|
||||
const supportedNodeFilters = ['all', 'edge', 'relay', 'tunnel', 'abnormal'] as const;
|
||||
const supportedNodeFilters = [
|
||||
'all',
|
||||
'edge',
|
||||
'relay',
|
||||
'tunnel',
|
||||
'abnormal',
|
||||
] as const;
|
||||
|
||||
type NodeFilter = (typeof supportedNodeFilters)[number];
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'info' | 'success' | 'danger';
|
||||
message: string;
|
||||
tone: 'info' | 'success' | 'danger';
|
||||
message: string;
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
export function NodesPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [editingNode, setEditingNode] = useState<NodeItem | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [editingNode, setEditingNode] = useState<NodeItem | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
|
||||
const nodesQuery = useQuery({
|
||||
queryKey: nodesQueryKey,
|
||||
queryFn: getNodes,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const nodesQuery = useQuery({
|
||||
queryKey: nodesQueryKey,
|
||||
queryFn: getNodes,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const configVersionsQuery = useQuery({
|
||||
queryKey: ['config-versions'],
|
||||
queryFn: getConfigVersions,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const configVersionsQuery = useQuery({
|
||||
queryKey: ['config-versions'],
|
||||
queryFn: getConfigVersions,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (payload: NodeMutationPayload) => {
|
||||
return editingNode
|
||||
? updateNode(editingNode.id, payload)
|
||||
: createNode(payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: editingNode ? '节点已更新。' : '节点已创建。',
|
||||
});
|
||||
setEditingNode(null);
|
||||
setIsEditorOpen(false);
|
||||
await queryClient.invalidateQueries({queryKey: nodesQueryKey});
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({tone: 'danger', message: getErrorMessage(error)});
|
||||
},
|
||||
});
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (payload: NodeMutationPayload) => {
|
||||
return editingNode
|
||||
? updateNode(editingNode.id, payload)
|
||||
: createNode(payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: editingNode ? '节点已更新。' : '节点已创建。',
|
||||
});
|
||||
setEditingNode(null);
|
||||
setIsEditorOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteNode,
|
||||
onSuccess: async () => {
|
||||
setFeedback({tone: 'success', message: '节点已删除。'});
|
||||
setEditingNode(null);
|
||||
await queryClient.invalidateQueries({queryKey: nodesQueryKey});
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({tone: 'danger', message: getErrorMessage(error)});
|
||||
},
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteNode,
|
||||
onSuccess: async () => {
|
||||
setFeedback({ tone: 'success', message: '节点已删除。' });
|
||||
setEditingNode(null);
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]);
|
||||
const activeVersion = useMemo(
|
||||
() =>
|
||||
(configVersionsQuery.data ?? []).find((item) => item.is_active)
|
||||
?.version ?? '',
|
||||
[configVersionsQuery.data],
|
||||
);
|
||||
const nodeFilter = useMemo<NodeFilter>(() => {
|
||||
const current = searchParams.get('filter')?.trim().toLowerCase() ?? '';
|
||||
if (supportedNodeFilters.includes(current as NodeFilter)) {
|
||||
return current as NodeFilter;
|
||||
}
|
||||
const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]);
|
||||
const activeVersion = useMemo(
|
||||
() =>
|
||||
(configVersionsQuery.data ?? []).find((item) => item.is_active)
|
||||
?.version ?? '',
|
||||
[configVersionsQuery.data],
|
||||
);
|
||||
const nodeFilter = useMemo<NodeFilter>(() => {
|
||||
const current = searchParams.get('filter')?.trim().toLowerCase() ?? '';
|
||||
if (supportedNodeFilters.includes(current as NodeFilter)) {
|
||||
return current as NodeFilter;
|
||||
}
|
||||
|
||||
const legacyRisk = searchParams.get('risk')?.trim().toLowerCase() ?? '';
|
||||
if (
|
||||
legacyRisk === 'offline' ||
|
||||
legacyRisk === 'unhealthy' ||
|
||||
legacyRisk === 'lagging'
|
||||
) {
|
||||
return 'abnormal';
|
||||
}
|
||||
const legacyRisk = searchParams.get('risk')?.trim().toLowerCase() ?? '';
|
||||
if (
|
||||
legacyRisk === 'offline' ||
|
||||
legacyRisk === 'unhealthy' ||
|
||||
legacyRisk === 'lagging'
|
||||
) {
|
||||
return 'abnormal';
|
||||
}
|
||||
|
||||
return 'all';
|
||||
}, [searchParams]);
|
||||
const filteredNodes = useMemo(() => {
|
||||
switch (nodeFilter) {
|
||||
case 'all':
|
||||
return nodes;
|
||||
case 'relay':
|
||||
return nodes.filter((node) => node.node_type === 'tunnel_relay');
|
||||
case 'tunnel':
|
||||
return nodes.filter((node) => node.node_type === 'tunnel_client');
|
||||
case 'abnormal':
|
||||
return nodes.filter((node) => isNodeAbnormal(node, activeVersion));
|
||||
case 'edge':
|
||||
default:
|
||||
return nodes.filter((node) => node.node_type === 'edge_node');
|
||||
}
|
||||
}, [activeVersion, nodeFilter, nodes]);
|
||||
return 'all';
|
||||
}, [searchParams]);
|
||||
const filteredNodes = useMemo(() => {
|
||||
switch (nodeFilter) {
|
||||
case 'all':
|
||||
return nodes;
|
||||
case 'relay':
|
||||
return nodes.filter((node) => node.node_type === 'tunnel_relay');
|
||||
case 'tunnel':
|
||||
return nodes.filter((node) => node.node_type === 'tunnel_client');
|
||||
case 'abnormal':
|
||||
return nodes.filter((node) => isNodeAbnormal(node, activeVersion));
|
||||
case 'edge':
|
||||
default:
|
||||
return nodes.filter((node) => node.node_type === 'edge_node');
|
||||
}
|
||||
}, [activeVersion, nodeFilter, nodes]);
|
||||
|
||||
const filterDescription = useMemo(() => {
|
||||
switch (nodeFilter) {
|
||||
case 'all':
|
||||
return '当前展示全部节点。';
|
||||
case 'relay':
|
||||
return '当前仅展示 Relay 节点。';
|
||||
case 'tunnel':
|
||||
return '当前仅展示 Tunnel 节点。';
|
||||
case 'abnormal':
|
||||
return activeVersion
|
||||
? `当前仅展示异常节点(离线、运行异常或未追平激活版本 ${activeVersion})。`
|
||||
: '当前仅展示异常节点(离线或运行异常)。';
|
||||
case 'edge':
|
||||
default:
|
||||
return '当前仅展示 Edge 节点。';
|
||||
}
|
||||
}, [activeVersion, nodeFilter]);
|
||||
const filterDescription = useMemo(() => {
|
||||
switch (nodeFilter) {
|
||||
case 'all':
|
||||
return '当前展示全部节点。';
|
||||
case 'relay':
|
||||
return '当前仅展示 Relay 节点。';
|
||||
case 'tunnel':
|
||||
return '当前仅展示 Tunnel 节点。';
|
||||
case 'abnormal':
|
||||
return activeVersion
|
||||
? `当前仅展示异常节点(离线、运行异常或未追平激活版本 ${activeVersion})。`
|
||||
: '当前仅展示异常节点(离线或运行异常)。';
|
||||
case 'edge':
|
||||
default:
|
||||
return '当前仅展示 Edge 节点。';
|
||||
}
|
||||
}, [activeVersion, nodeFilter]);
|
||||
|
||||
const handleReset = () => {
|
||||
setFeedback(null);
|
||||
setEditingNode(null);
|
||||
setIsEditorOpen(false);
|
||||
};
|
||||
const handleReset = () => {
|
||||
setFeedback(null);
|
||||
setEditingNode(null);
|
||||
setIsEditorOpen(false);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setFeedback(null);
|
||||
setEditingNode(null);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
const handleCreate = () => {
|
||||
setFeedback(null);
|
||||
setEditingNode(null);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (node: NodeItem) => {
|
||||
setFeedback(null);
|
||||
setEditingNode(node);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
const handleEdit = (node: NodeItem) => {
|
||||
setFeedback(null);
|
||||
setEditingNode(node);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (nodeId: number, nodeName: string) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`确认删除节点“${nodeName}”吗?删除后该节点需要重新创建并重新接入。`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const handleDelete = (nodeId: number, nodeName: string) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`确认删除节点“${nodeName}”吗?删除后该节点需要重新创建并重新接入。`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
deleteMutation.mutate(nodeId);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="节点管理"
|
||||
description="显示节点相关信息。"
|
||||
action={
|
||||
<>
|
||||
<SecondaryButton type="button" onClick={handleCreate}>
|
||||
新增节点
|
||||
</SecondaryButton>
|
||||
<Link
|
||||
href="/apply-log"
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
应用记录
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
setFeedback(null);
|
||||
deleteMutation.mutate(nodeId);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="节点管理"
|
||||
description="显示节点相关信息。"
|
||||
action={
|
||||
<>
|
||||
<SecondaryButton type="button" onClick={handleCreate}>
|
||||
新增节点
|
||||
</SecondaryButton>
|
||||
<Link
|
||||
href="/apply-log"
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
应用记录
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage
|
||||
tone={feedback.tone}
|
||||
message={feedback.message}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : null}
|
||||
{feedback ? (
|
||||
<InlineMessage
|
||||
tone={feedback.tone}
|
||||
message={feedback.message}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AppCard
|
||||
title="节点列表"
|
||||
description={filterDescription}
|
||||
action={
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void queryClient.invalidateQueries({queryKey: nodesQueryKey})
|
||||
}
|
||||
>
|
||||
立即刷新
|
||||
</SecondaryButton>
|
||||
}
|
||||
<AppCard
|
||||
title="节点列表"
|
||||
description={filterDescription}
|
||||
action={
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void queryClient.invalidateQueries({ queryKey: nodesQueryKey })
|
||||
}
|
||||
>
|
||||
立即刷新
|
||||
</SecondaryButton>
|
||||
}
|
||||
>
|
||||
{nodesQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : nodesQuery.isError ? (
|
||||
<ErrorState
|
||||
title="节点列表加载失败"
|
||||
description={getErrorMessage(nodesQuery.error)}
|
||||
/>
|
||||
) : filteredNodes.length === 0 ? (
|
||||
<EmptyState
|
||||
title={nodes.length === 0 ? '暂无节点' : '当前筛选无结果'}
|
||||
description={
|
||||
nodes.length === 0
|
||||
? '请先创建一个节点,然后进入详情页查看专属部署命令。'
|
||||
: '可以返回总览继续排查,或切换到其他筛选查看完整列表。'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/node"
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
|
||||
nodeFilter === 'all'
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
|
||||
}`}
|
||||
>
|
||||
{nodesQuery.isLoading ? (
|
||||
<LoadingState/>
|
||||
) : nodesQuery.isError ? (
|
||||
<ErrorState
|
||||
title="节点列表加载失败"
|
||||
description={getErrorMessage(nodesQuery.error)}
|
||||
/>
|
||||
) : filteredNodes.length === 0 ? (
|
||||
<EmptyState
|
||||
title={nodes.length === 0 ? '暂无节点' : '当前筛选无结果'}
|
||||
description={
|
||||
nodes.length === 0
|
||||
? '请先创建一个节点,然后进入详情页查看专属部署命令。'
|
||||
: '可以返回总览继续排查,或切换到其他筛选查看完整列表。'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/node"
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
|
||||
nodeFilter === 'all'
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
|
||||
}`}
|
||||
>
|
||||
全部节点
|
||||
</Link>
|
||||
<Link
|
||||
href="/node?filter=edge"
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
|
||||
nodeFilter === 'edge'
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
|
||||
}`}
|
||||
>
|
||||
Edge
|
||||
</Link>
|
||||
<Link
|
||||
href="/node?filter=relay"
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
|
||||
nodeFilter === 'relay'
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
|
||||
}`}
|
||||
>
|
||||
Relay
|
||||
</Link>
|
||||
<Link
|
||||
href="/node?filter=tunnel"
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
|
||||
nodeFilter === 'tunnel'
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
|
||||
}`}
|
||||
>
|
||||
Tunnel
|
||||
</Link>
|
||||
<Link
|
||||
href="/node?filter=abnormal"
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
|
||||
nodeFilter === 'abnormal'
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
|
||||
}`}
|
||||
>
|
||||
异常
|
||||
</Link>
|
||||
</div>
|
||||
全部节点
|
||||
</Link>
|
||||
<Link
|
||||
href="/node?filter=edge"
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
|
||||
nodeFilter === 'edge'
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
|
||||
}`}
|
||||
>
|
||||
Edge
|
||||
</Link>
|
||||
<Link
|
||||
href="/node?filter=relay"
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
|
||||
nodeFilter === 'relay'
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
|
||||
}`}
|
||||
>
|
||||
Relay
|
||||
</Link>
|
||||
<Link
|
||||
href="/node?filter=tunnel"
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
|
||||
nodeFilter === 'tunnel'
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
|
||||
}`}
|
||||
>
|
||||
Tunnel
|
||||
</Link>
|
||||
<Link
|
||||
href="/node?filter=abnormal"
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
|
||||
nodeFilter === 'abnormal'
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
|
||||
}`}
|
||||
>
|
||||
异常
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="text-[var(--foreground-secondary)]">
|
||||
<th className="px-3 py-3 font-medium">节点</th>
|
||||
<th className="px-3 py-3 font-medium">状态</th>
|
||||
<th className="px-3 py-3 font-medium">Version</th>
|
||||
<th className="px-3 py-3 font-medium">运行健康</th>
|
||||
<th className="px-3 py-3 font-medium">当前版本</th>
|
||||
<th className="px-3 py-3 font-medium">最近应用</th>
|
||||
<th className="px-3 py-3 font-medium">最近心跳</th>
|
||||
<th className="px-3 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{filteredNodes.map((node) => (
|
||||
<tr key={node.id} className="align-top">
|
||||
<td className="px-3 py-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-[var(--foreground-primary)]">
|
||||
{node.name}
|
||||
</p>
|
||||
{node.node_type === 'tunnel_relay' ? (
|
||||
<span
|
||||
className="rounded-full border border-blue-500/20 bg-blue-50 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="text-[var(--foreground-secondary)]">
|
||||
<th className="px-3 py-3 font-medium">节点</th>
|
||||
<th className="px-3 py-3 font-medium">状态</th>
|
||||
<th className="px-3 py-3 font-medium">Version</th>
|
||||
<th className="px-3 py-3 font-medium">运行健康</th>
|
||||
<th className="px-3 py-3 font-medium">当前版本</th>
|
||||
<th className="px-3 py-3 font-medium">最近应用</th>
|
||||
<th className="px-3 py-3 font-medium">最近心跳</th>
|
||||
<th className="px-3 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{filteredNodes.map((node) => (
|
||||
<tr key={node.id} className="align-top">
|
||||
<td className="px-3 py-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-[var(--foreground-primary)]">
|
||||
{node.name}
|
||||
</p>
|
||||
{node.node_type === 'tunnel_relay' ? (
|
||||
<span className="rounded-full border border-blue-500/20 bg-blue-50 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
Relay
|
||||
</span>
|
||||
) : node.node_type === 'tunnel_client' ? (
|
||||
<span
|
||||
className="rounded-full border border-purple-500/20 bg-purple-50 px-2 py-0.5 text-xs text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
|
||||
) : node.node_type === 'tunnel_client' ? (
|
||||
<span className="rounded-full border border-purple-500/20 bg-purple-50 px-2 py-0.5 text-xs text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
|
||||
Client
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="rounded-full border border-gray-500/20 bg-gray-50 px-2 py-0.5 text-xs text-gray-700 dark:bg-gray-800/50 dark:text-gray-300">
|
||||
) : (
|
||||
<span className="rounded-full border border-gray-500/20 bg-gray-50 px-2 py-0.5 text-xs text-gray-700 dark:bg-gray-800/50 dark:text-gray-300">
|
||||
Edge
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
IP:{node.ip || 'null'}
|
||||
{node.ip_manual_override ? '(已锁定)' : ''}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
位置:{node.geo_name || '未配置地图点位'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<StatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
variant={getNodeStatusVariant(node.status)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-sm text-[var(--foreground-secondary)]">
|
||||
{node.version || 'unknown'}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="space-y-2">
|
||||
{node.node_type === 'tunnel_relay' ? (
|
||||
<StatusBadge
|
||||
label={getRelayStatusLabel(node.relay_status)}
|
||||
variant={getRelayStatusVariant(
|
||||
node.relay_status,
|
||||
)}
|
||||
/>
|
||||
) : node.node_type === 'tunnel_client' ? (
|
||||
<StatusBadge
|
||||
label={node.status === 'online' ? '运行中' : '未知'}
|
||||
variant={node.status === 'online' ? 'success' : 'warning'}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge
|
||||
label={getOpenrestyStatusLabel(
|
||||
node.openresty_status,
|
||||
)}
|
||||
variant={getOpenrestyStatusVariant(
|
||||
node.openresty_status,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{node.current_version || (node.node_type === 'tunnel_relay' ? '实时配置' : '未应用')}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="space-y-2">
|
||||
{node.node_type === 'tunnel_relay' ? (
|
||||
<span className="text-sm text-[var(--foreground-secondary)]">—</span>
|
||||
) : (
|
||||
<StatusBadge
|
||||
label={getApplyLabel(node.latest_apply_result)}
|
||||
variant={getApplyVariant(
|
||||
node.latest_apply_result,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{isWSConnectedLastSeen(node.last_seen_at)
|
||||
? 'WS 已连接'
|
||||
: isMeaningfulTime(node.last_seen_at)
|
||||
? `${formatRelativeTime(
|
||||
node.last_seen_at,
|
||||
)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: '暂无'}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={`/node/detail?id=${node.id}`}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-3 py-2 text-xs font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
详情
|
||||
</Link>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => handleEdit(node)}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
编辑
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => handleDelete(node.id, node.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
IP:{node.ip || 'null'}
|
||||
{node.ip_manual_override ? '(已锁定)' : ''}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
位置:{node.geo_name || '未配置地图点位'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<StatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
variant={getNodeStatusVariant(node.status)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-sm text-[var(--foreground-secondary)]">
|
||||
{node.version || 'unknown'}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="space-y-2">
|
||||
{node.node_type === 'tunnel_relay' ? (
|
||||
<StatusBadge
|
||||
label={getRelayStatusLabel(node.relay_status)}
|
||||
variant={getRelayStatusVariant(
|
||||
node.relay_status,
|
||||
)}
|
||||
/>
|
||||
) : node.node_type === 'tunnel_client' ? (
|
||||
<StatusBadge
|
||||
label={
|
||||
node.status === 'online' ? '运行中' : '未知'
|
||||
}
|
||||
variant={
|
||||
node.status === 'online'
|
||||
? 'success'
|
||||
: 'warning'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge
|
||||
label={getOpenrestyStatusLabel(
|
||||
node.openresty_status,
|
||||
)}
|
||||
variant={getOpenrestyStatusVariant(
|
||||
node.openresty_status,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{node.current_version ||
|
||||
(node.node_type === 'tunnel_relay'
|
||||
? '实时配置'
|
||||
: '未应用')}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="space-y-2">
|
||||
{node.node_type === 'tunnel_relay' ? (
|
||||
<span className="text-sm text-[var(--foreground-secondary)]">
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
<StatusBadge
|
||||
label={getApplyLabel(node.latest_apply_result)}
|
||||
variant={getApplyVariant(
|
||||
node.latest_apply_result,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{isWSConnectedLastSeen(node.last_seen_at)
|
||||
? 'WS 已连接'
|
||||
: isMeaningfulTime(node.last_seen_at)
|
||||
? `${formatRelativeTime(
|
||||
node.last_seen_at,
|
||||
)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: '暂无'}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={`/node/detail?id=${node.id}`}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-3 py-2 text-xs font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
详情
|
||||
</Link>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => handleEdit(node)}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
编辑
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => handleDelete(node.id, node.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<NodeEditorModal
|
||||
isOpen={isEditorOpen}
|
||||
node={editingNode}
|
||||
isSubmitting={saveMutation.isPending}
|
||||
title={editingNode ? '编辑节点' : '新增节点'}
|
||||
onClose={handleReset}
|
||||
description="预创建节点后可在详情页查看专属 Token、部署命令与更新控制。"
|
||||
submitLabel={editingNode ? '保存修改' : '新增节点'}
|
||||
onSubmit={(payload) => {
|
||||
setFeedback(null);
|
||||
saveMutation.mutate(payload);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
<NodeEditorModal
|
||||
isOpen={isEditorOpen}
|
||||
node={editingNode}
|
||||
isSubmitting={saveMutation.isPending}
|
||||
title={editingNode ? '编辑节点' : '新增节点'}
|
||||
onClose={handleReset}
|
||||
description="预创建节点后可在详情页查看专属 Token、部署命令与更新控制。"
|
||||
submitLabel={editingNode ? '保存修改' : '新增节点'}
|
||||
onSubmit={(payload) => {
|
||||
setFeedback(null);
|
||||
saveMutation.mutate(payload);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -730,8 +730,6 @@ export function RelayDetailPage({ node }: { node: NodeItem }) {
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -766,7 +764,7 @@ export function RelayDetailPage({ node }: { node: NodeItem }) {
|
||||
href={`http://${node.ip || '127.0.0.1'}:${node.relay_bind_port + 500}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[var(--accent-strong)] hover:underline font-medium"
|
||||
className="font-medium text-[var(--accent-strong)] hover:underline"
|
||||
>
|
||||
点击打开 Web 界面
|
||||
</a>
|
||||
|
||||
@@ -27,10 +27,7 @@ import {
|
||||
updateNode,
|
||||
} from '@/features/nodes/api/nodes';
|
||||
import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal';
|
||||
import type {
|
||||
NodeItem,
|
||||
NodeAgentReleaseInfo,
|
||||
} from '@/features/nodes/types';
|
||||
import type { NodeItem, NodeAgentReleaseInfo } from '@/features/nodes/types';
|
||||
import {
|
||||
CodeBlock,
|
||||
DangerButton,
|
||||
@@ -217,9 +214,11 @@ export function TunnelDetailPage({ node }: { node: NodeItem }) {
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!window.confirm(
|
||||
`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`,
|
||||
)) {
|
||||
if (
|
||||
!window.confirm(
|
||||
`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setFeedback(null);
|
||||
@@ -266,7 +265,12 @@ export function TunnelDetailPage({ node }: { node: NodeItem }) {
|
||||
default:
|
||||
return observability?.health_events ?? [];
|
||||
}
|
||||
}, [activeHealthEvents, healthEventFilter, observability?.health_events, resolvedHealthEvents]);
|
||||
}, [
|
||||
activeHealthEvents,
|
||||
healthEventFilter,
|
||||
observability?.health_events,
|
||||
resolvedHealthEvents,
|
||||
]);
|
||||
|
||||
const tabs = useMemo(
|
||||
() =>
|
||||
@@ -550,7 +554,9 @@ export function TunnelDetailPage({ node }: { node: NodeItem }) {
|
||||
总内存
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{formatBytes(observability.profile.total_memory_bytes)}
|
||||
{formatBytes(
|
||||
observability.profile.total_memory_bytes,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -594,7 +600,9 @@ export function TunnelDetailPage({ node }: { node: NodeItem }) {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<MetricBar
|
||||
label="CPU"
|
||||
value={formatPercent(latestMetricSnapshot.cpu_usage_percent)}
|
||||
value={formatPercent(
|
||||
latestMetricSnapshot.cpu_usage_percent,
|
||||
)}
|
||||
progress={latestMetricSnapshot.cpu_usage_percent}
|
||||
hint={
|
||||
isMeaningfulTime(latestMetricSnapshot.captured_at)
|
||||
@@ -703,8 +711,12 @@ export function TunnelDetailPage({ node }: { node: NodeItem }) {
|
||||
variant={getHealthEventVariant(event)}
|
||||
/>
|
||||
<StatusBadge
|
||||
label={event.status === 'active' ? '活动中' : '已恢复'}
|
||||
variant={event.status === 'active' ? 'warning' : 'success'}
|
||||
label={
|
||||
event.status === 'active' ? '活动中' : '已恢复'
|
||||
}
|
||||
variant={
|
||||
event.status === 'active' ? 'warning' : 'success'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-[var(--foreground-secondary)]">
|
||||
@@ -1126,12 +1138,8 @@ export function TunnelDetailPage({ node }: { node: NodeItem }) {
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3 text-sm text-[var(--foreground-secondary)]">
|
||||
<p>
|
||||
该操作会物理删除此节点在控制端记录的所有健康诊断事件历史。
|
||||
</p>
|
||||
<p>
|
||||
这不会影响节点在后续运行中继续捕获并上报新的故障。
|
||||
</p>
|
||||
<p>该操作会物理删除此节点在控制端记录的所有健康诊断事件历史。</p>
|
||||
<p>这不会影响节点在后续运行中继续捕获并上报新的故障。</p>
|
||||
</div>
|
||||
)}
|
||||
</AppModal>
|
||||
@@ -1200,8 +1208,12 @@ export function TunnelDetailPage({ node }: { node: NodeItem }) {
|
||||
{selectedReleaseChannel === 'preview' ? '预览版' : '正式版'}
|
||||
</p>
|
||||
<StatusBadge
|
||||
label={selectedReleaseChannel === 'preview' ? 'Preview' : 'Stable'}
|
||||
variant={selectedReleaseChannel === 'preview' ? 'warning' : 'info'}
|
||||
label={
|
||||
selectedReleaseChannel === 'preview' ? 'Preview' : 'Stable'
|
||||
}
|
||||
variant={
|
||||
selectedReleaseChannel === 'preview' ? 'warning' : 'info'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
@@ -1254,7 +1266,9 @@ export function TunnelDetailPage({ node }: { node: NodeItem }) {
|
||||
? '发现可升级版本'
|
||||
: '当前已是最新版本'
|
||||
}
|
||||
variant={selectedAgentRelease.has_update ? 'warning' : 'success'}
|
||||
variant={
|
||||
selectedAgentRelease.has_update ? 'warning' : 'success'
|
||||
}
|
||||
/>
|
||||
{selectedAgentRelease.prerelease ? (
|
||||
<StatusBadge label="Preview 发布" variant="warning" />
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { NodeItem } from '@/features/nodes/types';
|
||||
|
||||
export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__';
|
||||
export const FLARED_WS_CONNECTED_LAST_SEEN = '__OPENFLARE_FLARED_WS_CONNECTED__';
|
||||
export const FLARED_WS_CONNECTED_LAST_SEEN =
|
||||
'__OPENFLARE_FLARED_WS_CONNECTED__';
|
||||
|
||||
export function isWSConnectedLastSeen(value: string | null | undefined) {
|
||||
return value === WS_CONNECTED_LAST_SEEN || value === FLARED_WS_CONNECTED_LAST_SEEN;
|
||||
return (
|
||||
value === WS_CONNECTED_LAST_SEEN || value === FLARED_WS_CONNECTED_LAST_SEEN
|
||||
);
|
||||
}
|
||||
|
||||
export function isMeaningfulTime(value: string | null | undefined) {
|
||||
|
||||
@@ -42,22 +42,28 @@ export function getPagesDeployments(projectId: number) {
|
||||
export function uploadPagesDeployment(
|
||||
projectId: number,
|
||||
file: File,
|
||||
rootDir = '',
|
||||
entryFile = 'index.html',
|
||||
onProgress?: (percent: number) => void,
|
||||
) {
|
||||
if (!onProgress) {
|
||||
const formData = new FormData();
|
||||
formData.append('package', file);
|
||||
formData.append('root_dir', rootDir);
|
||||
formData.append('entry_file', entryFile);
|
||||
return apiRequest<PagesDeployment>(`/pages/${projectId}/deployments/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
return apiRequest<PagesDeployment>(
|
||||
`/pages/${projectId}/deployments/upload`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise<PagesDeployment>((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append('package', file);
|
||||
formData.append('root_dir', rootDir);
|
||||
formData.append('entry_file', entryFile);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
@@ -86,7 +92,12 @@ export function uploadPagesDeployment(
|
||||
reject(new ApiError(payload?.message || '请求失败', xhr.status));
|
||||
}
|
||||
} else {
|
||||
reject(new ApiError(payload?.message || `请求失败(${xhr.status})`, xhr.status));
|
||||
reject(
|
||||
new ApiError(
|
||||
payload?.message || `请求失败(${xhr.status})`,
|
||||
xhr.status,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, useRef } from 'react';
|
||||
import { UploadCloud, File as FileIcon, X } from 'lucide-react';
|
||||
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import {
|
||||
activatePagesDeployment,
|
||||
uploadPagesDeployment,
|
||||
} from '@/features/pages/api/pages';
|
||||
import {
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import {
|
||||
deploymentsQueryKey,
|
||||
projectQueryKey,
|
||||
projectsQueryKey,
|
||||
} from '../utils';
|
||||
|
||||
interface PagesDeploymentUploadModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number, decimals = 2) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export function PagesDeploymentUploadModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
projectId,
|
||||
}: PagesDeploymentUploadModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isDragActive, setIsDragActive] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const resetForm = () => {
|
||||
setFile(null);
|
||||
setIsDragActive(false);
|
||||
setUploadProgress(null);
|
||||
setErrorMessage(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||
setIsDragActive(true);
|
||||
} else if (e.type === 'dragleave') {
|
||||
setIsDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragActive(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
const droppedFile = e.dataTransfer.files[0];
|
||||
if (droppedFile.name.toLowerCase().endsWith('.zip')) {
|
||||
setFile(droppedFile);
|
||||
setErrorMessage(null);
|
||||
} else {
|
||||
setErrorMessage('仅支持 zip 格式的文件');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async ({ shouldActivate }: { shouldActivate: boolean }) => {
|
||||
if (!file) {
|
||||
throw new Error('请选择 zip 文件');
|
||||
}
|
||||
setUploadProgress(0);
|
||||
setErrorMessage(null);
|
||||
|
||||
const deployment = await uploadPagesDeployment(
|
||||
projectId,
|
||||
file,
|
||||
'',
|
||||
'index.html',
|
||||
(percent) => {
|
||||
setUploadProgress(percent);
|
||||
},
|
||||
);
|
||||
|
||||
if (shouldActivate) {
|
||||
await activatePagesDeployment(projectId, deployment.id);
|
||||
}
|
||||
return deployment;
|
||||
},
|
||||
onSuccess: () => {
|
||||
resetForm();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(projectId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: projectQueryKey(projectId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: projectsQueryKey,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
setUploadProgress(null);
|
||||
setErrorMessage(error instanceof Error ? error.message : '上传失败');
|
||||
},
|
||||
});
|
||||
|
||||
const handleUploadOnly = () => {
|
||||
uploadMutation.mutate({ shouldActivate: false });
|
||||
};
|
||||
|
||||
const handleUploadAndDeploy = () => {
|
||||
uploadMutation.mutate({ shouldActivate: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title="上传部署包"
|
||||
description="上传已构建的 zip 静态资源包。项目将使用配置的根目录与入口文件路径。"
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={uploadMutation.isPending}
|
||||
>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
disabled={!file || uploadMutation.isPending}
|
||||
onClick={handleUploadOnly}
|
||||
>
|
||||
{uploadMutation.isPending &&
|
||||
!uploadMutation.variables?.shouldActivate
|
||||
? `上传中 (${uploadProgress ?? 0}%)...`
|
||||
: '上传'}
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
disabled={!file || uploadMutation.isPending}
|
||||
onClick={handleUploadAndDeploy}
|
||||
>
|
||||
{uploadMutation.isPending &&
|
||||
uploadMutation.variables?.shouldActivate
|
||||
? `上传并部署中 (${uploadProgress ?? 0}%)...`
|
||||
: '上传并部署'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Hidden File Input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={(event) => {
|
||||
const selectedFile = event.target.files?.[0] ?? null;
|
||||
if (selectedFile) {
|
||||
if (selectedFile.name.toLowerCase().endsWith('.zip')) {
|
||||
setFile(selectedFile);
|
||||
setErrorMessage(null);
|
||||
} else {
|
||||
setErrorMessage('仅支持 zip 格式的文件');
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="block space-y-2">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
<span>部署包</span>
|
||||
</span>
|
||||
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
'group relative flex min-h-48 cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[var(--border-default)] bg-[var(--surface-elevated)] p-6 text-center transition-all duration-200 hover:border-[var(--brand-primary)] hover:bg-[var(--surface-hover)]',
|
||||
isDragActive &&
|
||||
'scale-[0.99] border-[var(--brand-primary)] bg-[var(--accent-soft)]/20 shadow-inner',
|
||||
file && 'border-solid border-[var(--brand-primary)]',
|
||||
)}
|
||||
>
|
||||
{file ? (
|
||||
<div
|
||||
className="flex flex-col items-center space-y-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[var(--brand-primary)] transition duration-200 group-hover:scale-110">
|
||||
<FileIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="max-w-[300px] truncate text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
大小: {formatBytes(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-[var(--border-default)] bg-[var(--control-background)] px-3 text-xs font-medium text-[var(--foreground-primary)] transition hover:border-[var(--status-danger-border)] hover:bg-[var(--status-danger-soft)] hover:text-[var(--status-danger-foreground)]"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
清除文件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pointer-events-none flex flex-col items-center space-y-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--surface-muted)] text-[var(--foreground-muted)] transition duration-200 group-hover:scale-110 group-hover:bg-[var(--accent-soft)] group-hover:text-[var(--brand-primary)]">
|
||||
<UploadCloud className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
点击或拖拽 zip 部署包到此处
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
仅支持 zip,Server 会校验文件数量、体积、路径逃逸和入口文件
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<span className="mt-1 block text-xs text-[var(--status-danger-foreground)]">
|
||||
{errorMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploadProgress !== null && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs font-medium text-[var(--foreground-secondary)]">
|
||||
<span>上传进度</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--surface-muted)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--brand-primary)] transition-all duration-300 ease-out"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import {
|
||||
activatePagesDeployment,
|
||||
deletePagesDeployment,
|
||||
deletePagesProject,
|
||||
getPagesProject,
|
||||
getPagesDeployments,
|
||||
} from '@/features/pages/api/pages';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { PagesProjectEditorModal } from './pages-project-editor-modal';
|
||||
import { PagesDeploymentUploadModal } from './pages-deployment-upload-modal';
|
||||
import { formatBytes } from '@/lib/utils/metrics';
|
||||
import {
|
||||
deploymentsQueryKey,
|
||||
projectQueryKey,
|
||||
projectsQueryKey,
|
||||
} from '../utils';
|
||||
|
||||
export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [isEditModalOpen, setEditModalOpen] = useState(false);
|
||||
const [isUploadModalOpen, setUploadModalOpen] = useState(false);
|
||||
|
||||
const parsedProjectId = Number(projectId);
|
||||
const projectQuery = useQuery({
|
||||
queryKey: projectQueryKey(projectId),
|
||||
queryFn: () => getPagesProject(parsedProjectId),
|
||||
enabled: projectId !== '' && Number.isFinite(parsedProjectId),
|
||||
});
|
||||
const deploymentsQuery = useQuery({
|
||||
queryKey: deploymentsQueryKey(parsedProjectId),
|
||||
queryFn: () => getPagesDeployments(parsedProjectId),
|
||||
enabled: projectId !== '' && Number.isFinite(parsedProjectId),
|
||||
});
|
||||
|
||||
const activateMutation = useMutation({
|
||||
mutationFn: (deploymentId: number) =>
|
||||
activatePagesDeployment(parsedProjectId, deploymentId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(parsedProjectId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteDeploymentMutation = useMutation({
|
||||
mutationFn: (deploymentId: number) =>
|
||||
deletePagesDeployment(parsedProjectId, deploymentId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(parsedProjectId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteProjectMutation = useMutation({
|
||||
mutationFn: () => deletePagesProject(parsedProjectId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
router.push('/pages');
|
||||
},
|
||||
});
|
||||
|
||||
if (projectId === '' || !Number.isFinite(parsedProjectId)) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Pages 项目不存在"
|
||||
description="缺少有效的 Pages 项目 ID,请从项目列表重新进入。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (projectQuery.isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (projectQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="Pages 项目加载失败"
|
||||
description={projectQuery.error.message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const project = projectQuery.data;
|
||||
if (!project) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Pages 项目不存在"
|
||||
description="该项目可能已被删除,或当前 ID 无法匹配到项目记录。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDeleteProject = () => {
|
||||
if (!window.confirm(`确认删除 Pages 项目 ${project.name} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
deleteProjectMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={project.name}
|
||||
description={`${project.slug} · Pages 静态站点项目详情`}
|
||||
action={
|
||||
<>
|
||||
<Link
|
||||
href="/pages"
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
返回列表
|
||||
</Link>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setEditModalOpen(true)}
|
||||
>
|
||||
编辑项目
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => setUploadModalOpen(true)}
|
||||
>
|
||||
上传部署包
|
||||
</PrimaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
disabled={deleteProjectMutation.isPending}
|
||||
onClick={handleDeleteProject}
|
||||
>
|
||||
删除项目
|
||||
</DangerButton>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<AppCard
|
||||
title="部署历史"
|
||||
description="部署不可变;激活后发布配置,Agent 才会拉取并切换静态资源。"
|
||||
action={
|
||||
(deploymentsQuery.data ?? []).length > 0 ? (
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => setUploadModalOpen(true)}
|
||||
>
|
||||
上传部署包
|
||||
</PrimaryButton>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{deploymentsQuery.isLoading ? (
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
加载中...
|
||||
</p>
|
||||
) : deploymentsQuery.isError ? (
|
||||
<p className="text-sm text-[var(--status-danger-foreground)]">
|
||||
{deploymentsQuery.error.message}
|
||||
</p>
|
||||
) : (deploymentsQuery.data ?? []).length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无部署"
|
||||
description="上传 zip 部署包后,可以在这里激活某个部署版本。"
|
||||
>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => setUploadModalOpen(true)}
|
||||
>
|
||||
上传部署包
|
||||
</PrimaryButton>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--border-default)]">
|
||||
{(deploymentsQuery.data ?? []).map((deployment) => (
|
||||
<div
|
||||
key={deployment.id}
|
||||
className="flex flex-col gap-3 border-b border-[var(--border-default)] p-4 last:border-b-0 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
#{deployment.deployment_number}{' '}
|
||||
{deployment.status === 'active' ? '· 已激活' : ''}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[var(--foreground-secondary)]">
|
||||
{deployment.checksum.slice(0, 16)} · {deployment.file_count}{' '}
|
||||
files · {formatBytes(deployment.total_size)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
disabled={
|
||||
deployment.status === 'active' ||
|
||||
activateMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`确认激活部署 #${deployment.deployment_number} 吗?`,
|
||||
)
|
||||
) {
|
||||
activateMutation.mutate(deployment.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
激活
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
disabled={
|
||||
deployment.status === 'active' ||
|
||||
deleteDeploymentMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`确认删除部署 #${deployment.deployment_number} 吗?`,
|
||||
)
|
||||
) {
|
||||
deleteDeploymentMutation.mutate(deployment.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
|
||||
<PagesProjectEditorModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={() => setEditModalOpen(false)}
|
||||
project={project}
|
||||
/>
|
||||
|
||||
<PagesDeploymentUploadModal
|
||||
isOpen={isUploadModalOpen}
|
||||
onClose={() => setUploadModalOpen(false)}
|
||||
projectId={parsedProjectId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { getPagesProjects } from '@/features/pages/api/pages';
|
||||
import {
|
||||
activatePagesDeployment,
|
||||
createPagesProject,
|
||||
deletePagesDeployment,
|
||||
deletePagesProject,
|
||||
getPagesProject,
|
||||
getPagesDeployments,
|
||||
getPagesProjects,
|
||||
updatePagesProject,
|
||||
uploadPagesDeployment,
|
||||
} from '@/features/pages/api/pages';
|
||||
import type { PagesProject } from '@/features/pages/types';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
SecondaryButton,
|
||||
ToggleField,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { PagesProjectEditorModal } from './pages-project-editor-modal';
|
||||
import { PagesProjectListItem } from './pages-project-list-item';
|
||||
import { projectsQueryKey } from '../utils';
|
||||
|
||||
const projectsQueryKey = ['pages-projects'];
|
||||
|
||||
function projectQueryKey(projectId: string | number) {
|
||||
return ['pages-project', String(projectId)];
|
||||
}
|
||||
|
||||
function deploymentsQueryKey(projectId: string | number) {
|
||||
return ['pages-deployments', Number(projectId)];
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (value < 1024) {
|
||||
return `${value} B`;
|
||||
}
|
||||
if (value < 1024 * 1024) {
|
||||
return `${(value / 1024).toFixed(1)} KiB`;
|
||||
}
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) {
|
||||
return '未激活';
|
||||
}
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
export { PagesProjectDetailPage } from './pages-detail-page';
|
||||
|
||||
export function PagesPage() {
|
||||
const [isCreateModalOpen, setCreateModalOpen] = useState(false);
|
||||
@@ -79,8 +34,8 @@ export function PagesPage() {
|
||||
边缘静态站点托管
|
||||
</h1>
|
||||
<p className="max-w-3xl text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
创建 Pages 项目,上传已构建的 zip 静态资源包,然后在规则中选择 Pages
|
||||
项目作为上游。发布后 Agent
|
||||
创建 Pages 项目,上传已构建 of zip 静态资源包,然后在规则中选择
|
||||
Pages 项目作为上游。发布后 Agent
|
||||
会拉取部署包并在边缘节点本地服务静态文件。
|
||||
</p>
|
||||
</div>
|
||||
@@ -114,8 +69,7 @@ export function PagesPage() {
|
||||
新建 Pages 项目
|
||||
</SecondaryButton>
|
||||
}
|
||||
>
|
||||
</AppCard>
|
||||
/>
|
||||
) : (
|
||||
(projectsQuery.data ?? []).map((project) => (
|
||||
<PagesProjectListItem key={project.id} project={project} />
|
||||
@@ -123,608 +77,10 @@ export function PagesPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PagesProjectCreateModal
|
||||
<PagesProjectEditorModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setCreateModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [entryFile, setEntryFile] = useState('index.html');
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
const [isEditModalOpen, setEditModalOpen] = useState(false);
|
||||
|
||||
const parsedProjectId = Number(projectId);
|
||||
const projectQuery = useQuery({
|
||||
queryKey: projectQueryKey(projectId),
|
||||
queryFn: () => getPagesProject(parsedProjectId),
|
||||
enabled: projectId !== '' && Number.isFinite(parsedProjectId),
|
||||
});
|
||||
const deploymentsQuery = useQuery({
|
||||
queryKey: deploymentsQueryKey(parsedProjectId),
|
||||
queryFn: () => getPagesDeployments(parsedProjectId),
|
||||
enabled: projectId !== '' && Number.isFinite(parsedProjectId),
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!file) {
|
||||
throw new Error('请选择 zip 文件');
|
||||
}
|
||||
setUploadProgress(0);
|
||||
return uploadPagesDeployment(parsedProjectId, file, entryFile, (percent) => {
|
||||
setUploadProgress(percent);
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setFile(null);
|
||||
setUploadProgress(null);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(parsedProjectId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
},
|
||||
onError: () => {
|
||||
setUploadProgress(null);
|
||||
},
|
||||
});
|
||||
const activateMutation = useMutation({
|
||||
mutationFn: (deploymentId: number) =>
|
||||
activatePagesDeployment(parsedProjectId, deploymentId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(parsedProjectId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
},
|
||||
});
|
||||
const deleteDeploymentMutation = useMutation({
|
||||
mutationFn: (deploymentId: number) =>
|
||||
deletePagesDeployment(parsedProjectId, deploymentId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(parsedProjectId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
},
|
||||
});
|
||||
const deleteProjectMutation = useMutation({
|
||||
mutationFn: () => deletePagesProject(parsedProjectId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
router.push('/pages');
|
||||
},
|
||||
});
|
||||
|
||||
if (projectId === '' || !Number.isFinite(parsedProjectId)) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Pages 项目不存在"
|
||||
description="缺少有效的 Pages 项目 ID,请从项目列表重新进入。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (projectQuery.isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (projectQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="Pages 项目加载失败"
|
||||
description={projectQuery.error.message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const project = projectQuery.data;
|
||||
if (!project) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Pages 项目不存在"
|
||||
description="该项目可能已被删除,或当前 ID 无法匹配到项目记录。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDeleteProject = () => {
|
||||
if (!window.confirm(`确认删除 Pages 项目 ${project.name} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
deleteProjectMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={project.name}
|
||||
description={`${project.slug} · Pages 静态站点项目详情`}
|
||||
action={
|
||||
<>
|
||||
<Link
|
||||
href="/pages"
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
返回列表
|
||||
</Link>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setEditModalOpen(true)}
|
||||
>
|
||||
编辑项目
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
disabled={deleteProjectMutation.isPending}
|
||||
onClick={handleDeleteProject}
|
||||
>
|
||||
删除项目
|
||||
</DangerButton>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
|
||||
<AppCard
|
||||
title="上传部署包"
|
||||
description="上传已构建的 zip 静态资源包,默认入口 index.html。"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<ResourceField
|
||||
label="部署包"
|
||||
hint="仅支持 zip,Server 会校验文件数量、体积、路径逃逸和入口文件。"
|
||||
>
|
||||
<ResourceInput
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="入口文件">
|
||||
<ResourceInput
|
||||
value={entryFile}
|
||||
onChange={(event) => setEntryFile(event.target.value)}
|
||||
/>
|
||||
</ResourceField>
|
||||
{uploadProgress !== null && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs font-medium text-[var(--foreground-secondary)]">
|
||||
<span>上传进度</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--surface-muted)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--brand-primary)] transition-all duration-300 ease-out"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
disabled={!file || uploadMutation.isPending}
|
||||
onClick={() => uploadMutation.mutate()}
|
||||
>
|
||||
{uploadMutation.isPending
|
||||
? `上传中 (${uploadProgress ?? 0}%)...`
|
||||
: '上传部署'}
|
||||
</PrimaryButton>
|
||||
{uploadMutation.error ? (
|
||||
<p className="text-sm text-[var(--status-danger-foreground)]">
|
||||
{uploadMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="部署历史"
|
||||
description="部署不可变;激活后发布配置,Agent 才会拉取并切换静态资源。"
|
||||
>
|
||||
{deploymentsQuery.isLoading ? (
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
加载中...
|
||||
</p>
|
||||
) : deploymentsQuery.isError ? (
|
||||
<p className="text-sm text-[var(--status-danger-foreground)]">
|
||||
{deploymentsQuery.error.message}
|
||||
</p>
|
||||
) : (deploymentsQuery.data ?? []).length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无部署"
|
||||
description="上传 zip 部署包后,可以在这里激活某个部署版本。"
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--border-default)]">
|
||||
{(deploymentsQuery.data ?? []).map((deployment) => (
|
||||
<div
|
||||
key={deployment.id}
|
||||
className="flex flex-col gap-3 border-b border-[var(--border-default)] p-4 last:border-b-0 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
#{deployment.deployment_number}{' '}
|
||||
{deployment.status === 'active' ? '· 已激活' : ''}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[var(--foreground-secondary)]">
|
||||
{deployment.checksum.slice(0, 16)} ·{' '}
|
||||
{deployment.file_count} files ·{' '}
|
||||
{formatBytes(deployment.total_size)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
disabled={
|
||||
deployment.status === 'active' ||
|
||||
activateMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`确认激活部署 #${deployment.deployment_number} 吗?`,
|
||||
)
|
||||
) {
|
||||
activateMutation.mutate(deployment.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
激活
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
disabled={
|
||||
deployment.status === 'active' ||
|
||||
deleteDeploymentMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`确认删除部署 #${deployment.deployment_number} 吗?`,
|
||||
)
|
||||
) {
|
||||
deleteDeploymentMutation.mutate(deployment.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<PagesProjectEditModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={() => setEditModalOpen(false)}
|
||||
project={project}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PagesProjectEditModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project: PagesProject;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState(project.name);
|
||||
const [slug, setSlug] = useState(project.slug);
|
||||
const [description, setDescription] = useState(project.description || '');
|
||||
const [spaFallbackEnabled, setSpaFallbackEnabled] = useState(project.spa_fallback_enabled);
|
||||
const [spaFallbackPath, setSpaFallbackPath] = useState(project.spa_fallback_path);
|
||||
|
||||
useEffect(() => {
|
||||
setName(project.name);
|
||||
setSlug(project.slug);
|
||||
setDescription(project.description || '');
|
||||
setSpaFallbackEnabled(project.spa_fallback_enabled);
|
||||
setSpaFallbackPath(project.spa_fallback_path);
|
||||
}, [project]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updatePagesProject(project.id, {
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
enabled: project.enabled,
|
||||
spa_fallback_enabled: spaFallbackEnabled,
|
||||
spa_fallback_path: spaFallbackPath,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
queryClient.invalidateQueries({ queryKey: projectQueryKey(project.id) });
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
},
|
||||
});
|
||||
|
||||
function handleUpdate(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
updateMutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="编辑 Pages 项目"
|
||||
description="修改静态站点项目的基础配置。"
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="pages-project-edit-form"
|
||||
disabled={updateMutation.isPending || name.trim() === ''}
|
||||
>
|
||||
{updateMutation.isPending ? '保存中...' : '保存修改'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="pages-project-edit-form"
|
||||
className="grid gap-4 md:grid-cols-2"
|
||||
onSubmit={handleUpdate}
|
||||
>
|
||||
<ResourceField label="项目名称">
|
||||
<ResourceInput
|
||||
value={name}
|
||||
placeholder="Marketing Site"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="项目标识" hint="留空时会按名称自动生成。">
|
||||
<ResourceInput
|
||||
value={slug}
|
||||
placeholder="marketing-site"
|
||||
onChange={(event) => setSlug(event.target.value)}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="描述" className="md:col-span-2">
|
||||
<ResourceInput
|
||||
value={description}
|
||||
placeholder="这个项目托管的静态站点用途"
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ToggleField
|
||||
label="启用 SPA fallback"
|
||||
description="开启后未命中的路径会回退到指定文件,适合 React/Vue history 路由。"
|
||||
checked={spaFallbackEnabled}
|
||||
onChange={setSpaFallbackEnabled}
|
||||
/>
|
||||
<ResourceField
|
||||
label="SPA 回退路径"
|
||||
hint="以 / 开头,例如 /index.html 或 /app.html。关闭 fallback 时不会生效。"
|
||||
>
|
||||
<ResourceInput
|
||||
value={spaFallbackPath}
|
||||
placeholder="/index.html"
|
||||
disabled={!spaFallbackEnabled}
|
||||
onChange={(event) => setSpaFallbackPath(event.target.value)}
|
||||
/>
|
||||
</ResourceField>
|
||||
{updateMutation.error ? (
|
||||
<p className="text-sm text-[var(--status-danger-foreground)] md:col-span-2">
|
||||
{updateMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
function PagesProjectCreateModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [spaFallbackEnabled, setSpaFallbackEnabled] = useState(false);
|
||||
const [spaFallbackPath, setSpaFallbackPath] = useState('/index.html');
|
||||
|
||||
const resetForm = () => {
|
||||
setName('');
|
||||
setSlug('');
|
||||
setDescription('');
|
||||
setSpaFallbackEnabled(false);
|
||||
setSpaFallbackPath('/index.html');
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createPagesProject({
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
enabled: true,
|
||||
spa_fallback_enabled: spaFallbackEnabled,
|
||||
spa_fallback_path: spaFallbackPath,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
},
|
||||
});
|
||||
|
||||
function handleCreate(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
createMutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={closeModal}
|
||||
title="新建 Pages 项目"
|
||||
description="配置静态站点项目的基础信息。创建后再上传已构建的 zip 部署包。"
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={closeModal}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="pages-project-create-form"
|
||||
disabled={createMutation.isPending || name.trim() === ''}
|
||||
>
|
||||
{createMutation.isPending ? '创建中...' : '创建项目'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="pages-project-create-form"
|
||||
className="grid gap-4 md:grid-cols-2"
|
||||
onSubmit={handleCreate}
|
||||
>
|
||||
<ResourceField label="项目名称">
|
||||
<ResourceInput
|
||||
value={name}
|
||||
placeholder="Marketing Site"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="项目标识" hint="留空时会按名称自动生成。">
|
||||
<ResourceInput
|
||||
value={slug}
|
||||
placeholder="marketing-site"
|
||||
onChange={(event) => setSlug(event.target.value)}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="描述" className="md:col-span-2">
|
||||
<ResourceInput
|
||||
value={description}
|
||||
placeholder="这个项目托管的静态站点用途"
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ToggleField
|
||||
label="启用 SPA fallback"
|
||||
description="开启后未命中的路径会回退到指定文件,适合 React/Vue history 路由。"
|
||||
checked={spaFallbackEnabled}
|
||||
onChange={setSpaFallbackEnabled}
|
||||
/>
|
||||
<ResourceField
|
||||
label="SPA 回退路径"
|
||||
hint="以 / 开头,例如 /index.html 或 /app.html。关闭 fallback 时不会生效。"
|
||||
>
|
||||
<ResourceInput
|
||||
value={spaFallbackPath}
|
||||
placeholder="/index.html"
|
||||
disabled={!spaFallbackEnabled}
|
||||
onChange={(event) => setSpaFallbackPath(event.target.value)}
|
||||
/>
|
||||
</ResourceField>
|
||||
{createMutation.error ? (
|
||||
<p className="text-sm text-[var(--status-danger-foreground)] md:col-span-2">
|
||||
{createMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
function PagesProjectListItem({ project }: { project: PagesProject }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/pages/detail?id=${project.id}`}
|
||||
className="group block rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-panel)] p-5 shadow-[var(--shadow-card)] transition hover:-translate-y-0.5 hover:border-[var(--border-strong)] hover:shadow-[var(--shadow-soft)]"
|
||||
>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{project.name}
|
||||
</h2>
|
||||
<StatusBadge
|
||||
label={project.enabled ? '已启用' : '已停用'}
|
||||
variant={project.enabled ? 'success' : 'warning'}
|
||||
/>
|
||||
<StatusBadge
|
||||
label={project.spa_fallback_enabled ? 'SPA fallback' : '严格 404'}
|
||||
variant={project.spa_fallback_enabled ? 'info' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{project.slug}
|
||||
</p>
|
||||
{project.description ? (
|
||||
<p className="line-clamp-2 text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
{project.description}
|
||||
</p>
|
||||
) : null}
|
||||
{project.spa_fallback_enabled ? (
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
回退路径:{project.spa_fallback_path || '/index.html'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid shrink-0 grid-cols-2 gap-3 text-sm md:min-w-80">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-3">
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">部署数</p>
|
||||
<p className="mt-1 font-semibold text-[var(--foreground-primary)]">
|
||||
{project.deployment_count}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-3">
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
当前激活
|
||||
</p>
|
||||
<p className="mt-1 font-semibold text-[var(--foreground-primary)]">
|
||||
{project.active_deployment
|
||||
? `#${project.active_deployment.deployment_number}`
|
||||
: '暂无'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between border-t border-[var(--border-default)] pt-4">
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
激活时间:{formatDate(project.active_deployment?.activated_at)}
|
||||
</p>
|
||||
<span className="text-sm font-medium text-[var(--brand-primary)] transition group-hover:translate-x-1">
|
||||
查看详情 →
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import {
|
||||
createPagesProject,
|
||||
updatePagesProject,
|
||||
} from '@/features/pages/api/pages';
|
||||
import type { PagesProject } from '@/features/pages/types';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
SecondaryButton,
|
||||
ToggleField,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { projectQueryKey, projectsQueryKey } from '../utils';
|
||||
import { pagesProjectSchema, type PagesProjectFormValues } from '../schemas';
|
||||
|
||||
interface PagesProjectEditorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project?: PagesProject | null;
|
||||
onSaved?: (project: PagesProject, mode: 'create' | 'update') => void;
|
||||
}
|
||||
|
||||
function toFormValues(project?: PagesProject | null): PagesProjectFormValues {
|
||||
if (!project) {
|
||||
return {
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
spa_fallback_enabled: false,
|
||||
spa_fallback_path: '/index.html',
|
||||
api_proxy_enabled: false,
|
||||
api_proxy_path: '',
|
||||
api_proxy_pass: '',
|
||||
api_proxy_rewrite: '',
|
||||
root_dir: '',
|
||||
entry_file: 'index.html',
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: project.name,
|
||||
slug: project.slug,
|
||||
description: project.description || '',
|
||||
spa_fallback_enabled: project.spa_fallback_enabled,
|
||||
spa_fallback_path: project.spa_fallback_path,
|
||||
api_proxy_enabled: project.api_proxy_enabled || false,
|
||||
api_proxy_path: project.api_proxy_path || '',
|
||||
api_proxy_pass: project.api_proxy_pass || '',
|
||||
api_proxy_rewrite: project.api_proxy_rewrite || '',
|
||||
root_dir: project.root_dir || '',
|
||||
entry_file: project.entry_file || 'index.html',
|
||||
};
|
||||
}
|
||||
|
||||
export function PagesProjectEditorModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
onSaved,
|
||||
}: PagesProjectEditorModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm<PagesProjectFormValues>({
|
||||
resolver: zodResolver(pagesProjectSchema),
|
||||
defaultValues: toFormValues(project),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
form.reset(toFormValues(project));
|
||||
}
|
||||
}, [form, project, isOpen]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: PagesProjectFormValues) => {
|
||||
const payload = {
|
||||
name: values.name.trim(),
|
||||
slug: values.slug?.trim() || '',
|
||||
description: values.description?.trim() || '',
|
||||
enabled: project ? project.enabled : true,
|
||||
spa_fallback_enabled: values.spa_fallback_enabled,
|
||||
spa_fallback_path: values.spa_fallback_enabled
|
||||
? values.spa_fallback_path.trim()
|
||||
: project
|
||||
? project.spa_fallback_path
|
||||
: '/index.html',
|
||||
api_proxy_enabled: values.api_proxy_enabled,
|
||||
api_proxy_path: values.api_proxy_enabled
|
||||
? values.api_proxy_path.trim()
|
||||
: '',
|
||||
api_proxy_pass: values.api_proxy_enabled
|
||||
? values.api_proxy_pass.trim()
|
||||
: '',
|
||||
api_proxy_rewrite: values.api_proxy_enabled
|
||||
? values.api_proxy_rewrite.trim()
|
||||
: '',
|
||||
root_dir: values.root_dir?.trim() || '',
|
||||
entry_file: values.entry_file?.trim() || 'index.html',
|
||||
};
|
||||
return project
|
||||
? updatePagesProject(project.id, payload)
|
||||
: createPagesProject(payload);
|
||||
},
|
||||
onSuccess: (savedProject) => {
|
||||
onClose();
|
||||
if (project) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: projectQueryKey(project.id),
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
onSaved?.(savedProject, project ? 'update' : 'create');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
mutation.mutate(values);
|
||||
});
|
||||
|
||||
const spaFallbackEnabled = form.watch('spa_fallback_enabled');
|
||||
const apiProxyEnabled = form.watch('api_proxy_enabled');
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={project ? '编辑 Pages 项目' : '新建 Pages 项目'}
|
||||
description={
|
||||
project
|
||||
? '修改静态站点项目的基础配置与高级反代规则。'
|
||||
: '配置静态站点项目的基础信息与高级反代规则。创建后再上传已构建的 zip 部署包。'
|
||||
}
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="pages-project-editor-form"
|
||||
disabled={mutation.isPending || !form.formState.isValid}
|
||||
>
|
||||
{mutation.isPending
|
||||
? '保存中...'
|
||||
: project
|
||||
? '保存修改'
|
||||
: '创建项目'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="pages-project-editor-form"
|
||||
className="grid gap-4 md:grid-cols-2"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<ResourceField
|
||||
label="项目名称"
|
||||
error={form.formState.errors.name?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="Marketing Site"
|
||||
{...form.register('name')}
|
||||
required
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="项目标识"
|
||||
hint="留空时会按名称自动生成。"
|
||||
error={form.formState.errors.slug?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="marketing-site"
|
||||
{...form.register('slug')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="描述"
|
||||
className="md:col-span-2"
|
||||
error={form.formState.errors.description?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="这个项目托管的静态站点用途"
|
||||
{...form.register('description')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="根目录 (可选)"
|
||||
hint="静态资源的根文件夹路径 (例如 build 或 dist),若为空则默认为 zip 包根目录。"
|
||||
error={form.formState.errors.root_dir?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="例如: build"
|
||||
{...form.register('root_dir')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="入口文件"
|
||||
hint="基于根目录下的入口文件路径。"
|
||||
error={form.formState.errors.entry_file?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="index.html"
|
||||
{...form.register('entry_file')}
|
||||
required
|
||||
/>
|
||||
</ResourceField>
|
||||
<ToggleField
|
||||
label="启用 SPA fallback"
|
||||
description="开启后未命中的路径会回退到指定文件,适合 React/Vue history 路由。"
|
||||
checked={spaFallbackEnabled}
|
||||
onChange={(checked) =>
|
||||
form.setValue('spa_fallback_enabled', checked, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ResourceField
|
||||
label="SPA 回退路径"
|
||||
hint="以 / 开头,例如 /index.html 或 /app.html。关闭 fallback 时不会生效。"
|
||||
error={form.formState.errors.spa_fallback_path?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="/index.html"
|
||||
disabled={!spaFallbackEnabled}
|
||||
{...form.register('spa_fallback_path')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ToggleField
|
||||
label="启用 API 反向代理"
|
||||
description="允许为该静态站点配置反代后端(例如反代指定 API 路径至您的后端服务)。"
|
||||
checked={apiProxyEnabled}
|
||||
onChange={(checked) =>
|
||||
form.setValue('api_proxy_enabled', checked, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{apiProxyEnabled && (
|
||||
<>
|
||||
<ResourceField
|
||||
label="反代匹配路径"
|
||||
hint="以 / 开头,例如 /api 或 /api/v1。匹配该前缀的请求将被转发。"
|
||||
error={form.formState.errors.api_proxy_path?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="/api"
|
||||
{...form.register('api_proxy_path')}
|
||||
required
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="后端服务地址"
|
||||
hint="包含协议和主机的完整 URL,例如 http://127.0.0.1:8080。"
|
||||
error={form.formState.errors.api_proxy_pass?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="http://127.0.0.1:8080"
|
||||
{...form.register('api_proxy_pass')}
|
||||
required
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="路径重写目标"
|
||||
hint="可选。如果配置为 /,请求 /api/users 将被重写转发至后端 /users 路径。"
|
||||
error={form.formState.errors.api_proxy_rewrite?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="/"
|
||||
{...form.register('api_proxy_rewrite')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</>
|
||||
)}
|
||||
{mutation.isError ? (
|
||||
<p className="text-sm text-[var(--status-danger-foreground)] md:col-span-2">
|
||||
{mutation.error instanceof Error
|
||||
? mutation.error.message
|
||||
: '操作失败,请稍后重试。'}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import type { PagesProject } from '@/features/pages/types';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
export function PagesProjectListItem({ project }: { project: PagesProject }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/pages/detail?id=${project.id}`}
|
||||
className="group block rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-panel)] p-5 shadow-[var(--shadow-card)] transition hover:-translate-y-0.5 hover:border-[var(--border-strong)] hover:shadow-[var(--shadow-soft)]"
|
||||
>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{project.name}
|
||||
</h2>
|
||||
<StatusBadge
|
||||
label={project.enabled ? '已启用' : '已停用'}
|
||||
variant={project.enabled ? 'success' : 'warning'}
|
||||
/>
|
||||
<StatusBadge
|
||||
label={project.spa_fallback_enabled ? 'SPA fallback' : '严格 404'}
|
||||
variant={project.spa_fallback_enabled ? 'info' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{project.slug}
|
||||
</p>
|
||||
{project.description ? (
|
||||
<p className="line-clamp-2 text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
{project.description}
|
||||
</p>
|
||||
) : null}
|
||||
{project.spa_fallback_enabled ? (
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
回退路径:{project.spa_fallback_path || '/index.html'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid shrink-0 grid-cols-1 gap-3 text-sm md:min-w-40">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-3">
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
当前激活
|
||||
</p>
|
||||
<p className="mt-1 font-semibold text-[var(--foreground-primary)]">
|
||||
{project.active_deployment
|
||||
? `#${project.active_deployment.deployment_number}`
|
||||
: '暂无'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between border-t border-[var(--border-default)] pt-4">
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
激活时间:
|
||||
{project.active_deployment?.activated_at
|
||||
? formatDateTime(project.active_deployment.activated_at)
|
||||
: '未激活'}
|
||||
</p>
|
||||
<span className="text-sm font-medium text-[var(--brand-primary)] transition group-hover:translate-x-1">
|
||||
查看详情 →
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const pagesProjectSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入项目名称')
|
||||
.max(255, '项目名称不能超过 255 个字符'),
|
||||
slug: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(255, '项目标识不能超过 255 个字符')
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
description: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(1000, '描述不能超过 1000 个字符')
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
spa_fallback_enabled: z.boolean(),
|
||||
spa_fallback_path: z.string().trim(),
|
||||
api_proxy_enabled: z.boolean(),
|
||||
api_proxy_path: z.string().trim(),
|
||||
api_proxy_pass: z.string().trim(),
|
||||
api_proxy_rewrite: z.string().trim(),
|
||||
root_dir: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(512, '根目录不能超过 512 个字符')
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
entry_file: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入入口文件')
|
||||
.max(512, '入口文件不能超过 512 个字符'),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.spa_fallback_enabled) {
|
||||
if (!data.spa_fallback_path) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['spa_fallback_path'],
|
||||
message: '启用 SPA fallback 时必须输入回退路径',
|
||||
});
|
||||
} else if (!data.spa_fallback_path.startsWith('/')) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['spa_fallback_path'],
|
||||
message: '回退路径必须以 / 开头,例如 /index.html',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.api_proxy_enabled) {
|
||||
if (!data.api_proxy_path) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_path'],
|
||||
message: '启用 API 反向代理时必须输入匹配路径',
|
||||
});
|
||||
} else if (!data.api_proxy_path.startsWith('/')) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_path'],
|
||||
message: '匹配路径必须以 / 开头,例如 /api',
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.api_proxy_pass) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_pass'],
|
||||
message: '启用 API 反向代理时必须输入后端服务地址',
|
||||
});
|
||||
} else if (!/^https?:\/\//i.test(data.api_proxy_pass)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_pass'],
|
||||
message: '后端服务地址必须以 http:// 或 https:// 开头',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type PagesProjectFormValues = z.infer<typeof pagesProjectSchema>;
|
||||
@@ -6,6 +6,7 @@ export interface PagesDeployment {
|
||||
status: 'uploaded' | 'active';
|
||||
file_count: number;
|
||||
total_size: number;
|
||||
root_dir?: string;
|
||||
entry_file: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
@@ -20,6 +21,12 @@ export interface PagesProject {
|
||||
enabled: boolean;
|
||||
spa_fallback_enabled: boolean;
|
||||
spa_fallback_path: string;
|
||||
api_proxy_enabled: boolean;
|
||||
api_proxy_path: string;
|
||||
api_proxy_pass: string;
|
||||
api_proxy_rewrite: string;
|
||||
root_dir?: string;
|
||||
entry_file: string;
|
||||
active_deployment_id?: number | null;
|
||||
active_deployment?: PagesDeployment | null;
|
||||
deployment_count: number;
|
||||
@@ -34,4 +41,10 @@ export interface PagesProjectPayload {
|
||||
enabled: boolean;
|
||||
spa_fallback_enabled: boolean;
|
||||
spa_fallback_path: string;
|
||||
api_proxy_enabled: boolean;
|
||||
api_proxy_path: string;
|
||||
api_proxy_pass: string;
|
||||
api_proxy_rewrite: string;
|
||||
root_dir?: string;
|
||||
entry_file: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const projectsQueryKey = ['pages-projects'];
|
||||
|
||||
export function projectQueryKey(projectId: string | number) {
|
||||
return ['pages-project', String(projectId)];
|
||||
}
|
||||
|
||||
export function deploymentsQueryKey(projectId: string | number) {
|
||||
return ['pages-deployments', Number(projectId)];
|
||||
}
|
||||
@@ -206,7 +206,8 @@ export function PerformancePage() {
|
||||
|
||||
const optionMap = optionsToMap(optionsQuery.data);
|
||||
setPerformanceFields({
|
||||
OpenRestyDefaultServerReturnStatus: optionMap.OpenRestyDefaultServerReturnStatus ?? '421',
|
||||
OpenRestyDefaultServerReturnStatus:
|
||||
optionMap.OpenRestyDefaultServerReturnStatus ?? '421',
|
||||
OpenRestyWorkerProcesses: optionMap.OpenRestyWorkerProcesses ?? 'auto',
|
||||
OpenRestyWorkerConnections:
|
||||
optionMap.OpenRestyWorkerConnections ?? '4096',
|
||||
@@ -235,10 +236,7 @@ export function PerformancePage() {
|
||||
optionMap.OpenRestyWebsocketEnabled,
|
||||
true,
|
||||
),
|
||||
OpenRestyHTTP3Enabled: toBoolean(
|
||||
optionMap.OpenRestyHTTP3Enabled,
|
||||
false,
|
||||
),
|
||||
OpenRestyHTTP3Enabled: toBoolean(optionMap.OpenRestyHTTP3Enabled, false),
|
||||
OpenRestyProxyRequestBufferingEnabled: toBoolean(
|
||||
optionMap.OpenRestyProxyRequestBufferingEnabled,
|
||||
false,
|
||||
|
||||
@@ -580,8 +580,11 @@ function ReverseProxySection({
|
||||
origin_host: route.origin_host || '',
|
||||
tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '',
|
||||
tunnel_target_addr: route.tunnel_target_addr || '',
|
||||
tunnel_target_protocol: (route.tunnel_target_protocol as 'http' | 'https') || 'http',
|
||||
pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
|
||||
tunnel_target_protocol:
|
||||
(route.tunnel_target_protocol as 'http' | 'https') || 'http',
|
||||
pages_project_id: route.pages_project_id
|
||||
? String(route.pages_project_id)
|
||||
: '',
|
||||
custom_headers_text: customHeadersToText(route.custom_header_list),
|
||||
remark: route.remark || '',
|
||||
},
|
||||
@@ -594,8 +597,11 @@ function ReverseProxySection({
|
||||
origin_host: route.origin_host || '',
|
||||
tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '',
|
||||
tunnel_target_addr: route.tunnel_target_addr || '',
|
||||
tunnel_target_protocol: (route.tunnel_target_protocol as 'http' | 'https') || 'http',
|
||||
pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
|
||||
tunnel_target_protocol:
|
||||
(route.tunnel_target_protocol as 'http' | 'https') || 'http',
|
||||
pages_project_id: route.pages_project_id
|
||||
? String(route.pages_project_id)
|
||||
: '',
|
||||
custom_headers_text: customHeadersToText(route.custom_header_list),
|
||||
remark: route.remark || '',
|
||||
});
|
||||
@@ -678,32 +684,34 @@ function ReverseProxySection({
|
||||
})}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium text-[var(--foreground-primary)] block">回源方式</label>
|
||||
<label className="block text-sm font-medium text-[var(--foreground-primary)]">
|
||||
回源方式
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-[var(--foreground-primary)] cursor-pointer">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-[var(--foreground-primary)]">
|
||||
<input
|
||||
type="radio"
|
||||
value="direct"
|
||||
{...form.register('upstream_type')}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
直连上游
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-[var(--foreground-primary)] cursor-pointer">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-[var(--foreground-primary)]">
|
||||
<input
|
||||
type="radio"
|
||||
value="tunnel"
|
||||
{...form.register('upstream_type')}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
内网穿透 (Tunnel)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-[var(--foreground-primary)] cursor-pointer">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-[var(--foreground-primary)]">
|
||||
<input
|
||||
type="radio"
|
||||
value="pages"
|
||||
{...form.register('upstream_type')}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
Pages 静态站点
|
||||
</label>
|
||||
@@ -726,7 +734,7 @@ function ReverseProxySection({
|
||||
/>
|
||||
</ResourceField>
|
||||
) : form.watch('upstream_type') === 'tunnel' ? (
|
||||
<div className="p-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] space-y-4">
|
||||
<div className="space-y-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4">
|
||||
<ResourceField
|
||||
label="选择内网穿透隧道"
|
||||
hint="将请求转发到该隧道连接的客户端节点。"
|
||||
@@ -734,17 +742,18 @@ function ReverseProxySection({
|
||||
>
|
||||
<select
|
||||
{...form.register('tunnel_id')}
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] outline-none transition focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] transition outline-none focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
>
|
||||
<option value="">请选择...</option>
|
||||
{(tunnelClients).map((tunnel) => (
|
||||
{tunnelClients.map((tunnel) => (
|
||||
<option key={tunnel.id} value={tunnel.id}>
|
||||
{tunnel.name} ({tunnel.status === 'online' ? '在线' : '离线'})
|
||||
{tunnel.name} (
|
||||
{tunnel.status === 'online' ? '在线' : '离线'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</ResourceField>
|
||||
|
||||
|
||||
<ResourceField
|
||||
label="内网服务协议"
|
||||
hint="转发到内网服务时使用的协议。"
|
||||
@@ -752,7 +761,7 @@ function ReverseProxySection({
|
||||
>
|
||||
<select
|
||||
{...form.register('tunnel_target_protocol')}
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] outline-none transition focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] transition outline-none focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
@@ -771,7 +780,7 @@ function ReverseProxySection({
|
||||
</ResourceField>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] space-y-4">
|
||||
<div className="space-y-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4">
|
||||
<ResourceField
|
||||
label="选择 Pages 项目"
|
||||
hint="仅显示已启用且已有激活部署的 Pages 项目。"
|
||||
@@ -779,7 +788,7 @@ function ReverseProxySection({
|
||||
>
|
||||
<select
|
||||
{...form.register('pages_project_id')}
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] outline-none transition focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] transition outline-none focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
>
|
||||
<option value="">请选择...</option>
|
||||
{pagesProjects.map((project) => (
|
||||
@@ -1039,7 +1048,9 @@ function WAFBindingSection({ route }: { route: ProxyRouteItem }) {
|
||||
const checked = event.target.checked;
|
||||
setSelectedIDs((current) =>
|
||||
checked
|
||||
? [...current, group.id].sort((left, right) => left - right)
|
||||
? [...current, group.id].sort(
|
||||
(left, right) => left - right,
|
||||
)
|
||||
: current.filter((id) => id !== group.id),
|
||||
);
|
||||
}}
|
||||
|
||||
+18
-15
@@ -383,32 +383,34 @@ export function ProxyRouteCreateDrawer({
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium text-[var(--foreground-primary)] block">回源方式</label>
|
||||
<label className="block text-sm font-medium text-[var(--foreground-primary)]">
|
||||
回源方式
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-[var(--foreground-primary)] cursor-pointer">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-[var(--foreground-primary)]">
|
||||
<input
|
||||
type="radio"
|
||||
value="direct"
|
||||
{...form.register('upstream_type')}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
直连上游
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-[var(--foreground-primary)] cursor-pointer">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-[var(--foreground-primary)]">
|
||||
<input
|
||||
type="radio"
|
||||
value="tunnel"
|
||||
{...form.register('upstream_type')}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
内网穿透 (Tunnel)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-[var(--foreground-primary)] cursor-pointer">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-[var(--foreground-primary)]">
|
||||
<input
|
||||
type="radio"
|
||||
value="pages"
|
||||
{...form.register('upstream_type')}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
Pages 静态站点
|
||||
</label>
|
||||
@@ -430,7 +432,7 @@ export function ProxyRouteCreateDrawer({
|
||||
/>
|
||||
</ResourceField>
|
||||
) : form.watch('upstream_type') === 'tunnel' ? (
|
||||
<div className="p-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] space-y-4">
|
||||
<div className="space-y-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4">
|
||||
<ResourceField
|
||||
label="选择内网穿透隧道"
|
||||
hint="将请求转发到该隧道连接的客户端节点。"
|
||||
@@ -438,17 +440,18 @@ export function ProxyRouteCreateDrawer({
|
||||
>
|
||||
<select
|
||||
{...form.register('tunnel_id')}
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] outline-none transition focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] transition outline-none focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
>
|
||||
<option value="">请选择...</option>
|
||||
{(tunnelClients).map((tunnel) => (
|
||||
{tunnelClients.map((tunnel) => (
|
||||
<option key={tunnel.id} value={tunnel.id}>
|
||||
{tunnel.name} ({tunnel.status === 'online' ? '在线' : '离线'})
|
||||
{tunnel.name} (
|
||||
{tunnel.status === 'online' ? '在线' : '离线'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</ResourceField>
|
||||
|
||||
|
||||
<ResourceField
|
||||
label="内网服务协议"
|
||||
hint="转发到内网服务时使用的协议。"
|
||||
@@ -456,7 +459,7 @@ export function ProxyRouteCreateDrawer({
|
||||
>
|
||||
<select
|
||||
{...form.register('tunnel_target_protocol')}
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] outline-none transition focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] transition outline-none focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
@@ -475,7 +478,7 @@ export function ProxyRouteCreateDrawer({
|
||||
</ResourceField>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] space-y-4">
|
||||
<div className="space-y-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4">
|
||||
<ResourceField
|
||||
label="选择 Pages 项目"
|
||||
hint="仅显示已启用且已有激活部署的 Pages 项目。"
|
||||
@@ -483,7 +486,7 @@ export function ProxyRouteCreateDrawer({
|
||||
>
|
||||
<select
|
||||
{...form.register('pages_project_id')}
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] outline-none transition focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] transition outline-none focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
>
|
||||
<option value="">请选择...</option>
|
||||
{pagesProjects.map((project) => (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
import { setStoredOpenFlareToken } from '@/lib/api/auth-token';
|
||||
|
||||
import type {
|
||||
BootstrapTokenPayload,
|
||||
@@ -109,7 +110,10 @@ export function updateSelf(payload: UpdateSelfPayload) {
|
||||
}
|
||||
|
||||
export function generateAccessToken() {
|
||||
return apiRequest<string>('/user/token');
|
||||
return apiRequest<string>('/user/token').then((token) => {
|
||||
setStoredOpenFlareToken(token);
|
||||
return token;
|
||||
});
|
||||
}
|
||||
|
||||
export function bindWeChat(code: string) {
|
||||
|
||||
@@ -388,7 +388,8 @@ export function SettingsPage() {
|
||||
NodeOfflineThreshold: optionMap.NodeOfflineThreshold ?? '120000',
|
||||
AgentUpdateRepo: optionMap.AgentUpdateRepo ?? 'Rain-kl/OpenFlare',
|
||||
GeoIPProvider: optionMap.GeoIPProvider ?? 'ipinfo',
|
||||
OpenRestyDefaultServerReturnStatus: optionMap.OpenRestyDefaultServerReturnStatus ?? '421',
|
||||
OpenRestyDefaultServerReturnStatus:
|
||||
optionMap.OpenRestyDefaultServerReturnStatus ?? '421',
|
||||
OpenRestyWorkerProcesses: optionMap.OpenRestyWorkerProcesses ?? 'auto',
|
||||
OpenRestyWorkerConnections:
|
||||
optionMap.OpenRestyWorkerConnections ?? '4096',
|
||||
@@ -683,10 +684,16 @@ export function SettingsPage() {
|
||||
|
||||
const handleUptimeKumaSave = () => {
|
||||
void runBusyAction('uptimekuma-save', async () => {
|
||||
const syncInt = Number.parseInt(operationFields.UptimeKumaSyncInterval, 10);
|
||||
const syncInt = Number.parseInt(
|
||||
operationFields.UptimeKumaSyncInterval,
|
||||
10,
|
||||
);
|
||||
const interval = Number.parseInt(operationFields.UptimeKumaInterval, 10);
|
||||
const retry = Number.parseInt(operationFields.UptimeKumaRetry, 10);
|
||||
const retryInt = Number.parseInt(operationFields.UptimeKumaRetryInterval, 10);
|
||||
const retryInt = Number.parseInt(
|
||||
operationFields.UptimeKumaRetryInterval,
|
||||
10,
|
||||
);
|
||||
const timeout = Number.parseInt(operationFields.UptimeKumaTimeout, 10);
|
||||
|
||||
if (operationFields.UptimeKumaEnabled) {
|
||||
@@ -1399,7 +1406,10 @@ export function SettingsPage() {
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={handleUptimeKumaSync}
|
||||
disabled={busyKey === 'uptimekuma-sync' || !operationFields.UptimeKumaEnabled}
|
||||
disabled={
|
||||
busyKey === 'uptimekuma-sync' ||
|
||||
!operationFields.UptimeKumaEnabled
|
||||
}
|
||||
>
|
||||
{busyKey === 'uptimekuma-sync' ? '同步中...' : '立即同步'}
|
||||
</SecondaryButton>
|
||||
@@ -1429,7 +1439,10 @@ export function SettingsPage() {
|
||||
{operationFields.UptimeKumaEnabled ? (
|
||||
<div className="space-y-5 border-t border-[var(--border-default)] pt-5">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<ResourceField label="Uptime Kuma 地址" hint="例如 http://192.168.1.100:3001">
|
||||
<ResourceField
|
||||
label="Uptime Kuma 地址"
|
||||
hint="例如 http://192.168.1.100:3001"
|
||||
>
|
||||
<ResourceInput
|
||||
value={operationFields.UptimeKumaUrl}
|
||||
onChange={(event) =>
|
||||
@@ -1475,14 +1488,14 @@ export function SettingsPage() {
|
||||
</ResourceField>
|
||||
<ResourceField label="同步间隔 (分钟)">
|
||||
<ResourceInput
|
||||
type="number"
|
||||
value={operationFields.UptimeKumaSyncInterval}
|
||||
onChange={(event) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
UptimeKumaSyncInterval: event.target.value,
|
||||
}))
|
||||
}
|
||||
type="number"
|
||||
value={operationFields.UptimeKumaSyncInterval}
|
||||
onChange={(event) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
UptimeKumaSyncInterval: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="监控范围">
|
||||
@@ -1499,32 +1512,40 @@ export function SettingsPage() {
|
||||
<option value="selected">选择站点</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{operationFields.UptimeKumaMonitorScope === 'selected' ? (
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-4 space-y-3">
|
||||
<div className="space-y-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-[var(--foreground-primary)]">已选站点</span>
|
||||
<SecondaryButton type="button" onClick={() => setUptimeKumaModalOpen(true)}>
|
||||
<span className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
已选站点
|
||||
</span>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setUptimeKumaModalOpen(true)}
|
||||
>
|
||||
选择监控站点
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--foreground-secondary)] break-all max-h-[80px] overflow-y-auto leading-5">
|
||||
<div className="max-h-[80px] overflow-y-auto text-xs leading-5 break-all text-[var(--foreground-secondary)]">
|
||||
{operationFields.UptimeKumaSelectedSites
|
||||
? operationFields.UptimeKumaSelectedSites.split(',').join(', ')
|
||||
? operationFields.UptimeKumaSelectedSites.split(
|
||||
',',
|
||||
).join(', ')
|
||||
: '未选择任何站点,同步不会执行。'}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="border-t border-[var(--border-default)] pt-5 space-y-4">
|
||||
<div className="space-y-4 border-t border-[var(--border-default)] pt-5">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
Uptime Kuma 属性配置
|
||||
</p>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<ResourceField label="心跳间隔 (检测频率秒)" hint="服务检测频率,单位:秒">
|
||||
<ResourceField
|
||||
label="心跳间隔 (检测频率秒)"
|
||||
hint="服务检测频率,单位:秒"
|
||||
>
|
||||
<ResourceInput
|
||||
type="number"
|
||||
value={operationFields.UptimeKumaInterval}
|
||||
@@ -1537,7 +1558,10 @@ export function SettingsPage() {
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField label="重试次数" hint="服务被标记为故障前的最大重试次数">
|
||||
<ResourceField
|
||||
label="重试次数"
|
||||
hint="服务被标记为故障前的最大重试次数"
|
||||
>
|
||||
<ResourceInput
|
||||
type="number"
|
||||
value={operationFields.UptimeKumaRetry}
|
||||
@@ -1550,7 +1574,10 @@ export function SettingsPage() {
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField label="心跳重试间隔 (秒)" hint="重试间隔时间,单位:秒">
|
||||
<ResourceField
|
||||
label="心跳重试间隔 (秒)"
|
||||
hint="重试间隔时间,单位:秒"
|
||||
>
|
||||
<ResourceInput
|
||||
type="number"
|
||||
value={operationFields.UptimeKumaRetryInterval}
|
||||
@@ -1563,7 +1590,10 @@ export function SettingsPage() {
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField label="请求超时 (秒)" hint="超时断开时间,单位:秒">
|
||||
<ResourceField
|
||||
label="请求超时 (秒)"
|
||||
hint="超时断开时间,单位:秒"
|
||||
>
|
||||
<ResourceInput
|
||||
type="number"
|
||||
value={operationFields.UptimeKumaTimeout}
|
||||
@@ -2109,7 +2139,6 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
敏感接口限流
|
||||
|
||||
@@ -32,7 +32,11 @@ export function UptimeKumaSiteSelectModal({
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [tempSelected, setTempSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const { data: routes = [], isLoading, error } = useQuery({
|
||||
const {
|
||||
data: routes = [],
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['proxy-routes'],
|
||||
queryFn: getProxyRoutes,
|
||||
enabled: isOpen,
|
||||
@@ -40,7 +44,9 @@ export function UptimeKumaSiteSelectModal({
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTempSelected(new Set(selectedSites.map(s => s.trim()).filter(Boolean)));
|
||||
setTempSelected(
|
||||
new Set(selectedSites.map((s) => s.trim()).filter(Boolean)),
|
||||
);
|
||||
setSearchTerm('');
|
||||
}
|
||||
}, [isOpen, selectedSites]);
|
||||
@@ -162,7 +168,10 @@ export function UptimeKumaSiteSelectModal({
|
||||
onClick={() => toggleSite(route.site_name)}
|
||||
className="cursor-pointer hover:bg-[var(--surface-elevated)]"
|
||||
>
|
||||
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||
<td
|
||||
className="px-4 py-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
@@ -192,7 +201,10 @@ export function UptimeKumaSiteSelectModal({
|
||||
})}
|
||||
{filteredRoutes.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-[var(--foreground-secondary)]">
|
||||
<td
|
||||
colSpan={4}
|
||||
className="px-4 py-8 text-center text-[var(--foreground-secondary)]"
|
||||
>
|
||||
无匹配的站点
|
||||
</td>
|
||||
</tr>
|
||||
@@ -201,8 +213,8 @@ export function UptimeKumaSiteSelectModal({
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="text-xs text-[var(--foreground-muted)] text-right">
|
||||
|
||||
<div className="text-right text-xs text-[var(--foreground-muted)]">
|
||||
已选择 {tempSelected.size} 个监控站点
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -130,7 +130,7 @@ export function VersionUpgradeModal({
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="版本"
|
||||
description="默认检查正式版更新;你也可以手动检查 preview 发布并选择升级,或上传 Server 二进制确认升级。升级开始后服务会短暂重启。"
|
||||
description="默认检查正式版更新;你也可以手动检查 preview 发布并选择升级。升级开始后服务会短暂重启。"
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
@@ -319,7 +319,7 @@ export function VersionUpgradeModal({
|
||||
</AppCard>
|
||||
) : null}
|
||||
|
||||
{canUpgrade ? (
|
||||
{canUpgrade && (false as boolean) ? (
|
||||
<AppCard title="手动升级">
|
||||
<div className="space-y-4">
|
||||
<ResourceField
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Download, Play, Plus, Save, Trash2, Eye } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
Play,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@@ -626,7 +634,7 @@ export function WAFIPGroupsPage() {
|
||||
<div className="mb-2 text-xs font-medium text-[var(--foreground-secondary)]">
|
||||
命中 IP 列表
|
||||
</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-all text-sm text-[var(--foreground-primary)]">
|
||||
<pre className="overflow-x-auto text-sm break-all whitespace-pre-wrap text-[var(--foreground-primary)]">
|
||||
{autoTestResult.matched_ips.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -694,7 +702,10 @@ export function WAFIPGroupsPage() {
|
||||
onClose={() => setIsCapturedIPsModalOpen(false)}
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<SecondaryButton type="button" onClick={() => setIsCapturedIPsModalOpen(false)}>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setIsCapturedIPsModalOpen(false)}
|
||||
>
|
||||
关闭
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
@@ -712,19 +723,29 @@ export function WAFIPGroupsPage() {
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)]">
|
||||
<thead className="bg-[var(--surface-muted)]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-[var(--foreground-secondary)]">IP 地址</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-[var(--foreground-secondary)]">抓取时间</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-[var(--foreground-secondary)]">封禁剩余时间</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-[var(--foreground-secondary)]">
|
||||
IP 地址
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-[var(--foreground-secondary)]">
|
||||
抓取时间
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-[var(--foreground-secondary)]">
|
||||
封禁剩余时间
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)] bg-[var(--surface-elevated)]">
|
||||
{selectedGroup.ext_ips.map((item) => {
|
||||
const autoConfig = selectedGroup.auto_config as { ttl?: number } | undefined;
|
||||
const autoConfig = selectedGroup.auto_config as
|
||||
| { ttl?: number }
|
||||
| undefined;
|
||||
const ttl = autoConfig?.ttl ?? -1;
|
||||
let expireText = '永久';
|
||||
if (ttl > 0) {
|
||||
const capturedDate = new Date(item.captured_at);
|
||||
const expireDate = new Date(capturedDate.getTime() + ttl * 1000);
|
||||
const expireDate = new Date(
|
||||
capturedDate.getTime() + ttl * 1000,
|
||||
);
|
||||
const now = new Date();
|
||||
if (expireDate.getTime() <= now.getTime()) {
|
||||
expireText = '已过期';
|
||||
@@ -741,9 +762,20 @@ export function WAFIPGroupsPage() {
|
||||
}
|
||||
return (
|
||||
<tr key={item.ip}>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] font-mono">{item.ip}</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm text-[var(--foreground-secondary)]">{new Date(item.captured_at).toLocaleString()}</td>
|
||||
<td className={cn("whitespace-nowrap px-4 py-3 text-sm font-medium", ttl > 0 ? "text-amber-500" : "text-emerald-500")}>{expireText}</td>
|
||||
<td className="px-4 py-3 font-mono text-sm font-medium whitespace-nowrap text-[var(--foreground-primary)]">
|
||||
{item.ip}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap text-[var(--foreground-secondary)]">
|
||||
{new Date(item.captured_at).toLocaleString()}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
'px-4 py-3 text-sm font-medium whitespace-nowrap',
|
||||
ttl > 0 ? 'text-amber-500' : 'text-emerald-500',
|
||||
)}
|
||||
>
|
||||
{expireText}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -108,9 +108,11 @@ export function PowTabPanel({
|
||||
<select
|
||||
value={config.algorithm}
|
||||
onChange={(event) =>
|
||||
updateConfig(enabled, { algorithm: event.target.value as 'fast' | 'slow' })
|
||||
updateConfig(enabled, {
|
||||
algorithm: event.target.value as 'fast' | 'slow',
|
||||
})
|
||||
}
|
||||
className="w-full rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm text-[var(--foreground-primary)] outline-none transition focus:border-[var(--border-strong)]"
|
||||
className="w-full rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none focus:border-[var(--border-strong)]"
|
||||
>
|
||||
<option value="fast">Fast</option>
|
||||
<option value="slow">Slow</option>
|
||||
@@ -143,7 +145,9 @@ export function PowTabPanel({
|
||||
min={30}
|
||||
value={config.challenge_ttl}
|
||||
onChange={(event) =>
|
||||
updateConfig(enabled, { challenge_ttl: Number(event.target.value) })
|
||||
updateConfig(enabled, {
|
||||
challenge_ttl: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
@@ -188,7 +188,8 @@ export function RuleEntryModal({
|
||||
选择 IP 组
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||
发布版本只保存引用 ID,IP 组成员由 Agent 按 checksum 差异同步。
|
||||
发布版本只保存引用 ID,IP 组成员由 Agent 按 checksum
|
||||
差异同步。
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
|
||||
|
||||
@@ -2,7 +2,10 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Search } from 'lucide-react';
|
||||
import { Drawer } from '@/components/ui/drawer';
|
||||
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
|
||||
import { PrimaryButton, SecondaryButton } from '@/features/shared/components/resource-primitives';
|
||||
import {
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import type { WAFRuleGroup } from '@/features/waf/types';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const openFlareTokenStorageKey = 'OpenFlare-Token';
|
||||
|
||||
export function getStoredOpenFlareToken() {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
return window.localStorage.getItem(openFlareTokenStorageKey) || '';
|
||||
}
|
||||
|
||||
export function setStoredOpenFlareToken(token: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(openFlareTokenStorageKey, token);
|
||||
}
|
||||
|
||||
export function clearStoredOpenFlareToken() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.removeItem(openFlareTokenStorageKey);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { publicEnv } from '@/lib/env/public-env';
|
||||
import { getStoredOpenFlareToken } from '@/lib/api/auth-token';
|
||||
import type { ApiEnvelope } from '@/types/api';
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -23,6 +24,12 @@ export async function apiRequest<T>(path: string, init?: RequestInit) {
|
||||
if (!(init?.body instanceof FormData) && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
if (!headers.has('OpenFlare-Token')) {
|
||||
const token = getStoredOpenFlareToken();
|
||||
if (token) {
|
||||
headers.set('OpenFlare-Token', token);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(getApiUrl(path), {
|
||||
credentials: 'include',
|
||||
|
||||
Generated
+4765
-1627
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ export interface AuthUser {
|
||||
display_name: string;
|
||||
role: number;
|
||||
status: number;
|
||||
token?: string;
|
||||
email?: string;
|
||||
github_id?: string;
|
||||
wechat_id?: string;
|
||||
|
||||
+10
-10
@@ -8,27 +8,27 @@ require openflare v0.0.0-00010101000000-000000000000
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/snowflake v0.3.0 // indirect
|
||||
github.com/bytedance/sonic v1.11.2 // indirect
|
||||
github.com/bytedance/sonic v1.12.9 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/expr-lang/expr v1.17.8 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.9.1 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/gin-gonic/gin v1.10.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||
github.com/go-acme/lego/v4 v4.35.2 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.23.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.25.0 // indirect
|
||||
github.com/go-redis/redis/v8 v8.11.5 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -37,7 +37,7 @@ require (
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/longbridgeapp/sqlparser v0.3.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
@@ -47,14 +47,14 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pressly/goose/v3 v3.27.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.7.0 // indirect
|
||||
golang.org/x/arch v0.14.0 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
|
||||
+24
-28
@@ -2,21 +2,18 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
|
||||
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A=
|
||||
github.com/bytedance/sonic v1.11.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/bytedance/sonic v1.12.9 h1:Od1BvK55NnewtGaJsTDeAOSnLVO2BTSLOe0+ooKokmQ=
|
||||
github.com/bytedance/sonic v1.12.9/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0=
|
||||
github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
|
||||
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
@@ -35,10 +32,10 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
@@ -53,16 +50,16 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
|
||||
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
|
||||
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
|
||||
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -87,8 +84,8 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
@@ -122,8 +119,8 @@ github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
|
||||
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -138,12 +135,14 @@ github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLy
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
@@ -152,9 +151,8 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
|
||||
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4=
|
||||
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
@@ -165,7 +163,6 @@ golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
@@ -225,4 +222,3 @@ modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
Reference in New Issue
Block a user