Skip to content

Commit f1a54be

Browse files
sudoghutclaude
andcommitted
清掉 13 條無用路由並加測試防復發(#1250
`/api/select/codes` 指向從未存在的 `ApiController@codes`,命中時由基底 `Controller::__call` 拋 BadMethodCallException=HTTP 500。這類路由不會在啟動時 報錯(Laravel 只在請求進來才解析 action),所以能長期潛伏,只在被外部掃描或 誤點時變成錯誤日誌噪音。 掃完全站後發現不只一條:`Route::resource('operations', ...)` 與 `Route::resource('crowdsourcing', ...)` 各生 7 條,但兩個控制器只實作 index() 與一個空的 store(),於是 create/show/edit/update/destroy 共 10 條全是 500。 - 兩段 resource 整段移除。兩者的 index 早已在上方以顯式路由宣告,且實測改動前後 action 與 middleware 完全等價(crowdsourcing 那條同在 superadmin 群組內,保護 不變);空的 store 無呼叫端,行為僅由「回空 200」變 405。已確認全庫(含 public/build 與 e2e)沒有引用被移除的 URI 或路由名稱,專案也沒有 Ziggy。 - 不改用 ->only(['index']):RouteCollection 以 method+uri 為鍵、後註冊者會逐出 前者,保留 resource 會讓「兩處宣告、只有一處生效」的陰影狀態繼續存在。 - 兩個空 store() 失去入口後一併刪除,避免留著讓人誤以為還有寫入端點。 - 新增 RouteActionsExistTest 守衛所有路由的 action 真的可 dispatch。判定不用 is_callable(基底有 __call,任何方法名都回 true,正是此 bug 能潛伏的原因), 也不只用 method_exists(不看可見性,private 方法一樣會落到 __call 變 500), 改以 Reflection 擋 private/abstract/指向框架基底 Controller 的方法;刻意允許 protected 與 static,兩者經 `$this->{$method}()` 都能正常呼叫,擋了會誤報。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9e9f289 commit f1a54be

8 files changed

Lines changed: 119 additions & 22 deletions

File tree

API.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1519,8 +1519,6 @@ v2 提案流程上線前的舊機制,仍在線但**建議一律改用 `/api/v2
15191519
| 其餘 `search/*``/api/code/addr``/api/name` | **Laravel 分頁物件**`{current_page, data: [...], total, ...}`)——它也有 `data` 鍵,但不是 v2 的 `ok``data``pagination` 信封 |
15201520
| `search/pinyin` | **純文字**(拼音字串;查無親屬對應時另帶 `X-Pinyin-Kinship-Unmatched` 標頭) |
15211521

1522-
**`/api/select/codes` 這條路由雖然註冊了,但對應的控制器方法不存在,呼叫必然 500——請勿使用。**
1523-
15241522
權威定義:`app/Http/Controllers/ApiController.php``/api/select/*``/api/code/addr`)與 `app/Http/Controllers/Api/NameController.php`。這些端點主要為站內 UI 服務,**回應格式不保證穩定**,外部整合請優先用 v2 端點。
15251523

15261524
### 14.5 AI 輔助(需登入)

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44

55
## 2026-08
66

7+
### 清掉 13 條無用路由(其中 11 條指向不存在的控制器方法),並加測試防復發(#1250
8+
- 起因:`/api/select/codes` 指向從未存在的 `ApiController@codes`,命中時由基底 `Controller::__call``BadMethodCallException`=HTTP 500。這類路由不會在啟動時報錯(Laravel 只在請求進來才解析 action),所以能長期潛伏,只在被外部掃描或誤點時變成錯誤日誌噪音。
9+
- 掃完全站後發現不只一條:`Route::resource('operations', ...)``Route::resource('crowdsourcing', ...)` 各生 7 條路由,但兩個控制器只實作 `index()` 與一個**空的 `store()`**,於是 `create``show``edit``update``destroy` 共 10 條全是 500。兩者的 `index` 早已在上方以顯式路由宣告(`crowdsourcing` 那條同在 superadmin 群組內,保護不變),空 `store` 無呼叫端,因此整段移除;已確認全庫沒有引用被拿掉的路由名稱(grep 命中的都是 SQL 欄名 `operations.created_at` 與翻譯鍵 `operations.edit_proposal` 之類的假陽性)。
10+
- 兩個空的 `store()` 失去唯一入口後一併刪除(`OperationsController``CrowdsourcingController`),避免留著讓人誤以為還有寫入端點。
11+
- **防復發是重點**:新增 `RouteActionsExistTest`,遍歷所有註冊路由斷言 action 真的可被 dispatch,失敗訊息直接列出死路由並提示用 `->only()` 收窄。判定刻意不用 `is_callable`(基底 `Controller``__call`,對任何方法名都回 true,正是這個 bug 能潛伏的原因),也不只用 `method_exists`——後者不看可見性,而 `private` 方法在 dispatch 時同樣會落到 `__call` 變 500。因此改以 Reflection 擋掉 private、abstract 與「指向框架基底 Controller 方法」三種情形。**刻意允許 `protected`**`callAction()` 在同一繼承鏈內呼得到,本庫 `Api\ApiController*` 底下十多條路由正是這個形狀)**`static`**(PHP 允許以實例語法呼叫 static 方法,擋它會對合法 action 誤報——這點是 codex 覆核時實測糾正的)。已驗證還原路由檔後測試會紅。
12+
713
### `/api/user` 不再外洩 `confirmation_token`#1248
814
- 起因:`Api\UserController@show``return $request->user()`,序列化範圍全靠 `User::$hidden` 的黑名單,而 `confirmation_token` 不在其中。它不是普通欄位,而是**第二套長期憑證**——`/api/operations/token` 直接把它當眾包 API token 發出去,`/api/operations/{add,update,delete}` 只憑它認證,且**無到期、無撤銷、不驗欄位白名單**。因此一個只被授予唯讀能力的 Sanctum token,可以換到一個能繞過 v2 全部白名單/主鍵校驗、直接往 `operations` 寫入的憑證:這是提權路徑,不只是欄位過度曝露。
915
- 端點改為**顯式白名單**(id/name/email/institution/avatar/is_admin/is_active/時間欄),而不是繼續維護黑名單——根因是「`users` 加一欄就默默對外」,白名單才治得住。順帶不再回傳 `settings`,它內含 `registration_ip``last_login_ip`

app/Http/Controllers/CrowdsourcingController.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ public function __construct(ToolsRepository $toolsRepository, OperationRepositor
2727
$this->biogMainRepository = $biogMainRepository;
2828
}
2929

30-
public function store() {
31-
// Operation::all();
32-
}
30+
// 空的 store() 已隨 `Route::resource('crowdsourcing', ...)` 一併移除(#1250)——
31+
// 該 resource 生出五條指向不存在方法的路由,而 store 本身是空實作、無呼叫端。
32+
// 眾包投稿的寫入端是 /api/operations/*(見 API.md §14.3),不從這裡進來。
3333

3434
/**
3535
* 建立眾包記錄列表(含每列 resource_diff),供 Blade index() 與 Inertia appIndex() 共用。

app/Http/Controllers/OperationsController.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ public function __construct(OperationRepository $operationRepository) {
2525
$this->operationRepository = $operationRepository;
2626
}
2727

28-
public function store() {
29-
// Operation::all();
30-
}
28+
// 原本這裡有一個空的 store()(body 整段被註解掉),唯一入口是
29+
// `Route::resource('operations', ...)` 生出的 POST /operations。該 resource 已於 #1250
30+
// 移除(它同時生出五條指向不存在方法的路由),空 store 因此完全無入口,一併刪除。
31+
// 操作紀錄的寫入一律經 v2 mutation handler 或 OperationRepository,不從這裡進來。
3132

3233
protected function buildOperationsListing(Request $request): array {
3334
$proposalsOnly = filter_var($request->input('proposals_only', false), FILTER_VALIDATE_BOOLEAN);

docs/CODES_SORT_FILTER_AUTH_GATE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
- `codes/{table_name}`(Blade,`CodesController@show`)與 `app/codes/{table_name}`(React/Inertia,`CodesController@appShow`**目前皆未掛 `auth` middleware**,任何人(含匿名爬蟲)都能直接帶 `sort_by``filters[...]` 打深分頁。
1616
- 兩條路由共用同一個 `buildShowPayload()`,該方法就是排序/filter/分頁邏輯的唯一入口。
1717
- 對照組:`view/{key}``ViewTableController`,另一個查詢瀏覽功能)**已經**`->middleware('auth')`,證明「瀏覽類功能要求登入」在本 repo 是既有慣例,非新發明。
18-
- **API 表面確認**(回答 owner 的疑問):`routes/api.php``select` 前綴群組(`auth.optional` middleware)裡有一條 `Route::get('codes', 'ApiController@codes')`實際路徑是 `/api/select/codes`,解析到 `App\Http\Controllers\ApiController`(非 `Api\ApiController`——這條路由沒有走 `Api\` 子命名空間)。**該類別目前根本沒有 `codes()` 方法**這條路由現況是打不通的(method not found);`CodesRepository::codes()`(回傳表名+說明清單)實際只被 `CodesController::index()`/`appIndex()` 呼叫,跟 `routes/api.php` 那條無關。無論如何,`routes/api.php` 裡沒有任何 `table_name` 相關路由,代表**不存在**任何暴露 per-table sort/filter/分頁資料的 API 路徑。既有的 Sanctum token(`routes/ai.php``api.php`)服務的是別的功能,跟這裡無關。
18+
- **API 表面確認**(回答 owner 的疑問):當時 `routes/api.php``select` 前綴群組(`auth.optional` middleware)裡有一條 `Route::get('codes', 'ApiController@codes')`路徑為 `/api/select/codes`,解析到 `App\Http\Controllers\ApiController`(非 `Api\ApiController`——這條路由沒有走 `Api\` 子命名空間)。**該類別根本沒有 `codes()` 方法**所以那條路由打不通(method not found,實際是 500);**#1250 已將它整條移除,現在是 404**`CodesRepository::codes()`(回傳表名+說明清單)實際只被 `CodesController::index()`/`appIndex()` 呼叫,跟 `routes/api.php` 那條無關。無論如何,`routes/api.php` 裡沒有任何 `table_name` 相關路由,代表**不存在**任何暴露 per-table sort/filter/分頁資料的 API 路徑。既有的 Sanctum token(`routes/ai.php``api.php`)服務的是別的功能,跟這裡無關。
1919
- **結論不變:不需要 token 機制**——`sort_by`/`filters` 這個能力只存在於 web 路由(`app.codes.show`),完全走 session,只做 session 登入門檻即可覆蓋全部觸發面。
2020
- `migration_flags.php``codes` 目前為 `'new'`,Blade 版 `show()` 理論上仍是 flag 回退路徑,但 **owner 明確決定本輪不處理 Blade 版**,只收斂 React/Inertia 版(`appShow()`)。已知取捨:若之後把 `codes` flag 切回 `'old'`,Blade 版 `show()` 會回到目前的無門檻狀態,此風險已知且暫時接受,記錄在第 7 節「風險與回退」。
2121
- `codes/{table_name}/export` 走全欄白名單匯出、已有 `throttle:6,1`、不吃 `sort_by`/`filters`,不在本輪範圍。

routes/api.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@
3737
Route::get('choronym', 'ApiController@choronym');
3838
Route::get('dynasty', 'ApiController@dynasty');
3939
Route::get('nianhao', 'ApiController@nianhao');
40-
Route::get('codes', 'ApiController@codes');
40+
// `select/codes` 已移除(#1250):ApiController 從來沒有 codes() 方法,這條路由
41+
// 命中時只會由基底 Controller::__call 拋 BadMethodCallException(HTTP 500)。
42+
// 全庫沒有呼叫端,僅是外部掃描打進來時的錯誤日誌噪音來源。
4143
Route::get('biogaddr', 'ApiController@biogaddr');
4244
Route::get('altcode', 'ApiController@altcode');
4345
Route::get('role', 'ApiController@role');

routes/web.php

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -381,12 +381,10 @@
381381
Route::match(['get', 'post'], 'merge-preview', 'MergePreviewController@index')->name('merge-preview.index');
382382
Route::get('app/merge-preview', 'MergePreviewController@appIndex')->name('app.merge-preview.index')->middleware('inertia');
383383

384-
Route::resource('operations', 'OperationsController', ['name' => [
385-
'show' => 'operations.show',
386-
'create' => 'operations.create',
387-
'edit' => 'operations.edit',
388-
'update' => 'operations.update',
389-
]]);
384+
// 原本這裡是 `Route::resource('operations', ...)`,但 OperationsController 只實作 index()
385+
// 與一個空的 store();resource 因此生出 create/show/edit/update/destroy 五條指向不存在
386+
// 方法的路由,命中即 500(#1250)。index 已在本檔開頭以顯式路由宣告(operations.index),
387+
// 空的 store 沒有任何呼叫端,故整段移除;確認過全庫沒有引用被拿掉的那些路由名稱。
390388
Route::post('operations/{operation}/restore', 'OperationsController@restore')->name('operations.restore');
391389

392390
Route::middleware('auth')->group(function () {
@@ -419,12 +417,10 @@
419417
// 最近眾包錄入記錄
420418
Route::get('crowdsourcing', ['as' => 'crowdsourcing.index', 'uses' => 'CrowdsourcingController@index']);
421419
Route::get('app/crowdsourcing', ['as' => 'app.crowdsourcing.index', 'uses' => 'CrowdsourcingController@appIndex'])->middleware('inertia');
422-
Route::resource('crowdsourcing', 'CrowdsourcingController', ['name' => [
423-
'show' => 'crowdsourcing.show',
424-
'create' => 'crowdsourcing.create',
425-
'edit' => 'crowdsourcing.edit',
426-
'update' => 'crowdsourcing.update',
427-
]]);
420+
// 同 operations(#1250):CrowdsourcingController 只有 index()/appIndex()/
421+
// confirm()/reject() 與一個空的 store(),resource 生出的 create/show/edit/
422+
// update/destroy 全是 500。index 已於上方顯式宣告(同在 superadmin 群組內),
423+
// 空的 store 無呼叫端,整段移除。
428424
Route::get('crowdsourcing/{id}/confirm', 'CrowdsourcingController@confirm');
429425
Route::get('crowdsourcing/{id}/reject', 'CrowdsourcingController@reject');
430426

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use Illuminate\Support\Facades\Route;
6+
use PHPUnit\Framework\Attributes\Test;
7+
use Tests\TestCase;
8+
9+
/**
10+
* #1250:全站不得存在「指向不存在控制器方法」的死路由。
11+
*
12+
* 這類路由不會在啟動時報錯——Laravel 只在請求進來時才解析 action,屆時由基底
13+
* `Illuminate\Routing\Controller::__call` 拋 `BadMethodCallException`,也就是 HTTP 500。
14+
* 因此它們能長期潛伏,只在被外部掃描或使用者誤點時變成一筆 500 進錯誤日誌。
15+
*
16+
* 修這個 issue 時實際清掉 11 條:
17+
* - `api/select/codes`(`ApiController` 從來沒有 `codes()`)
18+
* - `Route::resource('operations', ...)` 生出的 create/show/edit/update/destroy
19+
* - `Route::resource('crowdsourcing', ...)` 生出的同五條
20+
* 後兩組的根因是「resource 一次生 7 條,但控制器只實作 index 與一個空的 store」——
21+
* 這是最容易復發的形狀,所以用這條測試守住,而不是只針對個別 URI 斷言 404。
22+
*
23+
* 略過 Closure 與 invokable(action 名無 `@`):後者框架在**註冊時**就以
24+
* `RouteAction::makeInvokable()` 驗過 `__invoke` 存在,缺就直接拋 UnexpectedValueException,
25+
* 因此不構成盲區。
26+
*/
27+
class RouteActionsExistTest extends TestCase {
28+
#[Test]
29+
public function every_controller_route_points_at_an_existing_method(): void {
30+
$dead = [];
31+
32+
foreach (Route::getRoutes() as $route) {
33+
$action = $route->getActionName();
34+
35+
// Closure 路由與 invokable(無 @)不在此測試範圍。
36+
if ($action === 'Closure' || !str_contains($action, '@')) {
37+
continue;
38+
}
39+
40+
[$class, $method] = explode('@', $action, 2);
41+
42+
if (!class_exists($class)) {
43+
$dead[] = sprintf('%s → %s(類別不存在)', $route->uri(), $action);
44+
45+
continue;
46+
}
47+
48+
// 刻意不用 is_callable:基底 Controller 有 __call,is_callable 對任何方法名都會
49+
// 回 true,正是這個 bug 能潛伏的原因。
50+
if (!method_exists($class, $method)) {
51+
$dead[] = sprintf('%s → %s(方法不存在)', $route->uri(), $action);
52+
53+
continue;
54+
}
55+
56+
// method_exists 過關還不夠:它不看可見性,而 dispatch 是由基底
57+
// Controller::callAction() 以 `$this->{$method}()` 呼叫的,因此
58+
// - protected:同一繼承鏈內呼得到 → 正常運作(本庫 Api\ApiController* 底下
59+
// 有十多條路由就是指向 protected 方法,不能判紅)
60+
// - private:基底呼不到 → 落回 __call → BadMethodCallException → 500
61+
// 所以只擋 private,不要求 public。
62+
//
63+
// 刻意**不擋 static**:PHP 允許以實例語法呼叫 static 方法,`$this->{$method}()`
64+
// 對 public/protected static 都能正常執行(已實測),擋它會對合法的 static
65+
// action 誤報。abstract 則是真的不可實例化呼叫,仍需擋。
66+
$reflection = new \ReflectionMethod($class, $method);
67+
68+
if ($reflection->isPrivate()) {
69+
$dead[] = sprintf('%s → %s(方法是 private,dispatch 時會落到 __call)', $route->uri(), $action);
70+
71+
continue;
72+
}
73+
74+
if ($reflection->isAbstract()) {
75+
$dead[] = sprintf('%s → %s(方法是 abstract,無法呼叫)', $route->uri(), $action);
76+
77+
continue;
78+
}
79+
80+
// 指向框架基底自己的方法(middleware()/callAction()/getMiddleware())也算誤接線:
81+
// method_exists 會放行,但那不是任何人想要的 action。
82+
if ($reflection->getDeclaringClass()->getName() === \Illuminate\Routing\Controller::class) {
83+
$dead[] = sprintf('%s → %s(指向框架基底 Controller 的方法,不是真正的 action)', $route->uri(), $action);
84+
}
85+
}
86+
87+
$this->assertSame(
88+
[],
89+
$dead,
90+
"以下路由指向不存在的控制器方法,命中會 500:\n ".implode("\n ", $dead)
91+
."\n\n若是 Route::resource 生出來的,請用 ->only([...]) 或 ->except([...]) 收窄到控制器真正實作的動作。"
92+
);
93+
}
94+
}

0 commit comments

Comments
 (0)