diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md index b54b81b..460d8d3 100644 --- a/.helloagents/CHANGELOG.md +++ b/.helloagents/CHANGELOG.md @@ -3,7 +3,7 @@ ## [0.6.23] - 2026-04-29 ### 新增 -- **[user-frontend-access]**: 新增用户前端访问开关;后台站点设置可切换 `frontend_enable`,关闭后 `/`、订阅入口和用户侧 API 返回空 404,不渲染站点标题或用户主题内容,同时保留节点 API 与管理后台原有访问边界 — by yinjianm +- **[user-frontend-access]**: 新增用户前端访问开关;后台站点设置可切换 `frontend_enable`,关闭后仅用户首页 `/` 返回空 404,不渲染站点标题或用户主题内容,订阅/API、节点 API 与管理后台保持原有访问边界 — by yinjianm - 方案: [202604291559_user-frontend-access-toggle](archive/2026-04/202604291559_user-frontend-access-toggle/) - 决策: user-frontend-access-toggle#D001(使用路由级中间件控制用户入口) diff --git a/.helloagents/INDEX.md b/.helloagents/INDEX.md index 604545b..bd9f924 100644 --- a/.helloagents/INDEX.md +++ b/.helloagents/INDEX.md @@ -23,7 +23,7 @@ active_package: 无 - [order-payment](modules/order-payment.md): 订单支付成功快照、第三方回调元信息透传与后台支付成功信息展示 - [queue-mail](modules/queue-mail.md): 邮件发送队列、SMTP 运行时配置、Horizon 超时与失败重试边界 - [subscription-protocols](modules/subscription-protocols.md): 客户端订阅导出入口、协议适配器与版本兼容过滤 -- [user-frontend-access](modules/user-frontend-access.md): 用户前端访问开关、用户侧 API 隐藏边界与节点 API 白名单 +- [user-frontend-access](modules/user-frontend-access.md): 用户前端首页访问开关与 API 保留边界 ## 归档与变更 diff --git a/.helloagents/context.md b/.helloagents/context.md index dbff452..5e4b813 100644 --- a/.helloagents/context.md +++ b/.helloagents/context.md @@ -17,7 +17,7 @@ - 后端镜像发布工作流位于 `.github/workflows/docker-publish.yml`,使用 `paths-ignore` 排除 `admin-frontend/**`、`.helloagents/**` 与前端发布 workflow;仅这些路径变化时不触发后端镜像发布,混有后端相关文件时仍会触发 - 管理端 API 通过 `window.settings.secure_path` 或 `VITE_ADMIN_PATH` 解析 `/api/v2/{secure_path}` 前缀 - 登录接口复用 `/api/v2/passport/auth/login` -- 用户前端访问由 `frontend_enable` 控制,默认开启;关闭后 `/`、订阅入口和用户侧 API 返回空 404,不输出站点标题或主题内容,节点 API 与管理后台不受影响 +- 用户前端访问由 `frontend_enable` 控制,默认开启;关闭后仅用户首页 `/` 返回空 404,不输出站点标题或主题内容,订阅/API、节点 API 与管理后台不受影响 - 工单回复链路当前以 `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` 控制。 - 管理端仪表盘现已接入: diff --git a/.helloagents/modules/_index.md b/.helloagents/modules/_index.md index b2b0592..e1f5d49 100644 --- a/.helloagents/modules/_index.md +++ b/.helloagents/modules/_index.md @@ -10,4 +10,4 @@ | [order-payment](order-payment.md) | 订单支付成功快照、第三方回调元信息透传与后台支付成功信息展示 | 2026-04-25 | | [queue-mail](queue-mail.md) | 邮件发送队列、SMTP 运行时配置、Horizon 超时与失败重试边界 | 2026-04-28 | | [subscription-protocols](subscription-protocols.md) | 用户订阅导出入口、协议适配器与 Stash / Clash 系列兼容过滤 | 2026-04-24 | -| [user-frontend-access](user-frontend-access.md) | 用户前端访问开关、用户侧 API 隐藏边界与节点 API 白名单 | 2026-04-29 | +| [user-frontend-access](user-frontend-access.md) | 用户前端首页访问开关与 API 保留边界 | 2026-04-29 | diff --git a/.helloagents/modules/admin-frontend.md b/.helloagents/modules/admin-frontend.md index 89fc3a3..4588cfe 100644 --- a/.helloagents/modules/admin-frontend.md +++ b/.helloagents/modules/admin-frontend.md @@ -70,7 +70,7 @@ - 优惠券编辑弹窗支持金额/比例两种优惠类型、有效期范围、批量生成、自定义券码、指定周期与指定订阅限制 - 系统管理新增独立“系统管理”侧边栏分组,当前已完整实现 `#/system/config`、`#/system/themes`、`#/system/plugins`、`#/system/notices`、`#/system/payments` 与 `#/system/knowledge` - 系统配置页使用真实后端 `config/fetch`、`config/save`、`config/testSendMail` 与 `config/setTelegramWebhook`,并按站点、安全、订阅、邀请佣金、节点、邮件、Telegram、APP、订阅模板 9 个分组组织长表单 -- 系统配置页的站点设置包含“开放用户前端”开关;关闭后由后端让用户首页和用户侧 API 返回空 404,节点 API 与管理后台不受影响 +- 系统配置页的站点设置包含“开放用户前端”开关;关闭后由后端让用户首页 `/` 返回空 404,订阅/API、节点 API 与管理后台不受影响 - 主题管理页使用真实后端 `theme/getThemes`、`theme/getThemeConfig`、`theme/saveThemeConfig`、`theme/upload`,并通过 `config/save(frontend_theme)` 完成当前主题切换 - 主题配置抽屉按后端返回的动态 schema 渲染 `input / textarea / select` 字段,不在前端猜测额外配置项 - 插件管理页使用真实后端 `plugin/types`、`plugin/getPlugins`、`plugin/upload`、`plugin/install`、`plugin/uninstall`、`plugin/enable`、`plugin/disable`、`plugin/config` 与 `plugin/upgrade` diff --git a/.helloagents/modules/user-frontend-access.md b/.helloagents/modules/user-frontend-access.md index b5aa801..2382922 100644 --- a/.helloagents/modules/user-frontend-access.md +++ b/.helloagents/modules/user-frontend-access.md @@ -2,18 +2,18 @@ ## 职责 -- 控制用户前端网页、用户登录注册、用户中心 API、客户端订阅 API 和公开用户展示接口是否对外开放 -- 保留节点通信 API、管理后台页面、管理 API 和外部回调接口的原有访问边界 +- 控制用户前端首页 HTML 是否对外开放 +- 保留订阅/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 回调 +- `routes/web.php` 的 `/` 挂载 `user.frontend`,关闭时不会进入主题渲染 +- `/{subscribe_path}/{token}` 保持原有 `client` 中间件,不受 `frontend_enable` 控制 +- `/api/v1/passport/*`、`/api/v1/user/*`、`/api/v2/user/*`、`/api/v1/client/*`、`/api/v2/client/*` 不挂载 `user.frontend` +- `/api/v1/guest/*` 保持原有访问边界,不受 `frontend_enable` 控制 - `/api/v1/server/*` 与 `/api/v2/server/*` 不挂载 `user.frontend`,确保 mi-node 拉配置、上报在线和上报流量不受用户前端开关影响 - 管理后台路由和管理 API 不受 `frontend_enable` 控制;管理后台自身继续依赖 `secure_path` 与既有后台鉴权 diff --git a/admin-frontend/src/utils/systemConfig.ts b/admin-frontend/src/utils/systemConfig.ts index 4d046ac..487af31 100644 --- a/admin-frontend/src/utils/systemConfig.ts +++ b/admin-frontend/src/utils/systemConfig.ts @@ -119,7 +119,7 @@ export const systemConfigSections: SystemConfigSectionSchema[] = [ { 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: '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: 'frontend_enable', label: '开放用户前端', type: 'switch', defaultValue: true, helper: '关闭后首页返回空 404,API 不受影响。' }, { key: 'stop_register', 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 表示关闭试用。' }, diff --git a/app/Http/Routes/V1/ClientRoute.php b/app/Http/Routes/V1/ClientRoute.php index 6999d56..ad13989 100644 --- a/app/Http/Routes/V1/ClientRoute.php +++ b/app/Http/Routes/V1/ClientRoute.php @@ -11,7 +11,7 @@ class ClientRoute { $router->group([ 'prefix' => 'client', - 'middleware' => ['user.frontend', 'client'] + 'middleware' => 'client' ], function ($router) { // Client $router->get('/subscribe', [ClientController::class, 'subscribe'])->name('client.subscribe.legacy'); diff --git a/app/Http/Routes/V1/GuestRoute.php b/app/Http/Routes/V1/GuestRoute.php index bb5ca6c..3c4f571 100644 --- a/app/Http/Routes/V1/GuestRoute.php +++ b/app/Http/Routes/V1/GuestRoute.php @@ -14,18 +14,14 @@ class GuestRoute $router->group([ 'prefix' => 'guest' ], function ($router) { - $router->group([ - 'middleware' => 'user.frontend' - ], function ($router) { - // Plan - $router->get('/plan/fetch', [PlanController::class, 'fetch']); - // Comm - $router->get('/comm/config', [CommController::class, 'config']); - }); + // Plan + $router->get('/plan/fetch', [PlanController::class, 'fetch']); // Telegram $router->post('/telegram/webhook', [TelegramController::class, 'webhook']); // Payment $router->match(['get', 'post'], '/payment/notify/{method}/{uuid}', [PaymentController::class, 'notify']); + // Comm + $router->get('/comm/config', [CommController::class, 'config']); }); } } diff --git a/app/Http/Routes/V1/PassportRoute.php b/app/Http/Routes/V1/PassportRoute.php index 22268ff..3134b96 100644 --- a/app/Http/Routes/V1/PassportRoute.php +++ b/app/Http/Routes/V1/PassportRoute.php @@ -10,8 +10,7 @@ class PassportRoute public function map(Registrar $router) { $router->group([ - 'prefix' => 'passport', - 'middleware' => 'user.frontend' + 'prefix' => 'passport' ], function ($router) { // Auth $router->post('/auth/register', [AuthController::class, 'register']); diff --git a/app/Http/Routes/V1/UserRoute.php b/app/Http/Routes/V1/UserRoute.php index 5d3fdff..92c3605 100644 --- a/app/Http/Routes/V1/UserRoute.php +++ b/app/Http/Routes/V1/UserRoute.php @@ -22,7 +22,7 @@ class UserRoute { $router->group([ 'prefix' => 'user', - 'middleware' => ['user.frontend', 'user'] + 'middleware' => 'user' ], function ($router) { // User $router->get('/resetSecurity', [UserController::class, 'resetSecurity']); diff --git a/app/Http/Routes/V2/ClientRoute.php b/app/Http/Routes/V2/ClientRoute.php index 3ff32e5..693a40d 100644 --- a/app/Http/Routes/V2/ClientRoute.php +++ b/app/Http/Routes/V2/ClientRoute.php @@ -10,7 +10,7 @@ class ClientRoute { $router->group([ 'prefix' => 'client', - 'middleware' => ['user.frontend', 'client'] + 'middleware' => 'client' ], function ($router) { // App $router->get('/app/getConfig', [AppController::class, 'getConfig']); diff --git a/app/Http/Routes/V2/UserRoute.php b/app/Http/Routes/V2/UserRoute.php index abe2512..38bc12f 100644 --- a/app/Http/Routes/V2/UserRoute.php +++ b/app/Http/Routes/V2/UserRoute.php @@ -10,7 +10,7 @@ class UserRoute { $router->group([ 'prefix' => 'user', - 'middleware' => ['user.frontend', 'user'] + 'middleware' => 'user' ], function ($router) { // User $router->get('/resetSecurity', [UserController::class, 'resetSecurity']); diff --git a/routes/web.php b/routes/web.php index fa16368..7a11840 100755 --- a/routes/web.php +++ b/routes/web.php @@ -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']) - ->middleware(['user.frontend', 'client']) + ->middleware('client') ->name('client.subscribe'); diff --git a/tests/Feature/UserFrontendAccessToggleTest.php b/tests/Feature/UserFrontendAccessToggleTest.php index 66133c2..d77987d 100644 --- a/tests/Feature/UserFrontendAccessToggleTest.php +++ b/tests/Feature/UserFrontendAccessToggleTest.php @@ -4,6 +4,8 @@ namespace Tests\Feature; use App\Http\Middleware\InitializePlugins; use App\Support\Setting; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Route; use Tests\TestCase; class UserFrontendAccessToggleTest extends TestCase @@ -35,15 +37,17 @@ class UserFrontendAccessToggleTest extends TestCase $response->assertContent(''); } - public function test_disabled_frontend_hides_user_routes(): void + public function test_disabled_frontend_does_not_hide_user_api_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(''); + $this->postJson('/api/v1/passport/auth/login', [])->assertStatus(422); + $this->getJson('/api/v1/user/info')->assertStatus(403); + + $this->assertRouteDoesNotUseFrontendGate('GET', '/s/example-token'); + $this->assertRouteDoesNotUseFrontendGate('GET', '/api/v1/guest/plan/fetch'); + $this->assertRouteDoesNotUseFrontendGate('GET', '/api/v1/guest/comm/config'); + $this->assertRouteDoesNotUseFrontendGate('GET', '/api/v2/client/app/getVersion'); } public function test_disabled_frontend_does_not_hide_node_api(): void @@ -63,6 +67,13 @@ class UserFrontendAccessToggleTest extends TestCase ]); } + private function assertRouteDoesNotUseFrontendGate(string $method, string $uri): void + { + $route = Route::getRoutes()->match(Request::create($uri, $method)); + + $this->assertNotContains('user.frontend', $route->gatherMiddleware()); + } + private function bindSettings(array $settings = []): void { $settings = array_change_key_case(array_merge([