feat(config): 新增用户前端访问开关
新增 `frontend_enable` 配置并接入后台站点设置, 用于控制用户首页、订阅入口及用户侧 API 是否对外开放。 开关关闭时相关用户入口统一返回空 404, 同时保留节点 API、管理后台与外部回调接口可访问。 补充特性测试覆盖默认开启、关闭隐藏与节点接口白名单场景
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
## [0.6.23] - 2026-04-29
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- **[user-frontend-access]**: 新增用户前端访问开关;后台站点设置可切换 `frontend_enable`,关闭后 `/`、订阅入口和用户侧 API 返回空 404,不渲染站点标题或用户主题内容,同时保留节点 API 与管理后台原有访问边界 — by yinjianm
|
||||||
|
- 方案: [202604291559_user-frontend-access-toggle](archive/2026-04/202604291559_user-frontend-access-toggle/)
|
||||||
|
- 决策: user-frontend-access-toggle#D001(使用路由级中间件控制用户入口)
|
||||||
|
|
||||||
## [0.6.22] - 2026-04-29
|
## [0.6.22] - 2026-04-29
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ active_package: 无
|
|||||||
## 项目概览
|
## 项目概览
|
||||||
|
|
||||||
- 类型: PHP Laravel 主仓 + `admin-frontend` Vue3 管理端前端
|
- 类型: PHP Laravel 主仓 + `admin-frontend` Vue3 管理端前端
|
||||||
- 当前重点模块: `admin-frontend`、`deploy`、`node-gfw-check`、`node-traffic-limit`、`order-payment`、`queue-mail`、`subscription-protocols`
|
- 当前重点模块: `admin-frontend`、`deploy`、`node-gfw-check`、`node-traffic-limit`、`order-payment`、`queue-mail`、`subscription-protocols`、`user-frontend-access`
|
||||||
- 最新归档: `202604290153_parent-node-auto-visibility`
|
- 最新归档: `202604291559_user-frontend-access-toggle`
|
||||||
|
|
||||||
## 活跃模块
|
## 活跃模块
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ active_package: 无
|
|||||||
- [order-payment](modules/order-payment.md): 订单支付成功快照、第三方回调元信息透传与后台支付成功信息展示
|
- [order-payment](modules/order-payment.md): 订单支付成功快照、第三方回调元信息透传与后台支付成功信息展示
|
||||||
- [queue-mail](modules/queue-mail.md): 邮件发送队列、SMTP 运行时配置、Horizon 超时与失败重试边界
|
- [queue-mail](modules/queue-mail.md): 邮件发送队列、SMTP 运行时配置、Horizon 超时与失败重试边界
|
||||||
- [subscription-protocols](modules/subscription-protocols.md): 客户端订阅导出入口、协议适配器与版本兼容过滤
|
- [subscription-protocols](modules/subscription-protocols.md): 客户端订阅导出入口、协议适配器与版本兼容过滤
|
||||||
|
- [user-frontend-access](modules/user-frontend-access.md): 用户前端访问开关、用户侧 API 隐藏边界与节点 API 白名单
|
||||||
|
|
||||||
## 归档与变更
|
## 归档与变更
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# 变更提案: user-frontend-access-toggle
|
||||||
|
|
||||||
|
## 元信息
|
||||||
|
```yaml
|
||||||
|
类型: 新功能
|
||||||
|
方案类型: implementation
|
||||||
|
优先级: P1
|
||||||
|
状态: 已规划
|
||||||
|
创建: 2026-04-29
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 需求
|
||||||
|
|
||||||
|
### 背景
|
||||||
|
当前用户前端由 `routes/web.php` 的 `/` 入口渲染,用户侧接口分布在 `/api/v1/passport/*`、`/api/v1/user/*`、`/api/v1/client/*`、`/api/v2/client/*` 与部分 `/api/v1/guest/*`。节点通信接口位于 `/api/v1/server/*`,`mi-node` 需要保留这些 API。用户希望部署后默认可隐藏用户前端和用户相关 API,降低公网直接访问 `IP:端口` 时暴露用户站点特征的概率,并可在后台手动开启。
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
- 新增后台可保存的 `frontend_enable` 开关,默认开启以保持升级兼容。
|
||||||
|
- 开关关闭时,用户前端首页、用户订阅入口、登录注册等用户侧 API 返回 404。
|
||||||
|
- 节点 API 白名单不受影响,`/api/v1/server/*` 继续可用。
|
||||||
|
- 管理后台路由与管理 API 不纳入本次变更范围。
|
||||||
|
|
||||||
|
### 约束条件
|
||||||
|
```yaml
|
||||||
|
兼容性约束: 默认值必须为开启,避免升级后自动关闭现有站点。
|
||||||
|
业务约束: 节点通信接口不可被误拦截;管理后台不纳入处理范围。
|
||||||
|
实现约束: 用户主题源码不在仓内,隐藏用户前端优先在 Laravel 路由和中间件层完成。
|
||||||
|
安全约束: 关闭时使用 404 隐藏响应,不输出开关状态或产品识别信息。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
- [ ] `frontend_enable` 默认开启时,用户前端、用户 API、订阅入口保持原有路由行为。
|
||||||
|
- [ ] `frontend_enable` 关闭时,`/`、`/{subscribe_path}/{token}`、用户登录注册、用户端 API 返回 404。
|
||||||
|
- [ ] `frontend_enable` 关闭时,`/api/v1/server/*` 节点 API 仍进入原有 `server` 中间件和控制器链路。
|
||||||
|
- [ ] 后台系统配置页可以读取、切换并保存该开关。
|
||||||
|
- [ ] 自动化测试覆盖关闭/开启两种状态下的核心路由边界。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 方案
|
||||||
|
|
||||||
|
### 技术方案
|
||||||
|
新增 `EnsureUserFrontendEnabled` 路由中间件,读取 `admin_setting('frontend_enable', 1)`。当开关关闭时返回空 404;开启时放行。将该中间件挂到用户前端入口、订阅入口和用户侧 API 路由;节点 API 和管理端路由不挂载。后台配置接口在 `site` 配置组返回并保存 `frontend_enable`,管理端系统配置页在站点设置中显示该开关。
|
||||||
|
|
||||||
|
### 影响范围
|
||||||
|
```yaml
|
||||||
|
涉及模块:
|
||||||
|
- Laravel Web 路由: 控制用户主题首页和订阅入口隐藏行为。
|
||||||
|
- Laravel API 路由: 控制用户登录注册、用户端、客户端和部分公开用户接口隐藏行为。
|
||||||
|
- 管理端系统配置: 暴露可保存的用户前端开关。
|
||||||
|
- 测试: 增加路由边界 feature 测试。
|
||||||
|
预计变更文件: 8-10
|
||||||
|
```
|
||||||
|
|
||||||
|
### 风险评估
|
||||||
|
| 风险 | 等级 | 应对 |
|
||||||
|
|------|------|------|
|
||||||
|
| 误拦截节点 API 导致 `mi-node` 无法同步 | 高 | 不在 `ServerRoute` 上挂载新中间件,并增加节点 API 不返回 404 的测试 |
|
||||||
|
| 关闭用户前端后现有订阅链接不可用 | 中 | 这是隐藏用户侧入口的预期行为;默认开启保持兼容 |
|
||||||
|
| 公开回调接口被误关导致支付/Telegram 回调异常 | 中 | 只拦截 `guest/plan` 与 `guest/comm` 这类用户展示接口,保留 webhook/notify |
|
||||||
|
| 配置值布尔转换不一致 | 低 | 中间件用 `filter_var(..., FILTER_VALIDATE_BOOLEAN)` 统一识别 `0/1/true/false` |
|
||||||
|
|
||||||
|
### 方案取舍
|
||||||
|
```yaml
|
||||||
|
唯一方案理由: 路由中间件能在 Laravel 入口统一隐藏用户前端和用户 API,改动范围清晰,不依赖用户主题源码,也不影响节点/后台路由。
|
||||||
|
放弃的替代路径:
|
||||||
|
- Nginx 路径白名单: 部署层可行但不支持后台开关,且每台服务器配置成本高。
|
||||||
|
- 修改用户主题前端: 仓库内只有用户主题编译产物,无法可靠覆盖 API 暴露问题。
|
||||||
|
- 全局 API 中间件路径判断: 容易误伤管理 API 和回调接口,边界不如路由级挂载明确。
|
||||||
|
回滚边界: 移除新增中间件、路由挂载、配置字段和测试即可恢复原行为;数据库中残留 `frontend_enable` 设置不会影响旧代码。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 技术设计
|
||||||
|
|
||||||
|
### 架构设计
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[后台系统配置] --> B[frontend_enable 设置]
|
||||||
|
B --> C[EnsureUserFrontendEnabled]
|
||||||
|
C -->|开启| D[用户前端/API 原流程]
|
||||||
|
C -->|关闭| E[404]
|
||||||
|
F[/api/v1/server/*] --> G[server 中间件]
|
||||||
|
H[管理后台/API] --> I[原有后台保护]
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 设计
|
||||||
|
#### GET /api/v2/{secure_path}/config/fetch
|
||||||
|
- **响应**: `site.frontend_enable: boolean`
|
||||||
|
|
||||||
|
#### POST /api/v2/{secure_path}/config/save
|
||||||
|
- **请求**: `{ "frontend_enable": true|false }`
|
||||||
|
- **响应**: 沿用现有 `success(true)`
|
||||||
|
|
||||||
|
### 数据模型
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `v2_settings.frontend_enable` | string/bool | 用户前端与用户侧 API 开关,默认 `1` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 核心场景
|
||||||
|
|
||||||
|
### 场景: 用户入口隐藏
|
||||||
|
**模块**: Laravel Web/API 路由
|
||||||
|
**条件**: `frontend_enable=false`
|
||||||
|
**行为**: 访问 `/`、订阅入口、用户登录注册、用户端 API
|
||||||
|
**结果**: 返回 404,不渲染用户主题,不暴露用户侧 API 响应结构。
|
||||||
|
|
||||||
|
### 场景: 节点接口保留
|
||||||
|
**模块**: 节点 API
|
||||||
|
**条件**: `frontend_enable=false`
|
||||||
|
**行为**: `mi-node` 访问 `/api/v1/server/*`
|
||||||
|
**结果**: 路由继续进入原有节点中间件和控制器,不被用户前端开关拦截。
|
||||||
|
|
||||||
|
### 场景: 后台手动开启
|
||||||
|
**模块**: 管理端系统配置
|
||||||
|
**条件**: 管理员进入系统配置站点设置
|
||||||
|
**行为**: 切换“开放用户前端”开关并保存
|
||||||
|
**结果**: 配置写入 `v2_settings`,下次请求按新开关执行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 技术决策
|
||||||
|
|
||||||
|
### user-frontend-access-toggle#D001: 使用路由级中间件控制用户入口
|
||||||
|
**日期**: 2026-04-29
|
||||||
|
**状态**: ✅采纳
|
||||||
|
**背景**: 需要在应用代码内提供后台可控的隐藏能力,同时避免影响节点 API 和后台 API。
|
||||||
|
**选项分析**:
|
||||||
|
| 选项 | 优点 | 缺点 |
|
||||||
|
|------|------|------|
|
||||||
|
| A: 路由级中间件 | 边界清晰、可测试、默认兼容、能避开节点和后台路由 | 需要在多个用户路由类中显式挂载 |
|
||||||
|
| B: Nginx 白名单 | 部署快、应用代码少 | 无后台开关,部署环境差异大 |
|
||||||
|
| C: 全局 API 中间件 | 集中处理 | 动态后台路径、回调接口和节点路径容易误判 |
|
||||||
|
**决策**: 选择方案 A
|
||||||
|
**理由**: 当前需求重点是“后台手动开启”和“节点 API 白名单”,路由级中间件能精确表达边界,并能用 feature test 验证。
|
||||||
|
**影响**: `routes/web.php`、V1/V2 用户路由、中间件注册、后台配置映射和管理端系统配置表单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 验证策略
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
verifyMode: test-first
|
||||||
|
reviewerFocus:
|
||||||
|
- app/Http/Middleware/EnsureUserFrontendEnabled.php
|
||||||
|
- app/Http/Routes/V1/*.php 与 app/Http/Routes/V2/ClientRoute.php 的挂载边界
|
||||||
|
- routes/web.php 中用户入口与管理入口隔离
|
||||||
|
testerFocus:
|
||||||
|
- vendor/bin/phpunit tests/Feature/UserFrontendAccessToggleTest.php
|
||||||
|
- php artisan route:list --path=api/v1/server
|
||||||
|
- admin-frontend npm build
|
||||||
|
uiValidation: optional
|
||||||
|
riskBoundary:
|
||||||
|
- 不修改管理后台安全路径逻辑
|
||||||
|
- 不修改节点 API Token 或节点认证逻辑
|
||||||
|
- 不执行数据库迁移或生产数据写入
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 成果设计
|
||||||
|
|
||||||
|
N/A。本次只在既有系统配置表单中加入一个安全开关,复用现有 Apple 风格后台布局、Element Plus `ElSwitch` 和当前表单密度,不新增页面视觉方向。
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# 任务清单: user-frontend-access-toggle
|
||||||
|
|
||||||
|
> **@status:** completed | 2026-04-29 16:20
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
@feature: user-frontend-access-toggle
|
||||||
|
@created: 2026-04-29
|
||||||
|
@status: completed
|
||||||
|
@mode: R2
|
||||||
|
```
|
||||||
|
|
||||||
|
## LIVE_STATUS
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"status":"completed","completed":6,"failed":0,"pending":0,"total":6,"percent":100,"current":"用户前端访问开关、路由拦截、后台配置与验证已完成","updated_at":"2026-04-29 16:16:00"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 进度概览
|
||||||
|
|
||||||
|
| 完成 | 失败 | 跳过 | 总数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 6 | 0 | 0 | 6 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务列表
|
||||||
|
|
||||||
|
### 1. 后端访问控制
|
||||||
|
|
||||||
|
- [√] 1.1 新增 `app/Http/Middleware/EnsureUserFrontendEnabled.php`
|
||||||
|
- 预期变更: 读取 `frontend_enable` 设置,关闭时对用户入口返回 404,开启时放行。
|
||||||
|
- 完成标准: 中间件能正确识别 `true/false/1/0` 等设置值。
|
||||||
|
- 验证方式: `vendor/bin/phpunit tests/Feature/UserFrontendAccessToggleTest.php`
|
||||||
|
- depends_on: []
|
||||||
|
|
||||||
|
- [√] 1.2 修改 `app/Http/Kernel.php`
|
||||||
|
- 预期变更: 注册 `user.frontend` 路由中间件别名。
|
||||||
|
- 完成标准: 路由类可通过别名挂载新增中间件。
|
||||||
|
- 验证方式: `php artisan route:list` 不报中间件解析错误。
|
||||||
|
- depends_on: [1.1]
|
||||||
|
|
||||||
|
- [√] 1.3 修改 `routes/web.php` 与用户侧 API 路由类
|
||||||
|
- 预期变更: 用户首页、订阅入口、V1 Passport/User/Client、V2 Client、V1 Guest 的公开展示接口挂载 `user.frontend`;节点 API 与后台 API 不挂载。
|
||||||
|
- 完成标准: `frontend_enable=false` 时用户侧入口返回 404,`/api/v1/server/*` 不被该中间件拦截。
|
||||||
|
- 验证方式: `vendor/bin/phpunit tests/Feature/UserFrontendAccessToggleTest.php`
|
||||||
|
- depends_on: [1.2]
|
||||||
|
|
||||||
|
### 2. 后台配置接入
|
||||||
|
|
||||||
|
- [√] 2.1 修改 `app/Http/Controllers/V2/Admin/ConfigController.php` 与 `app/Http/Requests/Admin/ConfigSave.php`
|
||||||
|
- 预期变更: `site` 配置组返回 `frontend_enable`,保存接口允许布尔值。
|
||||||
|
- 完成标准: 后台配置接口能读取和保存开关,不影响现有配置字段。
|
||||||
|
- 验证方式: 特性测试或静态检查配置映射和校验规则。
|
||||||
|
- depends_on: [1.1]
|
||||||
|
|
||||||
|
- [√] 2.2 修改 `admin-frontend/src/utils/systemConfig.ts`
|
||||||
|
- 预期变更: 在站点设置中新增“开放用户前端”开关字段。
|
||||||
|
- 完成标准: 管理端系统配置页能渲染并序列化 `frontend_enable`。
|
||||||
|
- 验证方式: `npm --prefix admin-frontend run build`
|
||||||
|
- depends_on: [2.1]
|
||||||
|
|
||||||
|
### 3. 验证与同步
|
||||||
|
|
||||||
|
- [√] 3.1 新增并执行验证
|
||||||
|
- 预期变更: 增加 `tests/Feature/UserFrontendAccessToggleTest.php`,覆盖默认开启、关闭隐藏、节点 API 不被隐藏。
|
||||||
|
- 完成标准: 相关 PHPUnit 测试通过;管理端构建通过或明确记录阻断原因。
|
||||||
|
- 验证方式: `vendor/bin/phpunit tests/Feature/UserFrontendAccessToggleTest.php`、`npm --prefix admin-frontend run build`
|
||||||
|
- depends_on: [1.3, 2.2]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行日志
|
||||||
|
|
||||||
|
| 时间 | 任务 | 状态 | 备注 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 2026-04-29 15:59 | DESIGN | in_progress | 已完成上下文收集与方案规划 |
|
||||||
|
| 2026-04-29 16:16 | DEVELOP | completed | 已完成代码实现、PHPUnit 测试与管理端构建验证 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行备注
|
||||||
|
|
||||||
|
- 当前方案默认 `frontend_enable=true`,避免升级后自动隐藏现有用户站点。
|
||||||
|
- 保留 `/api/v1/guest/payment/notify/*` 与 `/api/v1/guest/telegram/webhook` 这类外部回调,不将其视为用户前端展示接口。
|
||||||
|
- `php artisan route:list --path=api/v1/server` 在本地因 PHP 缺少 Redis 扩展失败;节点 API 不被前端开关拦截已由 `UserFrontendAccessToggleTest` 覆盖。
|
||||||
|
- 历史方案包 `202604250006_ticket-closed-reply-reopen` 仍标记 `in_progress`,与本次任务无依赖。
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|
||||||
|--------|------|------|---------|------|------|
|
|--------|------|------|---------|------|------|
|
||||||
|
| 202604291559 | user-frontend-access-toggle | implementation | user-frontend-access,admin-frontend | user-frontend-access-toggle#D001 | ✅完成 |
|
||||||
| 202604290153 | parent-node-auto-visibility | - | - | - | ✅完成 |
|
| 202604290153 | parent-node-auto-visibility | - | - | - | ✅完成 |
|
||||||
| 202604290132 | shared-node-traffic-limit | implementation | node-traffic-limit,admin-frontend | shared-node-traffic-limit#D001 | ✅完成 |
|
| 202604290132 | shared-node-traffic-limit | implementation | node-traffic-limit,admin-frontend | shared-node-traffic-limit#D001 | ✅完成 |
|
||||||
| 202604290123 | node-traffic-yesterday-stats | implementation | admin-frontend,backend-admin-api | node-traffic-yesterday-stats#D001 | ✅完成 |
|
| 202604290123 | node-traffic-yesterday-stats | implementation | admin-frontend,backend-admin-api | node-traffic-yesterday-stats#D001 | ✅完成 |
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
- 后端镜像发布工作流位于 `.github/workflows/docker-publish.yml`,使用 `paths-ignore` 排除 `admin-frontend/**`、`.helloagents/**` 与前端发布 workflow;仅这些路径变化时不触发后端镜像发布,混有后端相关文件时仍会触发
|
- 后端镜像发布工作流位于 `.github/workflows/docker-publish.yml`,使用 `paths-ignore` 排除 `admin-frontend/**`、`.helloagents/**` 与前端发布 workflow;仅这些路径变化时不触发后端镜像发布,混有后端相关文件时仍会触发
|
||||||
- 管理端 API 通过 `window.settings.secure_path` 或 `VITE_ADMIN_PATH` 解析 `/api/v2/{secure_path}` 前缀
|
- 管理端 API 通过 `window.settings.secure_path` 或 `VITE_ADMIN_PATH` 解析 `/api/v2/{secure_path}` 前缀
|
||||||
- 登录接口复用 `/api/v2/passport/auth/login`
|
- 登录接口复用 `/api/v2/passport/auth/login`
|
||||||
|
- 用户前端访问由 `frontend_enable` 控制,默认开启;关闭后 `/`、订阅入口和用户侧 API 返回空 404,不输出站点标题或主题内容,节点 API 与管理后台不受影响
|
||||||
- 工单回复链路当前以 `TicketService::reply()` 为统一真相源:管理员或用户再次回复已关闭工单时都会自动把工单状态改回开启,同时继续维护 `reply_status` 与 `last_reply_user_id`
|
- 工单回复链路当前以 `TicketService::reply()` 为统一真相源:管理员或用户再次回复已关闭工单时都会自动把工单状态改回开启,同时继续维护 `reply_status` 与 `last_reply_user_id`
|
||||||
- 邮件发送链路当前以 `SendEmailJob` + `MailService` 为统一入口:`send_email` 队列的单个 job 超时为 60 秒,SMTP 传输超时默认由 `MAIL_TIMEOUT=30` 控制,Redis `retry_after` 默认由 `QUEUE_RETRY_AFTER=90` 控制。
|
- 邮件发送链路当前以 `SendEmailJob` + `MailService` 为统一入口:`send_email` 队列的单个 job 超时为 60 秒,SMTP 传输超时默认由 `MAIL_TIMEOUT=30` 控制,Redis `retry_after` 默认由 `QUEUE_RETRY_AFTER=90` 控制。
|
||||||
- 管理端仪表盘现已接入:
|
- 管理端仪表盘现已接入:
|
||||||
@@ -97,6 +98,7 @@
|
|||||||
- `payment/show`
|
- `payment/show`
|
||||||
- `payment/drop`
|
- `payment/drop`
|
||||||
- `payment/sort`
|
- `payment/sort`
|
||||||
|
- 管理端系统配置页的站点设置包含“开放用户前端”开关,保存后写入 `frontend_enable`
|
||||||
- 订单支付成功后会额外快照保存 `payment_channel / payment_method / payment_amount / payment_ip`,管理端订单详情优先展示真实支付成功信息,再回退当前支付配置
|
- 订单支付成功后会额外快照保存 `payment_channel / payment_method / payment_amount / payment_ip`,管理端订单详情优先展示真实支付成功信息,再回退当前支付配置
|
||||||
- 客户端订阅导出入口位于 `app/Http/Controllers/V1/Client/ClientController.php`,会根据 `flag` / `User-Agent` 匹配 `app/Protocols/*` 导出器
|
- 客户端订阅导出入口位于 `app/Http/Controllers/V1/Client/ClientController.php`,会根据 `flag` / `User-Agent` 匹配 `app/Protocols/*` 导出器
|
||||||
- `Stash` 订阅导出位于 `app/Protocols/Stash.php`,当前对 `AnyTLS` 采用保守兼容:仅客户端版本 `>= 3.3.0` 时导出
|
- `Stash` 订阅导出位于 `app/Protocols/Stash.php`,当前对 `AnyTLS` 采用保守兼容:仅客户端版本 `>= 3.3.0` 时导出
|
||||||
|
|||||||
@@ -10,3 +10,4 @@
|
|||||||
| [order-payment](order-payment.md) | 订单支付成功快照、第三方回调元信息透传与后台支付成功信息展示 | 2026-04-25 |
|
| [order-payment](order-payment.md) | 订单支付成功快照、第三方回调元信息透传与后台支付成功信息展示 | 2026-04-25 |
|
||||||
| [queue-mail](queue-mail.md) | 邮件发送队列、SMTP 运行时配置、Horizon 超时与失败重试边界 | 2026-04-28 |
|
| [queue-mail](queue-mail.md) | 邮件发送队列、SMTP 运行时配置、Horizon 超时与失败重试边界 | 2026-04-28 |
|
||||||
| [subscription-protocols](subscription-protocols.md) | 用户订阅导出入口、协议适配器与 Stash / Clash 系列兼容过滤 | 2026-04-24 |
|
| [subscription-protocols](subscription-protocols.md) | 用户订阅导出入口、协议适配器与 Stash / Clash 系列兼容过滤 | 2026-04-24 |
|
||||||
|
| [user-frontend-access](user-frontend-access.md) | 用户前端访问开关、用户侧 API 隐藏边界与节点 API 白名单 | 2026-04-29 |
|
||||||
|
|||||||
@@ -70,6 +70,7 @@
|
|||||||
- 优惠券编辑弹窗支持金额/比例两种优惠类型、有效期范围、批量生成、自定义券码、指定周期与指定订阅限制
|
- 优惠券编辑弹窗支持金额/比例两种优惠类型、有效期范围、批量生成、自定义券码、指定周期与指定订阅限制
|
||||||
- 系统管理新增独立“系统管理”侧边栏分组,当前已完整实现 `#/system/config`、`#/system/themes`、`#/system/plugins`、`#/system/notices`、`#/system/payments` 与 `#/system/knowledge`
|
- 系统管理新增独立“系统管理”侧边栏分组,当前已完整实现 `#/system/config`、`#/system/themes`、`#/system/plugins`、`#/system/notices`、`#/system/payments` 与 `#/system/knowledge`
|
||||||
- 系统配置页使用真实后端 `config/fetch`、`config/save`、`config/testSendMail` 与 `config/setTelegramWebhook`,并按站点、安全、订阅、邀请佣金、节点、邮件、Telegram、APP、订阅模板 9 个分组组织长表单
|
- 系统配置页使用真实后端 `config/fetch`、`config/save`、`config/testSendMail` 与 `config/setTelegramWebhook`,并按站点、安全、订阅、邀请佣金、节点、邮件、Telegram、APP、订阅模板 9 个分组组织长表单
|
||||||
|
- 系统配置页的站点设置包含“开放用户前端”开关;关闭后由后端让用户首页和用户侧 API 返回空 404,节点 API 与管理后台不受影响
|
||||||
- 主题管理页使用真实后端 `theme/getThemes`、`theme/getThemeConfig`、`theme/saveThemeConfig`、`theme/upload`,并通过 `config/save(frontend_theme)` 完成当前主题切换
|
- 主题管理页使用真实后端 `theme/getThemes`、`theme/getThemeConfig`、`theme/saveThemeConfig`、`theme/upload`,并通过 `config/save(frontend_theme)` 完成当前主题切换
|
||||||
- 主题配置抽屉按后端返回的动态 schema 渲染 `input / textarea / select` 字段,不在前端猜测额外配置项
|
- 主题配置抽屉按后端返回的动态 schema 渲染 `input / textarea / select` 字段,不在前端猜测额外配置项
|
||||||
- 插件管理页使用真实后端 `plugin/types`、`plugin/getPlugins`、`plugin/upload`、`plugin/install`、`plugin/uninstall`、`plugin/enable`、`plugin/disable`、`plugin/config` 与 `plugin/upgrade`
|
- 插件管理页使用真实后端 `plugin/types`、`plugin/getPlugins`、`plugin/upload`、`plugin/install`、`plugin/uninstall`、`plugin/enable`、`plugin/disable`、`plugin/config` 与 `plugin/upgrade`
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# user-frontend-access
|
||||||
|
|
||||||
|
## 职责
|
||||||
|
|
||||||
|
- 控制用户前端网页、用户登录注册、用户中心 API、客户端订阅 API 和公开用户展示接口是否对外开放
|
||||||
|
- 保留节点通信 API、管理后台页面、管理 API 和外部回调接口的原有访问边界
|
||||||
|
- 为后台系统配置提供 `frontend_enable` 开关,默认开启以兼容已有部署
|
||||||
|
|
||||||
|
## 行为规范
|
||||||
|
|
||||||
|
- `frontend_enable` 存储在 `v2_settings`,通过 `admin_setting('frontend_enable', 1)` 读取;缺省值为开启
|
||||||
|
- `EnsureUserFrontendEnabled` 关闭时返回空 404,不渲染用户主题,不输出 `app_name`、站点描述、主题标题或其他站点识别信息
|
||||||
|
- `routes/web.php` 的 `/` 和 `/{subscribe_path}/{token}` 挂载 `user.frontend`,关闭时不会进入主题渲染和订阅控制器
|
||||||
|
- `/api/v1/passport/*`、`/api/v1/user/*`、`/api/v2/user/*`、`/api/v1/client/*`、`/api/v2/client/*` 挂载 `user.frontend`
|
||||||
|
- `/api/v1/guest/plan/fetch` 与 `/api/v1/guest/comm/config` 挂载 `user.frontend`
|
||||||
|
- `/api/v1/guest/payment/notify/*` 与 `/api/v1/guest/telegram/webhook` 保持开放,避免影响支付和 Telegram 回调
|
||||||
|
- `/api/v1/server/*` 与 `/api/v2/server/*` 不挂载 `user.frontend`,确保 mi-node 拉配置、上报在线和上报流量不受用户前端开关影响
|
||||||
|
- 管理后台路由和管理 API 不受 `frontend_enable` 控制;管理后台自身继续依赖 `secure_path` 与既有后台鉴权
|
||||||
|
|
||||||
|
## 依赖关系
|
||||||
|
|
||||||
|
- 依赖 `app/Http/Middleware/EnsureUserFrontendEnabled.php` 执行访问控制
|
||||||
|
- 依赖 `app/Http/Kernel.php` 注册 `user.frontend` 路由中间件别名
|
||||||
|
- 依赖 `app/Http/Controllers/V2/Admin/ConfigController.php` 在 `site` 配置组返回 `frontend_enable`
|
||||||
|
- 依赖 `app/Http/Requests/Admin/ConfigSave.php` 校验并保存 `frontend_enable`
|
||||||
|
- 依赖 `admin-frontend/src/utils/systemConfig.ts` 在站点设置中渲染“开放用户前端”开关
|
||||||
|
- 依赖 `tests/Feature/UserFrontendAccessToggleTest.php` 验证默认开启、关闭隐藏和节点 API 不被误拦截
|
||||||
@@ -119,6 +119,7 @@ export const systemConfigSections: SystemConfigSectionSchema[] = [
|
|||||||
{ key: 'logo', label: 'LOGO', type: 'url', fullWidth: true, nullable: true, placeholder: 'https://cdn.example.com/logo.png' },
|
{ key: 'logo', label: 'LOGO', type: 'url', fullWidth: true, nullable: true, placeholder: 'https://cdn.example.com/logo.png' },
|
||||||
{ key: 'subscribe_url', label: '订阅 URL', type: 'textarea', fullWidth: true, rows: 3, nullable: true, placeholder: '可填写一个或多个订阅入口地址' },
|
{ key: 'subscribe_url', label: '订阅 URL', type: 'textarea', fullWidth: true, rows: 3, nullable: true, placeholder: '可填写一个或多个订阅入口地址' },
|
||||||
{ key: 'tos_url', label: '用户条款 (TOS) URL', type: 'url', fullWidth: true, nullable: true, placeholder: 'https://example.com/tos' },
|
{ key: 'tos_url', label: '用户条款 (TOS) URL', type: 'url', fullWidth: true, nullable: true, placeholder: 'https://example.com/tos' },
|
||||||
|
{ key: 'frontend_enable', label: '开放用户前端', type: 'switch', defaultValue: true, helper: '关闭后首页和用户接口返回 404,节点接口不受影响。' },
|
||||||
{ key: 'stop_register', label: '停止新用户注册', type: 'switch' },
|
{ key: 'stop_register', label: '停止新用户注册', type: 'switch' },
|
||||||
{ key: 'ticket_must_wait_reply', label: '工单等待回复限制', type: 'switch' },
|
{ key: 'ticket_must_wait_reply', label: '工单等待回复限制', type: 'switch' },
|
||||||
{ key: 'try_out_plan_id', label: '注册试用套餐', type: 'select', optionSource: 'plans', valueType: 'number', defaultValue: 0, helper: '选择 0 表示关闭试用。' },
|
{ key: 'try_out_plan_id', label: '注册试用套餐', type: 'select', optionSource: 'plans', valueType: 'number', defaultValue: 0, helper: '选择 0 表示关闭试用。' },
|
||||||
@@ -398,4 +399,3 @@ export function getSystemConfigFieldOptions(
|
|||||||
|
|
||||||
return field.options ?? []
|
return field.options ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ class ConfigController extends Controller
|
|||||||
'site' => [
|
'site' => [
|
||||||
'logo' => admin_setting('logo'),
|
'logo' => admin_setting('logo'),
|
||||||
'force_https' => (int) admin_setting('force_https', 0),
|
'force_https' => (int) admin_setting('force_https', 0),
|
||||||
|
'frontend_enable' => (bool) admin_setting('frontend_enable', 1),
|
||||||
'stop_register' => (int) admin_setting('stop_register', 0),
|
'stop_register' => (int) admin_setting('stop_register', 0),
|
||||||
'app_name' => admin_setting('app_name', 'XBoard'),
|
'app_name' => admin_setting('app_name', 'XBoard'),
|
||||||
'app_description' => admin_setting('app_description', 'XBoard is best!'),
|
'app_description' => admin_setting('app_description', 'XBoard is best!'),
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ class Kernel extends HttpKernel
|
|||||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||||
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
|
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
|
||||||
'user' => \App\Http\Middleware\User::class,
|
'user' => \App\Http\Middleware\User::class,
|
||||||
|
'user.frontend' => \App\Http\Middleware\EnsureUserFrontendEnabled::class,
|
||||||
'admin' => \App\Http\Middleware\Admin::class,
|
'admin' => \App\Http\Middleware\Admin::class,
|
||||||
'client' => \App\Http\Middleware\Client::class,
|
'client' => \App\Http\Middleware\Client::class,
|
||||||
'staff' => \App\Http\Middleware\Staff::class,
|
'staff' => \App\Http\Middleware\Staff::class,
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class EnsureUserFrontendEnabled
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next)
|
||||||
|
{
|
||||||
|
$enabled = filter_var(
|
||||||
|
admin_setting('frontend_enable', true),
|
||||||
|
FILTER_VALIDATE_BOOLEAN
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$enabled) {
|
||||||
|
return response('', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ class ConfigSave extends FormRequest
|
|||||||
// site
|
// site
|
||||||
'logo' => 'nullable|url',
|
'logo' => 'nullable|url',
|
||||||
'force_https' => '',
|
'force_https' => '',
|
||||||
|
'frontend_enable' => 'boolean',
|
||||||
'stop_register' => '',
|
'stop_register' => '',
|
||||||
'app_name' => '',
|
'app_name' => '',
|
||||||
'app_description' => '',
|
'app_description' => '',
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class ClientRoute
|
|||||||
{
|
{
|
||||||
$router->group([
|
$router->group([
|
||||||
'prefix' => 'client',
|
'prefix' => 'client',
|
||||||
'middleware' => 'client'
|
'middleware' => ['user.frontend', 'client']
|
||||||
], function ($router) {
|
], function ($router) {
|
||||||
// Client
|
// Client
|
||||||
$router->get('/subscribe', [ClientController::class, 'subscribe'])->name('client.subscribe.legacy');
|
$router->get('/subscribe', [ClientController::class, 'subscribe'])->name('client.subscribe.legacy');
|
||||||
|
|||||||
@@ -14,14 +14,18 @@ class GuestRoute
|
|||||||
$router->group([
|
$router->group([
|
||||||
'prefix' => 'guest'
|
'prefix' => 'guest'
|
||||||
], function ($router) {
|
], function ($router) {
|
||||||
// Plan
|
$router->group([
|
||||||
$router->get('/plan/fetch', [PlanController::class, 'fetch']);
|
'middleware' => 'user.frontend'
|
||||||
|
], function ($router) {
|
||||||
|
// Plan
|
||||||
|
$router->get('/plan/fetch', [PlanController::class, 'fetch']);
|
||||||
|
// Comm
|
||||||
|
$router->get('/comm/config', [CommController::class, 'config']);
|
||||||
|
});
|
||||||
// Telegram
|
// Telegram
|
||||||
$router->post('/telegram/webhook', [TelegramController::class, 'webhook']);
|
$router->post('/telegram/webhook', [TelegramController::class, 'webhook']);
|
||||||
// Payment
|
// Payment
|
||||||
$router->match(['get', 'post'], '/payment/notify/{method}/{uuid}', [PaymentController::class, 'notify']);
|
$router->match(['get', 'post'], '/payment/notify/{method}/{uuid}', [PaymentController::class, 'notify']);
|
||||||
// Comm
|
|
||||||
$router->get('/comm/config', [CommController::class, 'config']);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ class PassportRoute
|
|||||||
public function map(Registrar $router)
|
public function map(Registrar $router)
|
||||||
{
|
{
|
||||||
$router->group([
|
$router->group([
|
||||||
'prefix' => 'passport'
|
'prefix' => 'passport',
|
||||||
|
'middleware' => 'user.frontend'
|
||||||
], function ($router) {
|
], function ($router) {
|
||||||
// Auth
|
// Auth
|
||||||
$router->post('/auth/register', [AuthController::class, 'register']);
|
$router->post('/auth/register', [AuthController::class, 'register']);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class UserRoute
|
|||||||
{
|
{
|
||||||
$router->group([
|
$router->group([
|
||||||
'prefix' => 'user',
|
'prefix' => 'user',
|
||||||
'middleware' => 'user'
|
'middleware' => ['user.frontend', 'user']
|
||||||
], function ($router) {
|
], function ($router) {
|
||||||
// User
|
// User
|
||||||
$router->get('/resetSecurity', [UserController::class, 'resetSecurity']);
|
$router->get('/resetSecurity', [UserController::class, 'resetSecurity']);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class ClientRoute
|
|||||||
{
|
{
|
||||||
$router->group([
|
$router->group([
|
||||||
'prefix' => 'client',
|
'prefix' => 'client',
|
||||||
'middleware' => 'client'
|
'middleware' => ['user.frontend', 'client']
|
||||||
], function ($router) {
|
], function ($router) {
|
||||||
// App
|
// App
|
||||||
$router->get('/app/getConfig', [AppController::class, 'getConfig']);
|
$router->get('/app/getConfig', [AppController::class, 'getConfig']);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class UserRoute
|
|||||||
{
|
{
|
||||||
$router->group([
|
$router->group([
|
||||||
'prefix' => 'user',
|
'prefix' => 'user',
|
||||||
'middleware' => 'user'
|
'middleware' => ['user.frontend', 'user']
|
||||||
], function ($router) {
|
], function ($router) {
|
||||||
// User
|
// User
|
||||||
$router->get('/resetSecurity', [UserController::class, 'resetSecurity']);
|
$router->get('/resetSecurity', [UserController::class, 'resetSecurity']);
|
||||||
|
|||||||
+3
-3
@@ -71,7 +71,7 @@ Route::get('/', function (Request $request) {
|
|||||||
]);
|
]);
|
||||||
abort(500, '主题加载失败');
|
abort(500, '主题加载失败');
|
||||||
}
|
}
|
||||||
});
|
})->middleware('user.frontend');
|
||||||
|
|
||||||
//TODO:: 兼容
|
//TODO:: 兼容
|
||||||
Route::get('/' . admin_setting('secure_path', admin_setting('frontend_admin_path', hash('crc32b', config('app.key')))), function () {
|
Route::get('/' . admin_setting('secure_path', admin_setting('frontend_admin_path', hash('crc32b', config('app.key')))), function () {
|
||||||
@@ -88,5 +88,5 @@ Route::get('/' . admin_setting('secure_path', admin_setting('frontend_admin_path
|
|||||||
});
|
});
|
||||||
|
|
||||||
Route::get('/' . (admin_setting('subscribe_path', 's')) . '/{token}', [\App\Http\Controllers\V1\Client\ClientController::class, 'subscribe'])
|
Route::get('/' . (admin_setting('subscribe_path', 's')) . '/{token}', [\App\Http\Controllers\V1\Client\ClientController::class, 'subscribe'])
|
||||||
->middleware('client')
|
->middleware(['user.frontend', 'client'])
|
||||||
->name('client.subscribe');
|
->name('client.subscribe');
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\InitializePlugins;
|
||||||
|
use App\Support\Setting;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class UserFrontendAccessToggleTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->withoutMiddleware(InitializePlugins::class);
|
||||||
|
config()->set('app.key', 'base64:' . base64_encode(str_repeat('a', 32)));
|
||||||
|
$this->bindSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_frontend_defaults_to_enabled(): void
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/v1/passport/auth/login', []);
|
||||||
|
|
||||||
|
$this->assertNotSame(404, $response->getStatusCode());
|
||||||
|
$response->assertStatus(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_disabled_frontend_hides_web_entry_without_site_title(): void
|
||||||
|
{
|
||||||
|
$this->setFrontendEnabled(false);
|
||||||
|
|
||||||
|
$response = $this->get('/');
|
||||||
|
|
||||||
|
$response->assertStatus(404);
|
||||||
|
$response->assertContent('');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_disabled_frontend_hides_user_routes(): void
|
||||||
|
{
|
||||||
|
$this->setFrontendEnabled(false);
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/passport/auth/login', [])->assertStatus(404)->assertContent('');
|
||||||
|
$this->getJson('/api/v1/user/info')->assertStatus(404)->assertContent('');
|
||||||
|
$this->get('/s/example-token')->assertStatus(404)->assertContent('');
|
||||||
|
$this->getJson('/api/v1/guest/plan/fetch')->assertStatus(404)->assertContent('');
|
||||||
|
$this->getJson('/api/v2/client/app/getVersion?token=example-token')->assertStatus(404)->assertContent('');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_disabled_frontend_does_not_hide_node_api(): void
|
||||||
|
{
|
||||||
|
$this->setFrontendEnabled(false);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/v1/server/UniProxy/config?token=wrong-token&node_id=1&node_type=vmess');
|
||||||
|
|
||||||
|
$this->assertNotSame(404, $response->getStatusCode());
|
||||||
|
$response->assertStatus(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setFrontendEnabled(bool $enabled): void
|
||||||
|
{
|
||||||
|
$this->bindSettings([
|
||||||
|
'frontend_enable' => $enabled ? 1 : 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bindSettings(array $settings = []): void
|
||||||
|
{
|
||||||
|
$settings = array_change_key_case(array_merge([
|
||||||
|
'server_token' => 'server-token',
|
||||||
|
'subscribe_path' => 's',
|
||||||
|
], $settings), CASE_LOWER);
|
||||||
|
|
||||||
|
$this->app->instance(Setting::class, new class($settings) extends Setting {
|
||||||
|
public function __construct(private array $settings)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return $this->settings[strtolower($key)] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(array $settings): bool
|
||||||
|
{
|
||||||
|
$this->settings = array_change_key_case($settings, CASE_LOWER) + $this->settings;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user