收斂寫入端點的帳號啟用授權(縱深防禦) - #15
Conversation
- 現象(/app/basicinformation/{id}/edit):某條記錄被別人或自己在另一個瀏覽器
分頁新增/刪除/修改後,在本頁切分頁(例如別名→親屬→別名)時,分頁徽章
計數與列表內容都不變,必須整頁重載才看得到。
- 根因一:TabContentLoader 的分頁資料「一載入就永久快取」,切走再切回沿用首次
載入的快照。此前 409fd9e/7f8d7a8 只修了「自己在本頁的修改」。
- 根因二:判斷「已快取」的旗標設在 setCache 的 updater 裡、下一行讀取,而 React
只在 eager state 快路徑才同步呼叫 updater。實測 13 個分頁裡 7~8 個沿用舊快取、
5~6 個湊巧重抓,行為並不確定。
- 根因三:提供徽章計數與人物標題的 summary 端點只在掛載時抓一次(依賴不含
activeTab)。兩個宿主頁(PersonEditor、PersonBrowser/Index)都是同一寫法。
- 新增純函式 tabCachePolicy:每次「分頁啟用」(personId/分頁/重載序號/資料
端點任一改變)都重新向後端取資料。已有資料的分頁在重新驗證期間先繼續顯示舊
資料,不閃載入佔位;basic_info 例外一律等新資料才渲染——其 BasicInfoEditor 在
掛載時把欄位值快照進自己的 state 且不跟 props 同步,先用舊資料掛載會永遠停在
舊值,按下儲存還會把舊值寫回、覆蓋他人修改。啟用起始狀態在 render 期間標記
(React 允許的「隨 props 調整 state」用法),避免編輯器先以舊資料掛載一次。
- 每筆快取帶 activation 戳記,回應落庫前於 updater 內比對 committed state,丟掉
已被新啟用取代的回應(abort 在 cleanup 才發生,仍有空隙)。activationKeyOf 的
組成必須與抓取 effect 的依賴完全一致,否則會出現「effect 重跑卻沿用同一戳記」。
- 兩個宿主頁的 summary 依賴加入 activeTab,同一人物已有摘要時靜默更新並補
AbortController。每輪一律把 loading 設成本輪的意圖,否則「非靜默請求被中止→
下一輪靜默」會讓 summaryLoading 永遠卡住,而 PersonSummaryPanel 的 loading 分支
在 summary 之前短路、整塊面板會空掉。移除已無作用的 refreshTabCache 與
PersonEditor 中從未被讀取的 summaryLoading/summaryError。
- 順手補 i18n:載入中…/載入失敗/重新載入/未支援的分頁原為硬編碼中文,改為
一律重抓後出現頻率大增,改走 person 翻譯(新增 reload/unsupported_tab,
zh-TW/en 同步)。
- 測試:新增 tabCachePolicy.test.ts(vitest 23 tests,鎖住「切分頁必須重抓」、
basic_info 不可先用舊資料渲染、過期回應須丟棄、key 須涵蓋每項 effect 依賴)。
另以 headless Chrome 對真實 MariaDB 端到端驗證 23 項(兩個宿主頁的外部新增/
刪除/改親屬關係類型/改基本資料欄位;每次切分頁只發 1+1 個請求、停在同頁
不輪詢;快速連點與離線重試;摘要面板不卡 loading;不回歸 409fd9e/7f8d7a8),
修復前 6 項失敗、修復後全綠。
- 已執行 ./vendor/bin/phpunit(2429 tests 綠)、npx vitest run(51 tests 綠)、
php-cs-fixer、npm run build。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- 使用者回報 /app/codes/TEXT_CODES/{id}/edit 不再顯示作者。查證:該區塊只存在於舊版
codes/edit.blade.php(2022-01 cbdb-project#186 引入、2025-12 cbdb-project#655 改善多作者顯示),2026-06-26
React/Inertia 上線(3f131d6)時漏移植,而同一個 commit 把 codes flag 翻成 new,
功能自此在正式站消失。旁證:後端端點 /api/select/search/textauthor 完好(12497 回
1 筆、35232 回 98 筆)、翻譯鍵 author_label/no_author_data 留在原地無人使用、
React 端零引用。
- 補回方式改為伺服器端隨頁面一次 JOIN 取回(CodesController::textCodesAuthors() →
text_authors prop),順手修掉舊版三個缺陷:舊版走 AJAX 且每列再各查一次 BIOG_MAIN
與 TEXT_ROLE_CODES(N+1)、paginate(100) 靜默截斷、連 c_personid=0(未詳哨兵)也
給連結。
- 可直接跳轉到作者:每位作者連到 /app/basicinformation/{id}?tab=texts(該作者的著述
分頁,對齊舊版語義;此路徑正是 LegacyBladeFormGate 把舊 URL 導向的目標),另開新
分頁以免弄丟表單上未儲存的輸入(本頁無 dirty 守衛);c_personid=0 不給連結。
- BIOG_TEXT_DATA 主鍵為 (c_personid, c_role_id, c_textid)、含 c_role_id,同一人可在
同一本書掛多個角色,故逐(人物, 角色)成對列出、React key 用此複合鍵;c_textid 固定
時 ORDER BY c_personid, c_role_id 即全序,截斷取的永遠是同一批前 N 筆。
- 唯讀參考不可拖垮編輯本身:appEdit 的 try/catch 在呼叫點之前就結束,故本方法自行接住
例外並降級為 failed 態(前端顯示既有 codes.load_failed),對齊舊版「AJAX 失敗只顯示
紅字、表單照樣可編輯可儲存」的爆炸半徑。未加此保護時缺表會讓整頁 500(已以暫時移除
catch 反向驗證測試會轉紅)。
- c_textid=0 不特別排除:它是真實可編輯的「未知」書目列,其下 37 筆關係人是「著作不明」
的真實資料(角色含撰著者/編纂者),編目者編輯該列時需要看得到才能重新歸屬;改以
isset($rowArray['c_textid']) 區分「真的是 0」與「取不到欄位」。
- 是否截斷改由後端多取一筆判斷,不用 total 與 items 長度相比:count 與 select 是兩次
獨立查詢,兩者之間若有寫入會相等而靜默吞掉截斷提示。截斷提示的量詞用「筆」而非「位」
——同一人的多個角色分列計算。
- 可滾動時補回邊框與底色(對齊舊版 .author-list-scroll):細/覆蓋式滾動條在 Windows
與 macOS 預設看不見,沒有這個框使用者不會知道下面還有列。標題用純文字而非 FormField
的 label(這一區沒有表單控制項,label for 指向 div 是無效關聯)。
- 測試:新增 CodesTextAuthorsTest(14 tests:單作者含連結/同一人多角色不去重/排序為
人物再角色/未詳哨兵無連結/未知書目列仍列出自己的關係人/不跨書洩漏/LEFT JOIN 落空
仍成列/查詢失敗降級不 500/上限截斷回報真實總數/剛好等於上限不算截斷/非 TEXT_CODES
無此 prop)。另以 headless Chrome 對真實 MariaDB 驗證 13 項,含 98 位多作者全列與滾動、
實際點擊後落在該作者的著述分頁。
- 已執行 ./vendor/bin/phpunit(2443 tests 綠)、php-cs-fixer、npm run build。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- 根因:多個 search/* 端點在 LIKE 模糊比對之外,額外用 orWhere('id_col', $q)
讓使用者可直接輸入代碼查詢;MySQL/MariaDB 非嚴格模式下整數欄位與非數字字串比較會
把字串寬鬆轉型成 0,導致人名/關鍵字搜尋時偶爾誤中 id=0(「未詳」占位列),
例如親屬編輯頁 Relative Name 輸入人名後下拉候選出現不相干的「0 未詳」。
- 新增 App\Support\ExactCodeMatchGuard::isNumeric(),僅在輸入為純數字時才用
->when() 附加代碼相等的 orWhere 分支,否則完全略過(不使用哨兵值,因代碼欄位
多為有號 int 且無 UNSIGNED/CHECK 限制,無法保證負值代碼不存在)。
- 套用範圍:ApiController(searchText/searchOffice/socialinst/searchEntry/
searchKincode/searchAssoccode/searchStatuscode/searchBiog/searchEvent/
codeAddr)、v1.php(search,供 /api/v1/biog)、AddrCodeRepository、
AltCodeRepository、BiogMainRepository(namesByQuery/dynastyFacetsByQuery,
供主要人物搜尋 /api/name)。
- 三處已被上層 ctype_digit 分支保證「必為非數字」的 LIKE fallback,直接刪除
該 orWhere 而非用 when(),更簡潔。
- 新增 tests/Unit/ExactCodeMatchGuardTest.php 驗證守衛邏輯;SQLite 測試環境
不會重現 MySQL 的寬鬆轉型,故以單元測試驗證守衛本身。
- 已執行 ./vendor/bin/phpunit(全專案 2445 tests, 11008 assertions 皆過)與
./vendor/bin/php-cs-fixer fix --dry-run(0 files need fixing)。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- CodeAutocomplete(search 模式,全站約 30+ 處自動完成輸入框共用,含親屬編輯頁 Relative Name/c_kin_id)debounce 只用 setTimeout 延後查詢,未對底層 fetch 做序號或取消保護:先打出常見姓氏字首(結果多、後端較慢)、緊接著打出更精確的 字(結果少、較快)時,舊查詢的回應可能晚於新查詢回應才 resolve,導致 setOptions 被過期回應覆蓋,下拉顯示與目前輸入不符的候選。 - 新增 searchSeqRef 遞增序號:一進 effect(使用者按鍵/開關下拉的當下)就立刻 認領新序號,而非等 250ms debounce 觸發才認領,避免「使用者已打字但新請求 尚未送出」的空窗期讓舊回應通過;then/catch/finally 均以序號是否仍為最新 作為守衛。 - 關閉下拉、清空輸入框、effect cleanup(含防禦性處理 mode 切換離開 search) 皆同步重置 loading,避免序號守衛擋下舊請求的 finally 後卡在「載入中」。 - 已執行 npx tsc --noEmit 與 npm run build 確認無新增錯誤、建置成功。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- 起因:上一則作者清單的缺口不是孤例。以「Blade 有用、React 端完全找不到的翻譯鍵」
為橋樑掃過全站(961 個鍵 → 275 個孤兒;扣掉 cbdbapi/person、maps、home 三個本來
就沒有 React 版也沒有 flag 的頁面共 81 個),確認真缺口集中在 Codes/Edit.tsx——
React 把新增/編輯頁寫成泛用表單(columns → 純 Input),舊版 codes/edit.blade.php
的逐欄特殊處理因此整批漏移植。
- 新增 CodesController::codeColumnBehaviour() 作為逐欄行為的單一落點,三個碼表表單
(Create/Edit/ProposalEdit)共用,避免各自再長出一套硬編碼:
- 四個稽核欄一律灰底唯讀。先前 React 可自由輸入卻毫無提示,而後端
enforceAuditFieldsForUpdate 本來就會覆蓋(c_created_* 還原原值、c_modified_*
蓋當下),等於讓使用者對著會被丟棄的輸入框打字。用 readOnly 而非 disabled——
這四欄的用途就是被讀,disabled 會讓文字無法選取複製,舊版用的也是 readonly。
送出內容與改動前逐位元相同(欄位仍在 columns 與 form.data 裡,只改 UI)。
- c_modified_* 補回「提交後會被替換為 X」預覽,改走 AuditActor::currentName(),
與實際落庫的署名同源;舊版用 Auth::user()->name,核准情境下與寫入的雙人名不符。
以 tone=warn 粗體主色呈現(舊版是 text-info + <strong>,不該與一般說明同重量)。
- 欄位提示改由後端供給:Edit.tsx 先前完全沒有,Create.tsx 則是硬編碼中文(英文
語境漏字、且與既有 codes.* 鍵重複)。新增無 HTML 的 hint_* 鍵,連結以 :link
佔位就地嵌回句中(保留舊版讀法,字串本身仍無 HTML,不需 dangerouslySetInnerHTML),
並用 flag-aware 的 codesShowUrl() 指向 React 版碼表頁。
- TEXT_INSTANCE_DATA 依 c_textid 帶入書名(舊版「Load Data」鈕)。修掉舊版兩個缺陷:
舊版打 /api/select/search/text(c_title_chn LIKE %q% OR c_textid = q)再取 data[0],
用 ID 查時可能撈到「標題剛好含這串數字」的別本書——改為新端點
app/codes/text-title/{textId} 主鍵精確查詢(帶 throttle:60,1,理由同 codes.export);
舊版無條件覆寫兩個書名欄——改為只填空欄,不蓋掉人工修訂過的書名。
- 帶入結果訊息逐欄判定而非看「整次請求有沒有書名」:書目常只有中文書名而無拼音書名
(實測 21 筆 TEXT_CODES 如此、7 筆 instance 正好是這形狀),用單一旗標會把「拼音欄
還空著、來源也沒有拼音書名」誤報成「兩欄皆已有值」。現在如實列出哪些欄被帶入、
哪些欄因來源沒有書名而仍為空。訊息就近顯示在 c_textid 下方並帶 role=status,
使用者一動表單即清除訊息與黃底。
- 併發:以單調遞增的 run token 加 c_textid 比對,成功/404/例外三條出口都只允許
「最後一次啟動且 c_textid 未變」的請求寫狀態,舊請求的 404 不會蓋掉新畫面;
reset() 保留 pending,避免請求進行中改欄位讓按鈕重新啟用而交錯。
- 新欄位元件刻意不套用 ui/FormField:它會把 id 與 aria 注入「單一子節點」,而這裡的
子節點是包住輸入框+動作鈕+提示的 div——會讓 div 與 input 拿到相同 id(每頁重複
十餘個)、<label for> 指到不可標記的 div 而失效(點標籤不再聚焦輸入框),
aria-invalid/aria-describedby 也會落在 div 上。改為自行組出 label/aria 關聯。
- 提案調整頁一併套用同一份逐欄行為(稽核欄唯讀,但不給替換預覽——替換發生在核准當下、
由審核人蓋章)。Edit.tsx 補上舊版兩頁都有、React 只有 Create 有的「直接儲存會忽略
此欄」提示。Create.tsx 的兩顆送出按鈕移出 can_propose:app.codes.create 無 auth
middleware,訪客/非活躍帳號原本會看到完整表單卻一顆按鈕都沒有。
- 測試:新增 CodesColumnBehaviourTest(18 tests)。另以 headless Chrome 對真實
MariaDB 驗證 22 項,含帶入「愛日齋叢鈔」、第二次按不覆蓋、每欄僅一個 DOM id 且
label 正確關聯,以及只有中文書名的書目(c_textid=7626)三種訊息情境。
- 已執行 ./vendor/bin/phpunit(2461 tests 綠)、php-cs-fixer、npm run build。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- 承前:泛用 codes 表單漏移植的最後一項。舊版 codes/edit.blade.php 把人物欄渲染成 select2(姓名或 ID 皆可查),React 版是純數字輸入框——使用者必須先知道人物 ID。 - 判準改為「外鍵實際指向 BIOG_MAIN」,以 schema 宣告為唯一權威(personFkColumns())。 舊版按欄名硬編碼 c_personid/c_kin_id,會漏掉 ASSOC_DATA 的 c_assoc_id(社會關係 「對方是誰」)、c_assoc_kin_id、c_assoc_claimer_id、c_tertiary_personid 與 ENTRY_DATA.c_assoc_id 共 5 個真人物欄——恰恰是最需要用姓名搜尋的地方。改用外鍵後 涵蓋 17 張碼表、25 個欄位,且隨 schema 自動跟上,不需維護人工白名單。 兩項刻意取捨:BIOG_MAIN.c_index_year_source_id 納入(欄名像出處但 schema 確實宣告 外鍵)、MERGED_PERSON_DATA.c_personid 不納入(無外鍵),皆由測試明文鎖住。 - 外鍵反射用 Schema::getForeignKeys() 而非 information_schema(後者 SQLite 不存在, 前者由 driver 各自實作,SQLite 走 PRAGMA foreign_key_list),符合雙資料庫相容要求; 反射失敗只讓該欄退回純輸入框。表名比對大小寫不敏感——MySQL 在 lower_case_table_names=1(Windows 預設)回報 biog_main,硬比大寫會讓所有選擇器 無聲消失。 - 選擇器沿用既有 CodeAutocomplete(mode=search,端點 /api/select/search/biog,與親屬 編輯頁「親屬姓名」同一支),因此自動獲得同一份 debounce 與過期回應守衛。後端附上 目前值的顯示名稱並帶 ID(如「11 晁公武 / Chao Gongwu」)——改成選擇器後欄位不再 顯示數字,而編目者是以 ID 工作的;查不到人物時退回顯示 ID,避免欄位空白而 form.data 仍藏著值(使用者以為沒填、送出後撞外鍵)。 - 「未詳」哨兵:searchBiog 刻意把 person 0 的 option value 編成 -999,那是前端 「未設定」哨兵、不是人物 ID。BIOG_MAIN 沒有 -999,直接落庫撞外鍵 1452(訊息還指向 「必填未填」),提案路徑則會把 -999 原樣存進 resource_data、核准時才爆掉——而 「未詳」在 CBDB 極常見。改為在 extractFormData() 單一收口還原成 0(五條 codes 寫入/記錄路徑全部經過它;先前兩處 inline Arr::except 的控制鍵清單與它等價)。 只在「-999 確定不是真實人物」時才還原:c_personid 是有號 int、無 UNSIGNED/CHECK 限制,schema 允許負值(現行資料 min=0、無負值),若真有 person -999,無條件改寫會把 關係靜默改指到別人身上;查不到 BIOG_MAIN 時亦不改寫。同一顧慮見 ExactCodeMatchGuard。 另:僅在本次確實送了 -999 時才做那次查詢,否則會在沒有 BIOG_MAIN 的環境憑空多一次 查詢(第一版如此,弄壞 32 個不相關測試,且只有全量測試抓得到)。 - 人物主鍵欄不再預填猜測值:appCreate 原本把第一個主鍵欄預填成 max+1,而 BIOG_ADDR_DATA、STATUS_DATA 的第一個主鍵欄就是 c_personid;CBDB 人物 ID 很密集, 猜測值往往真的存在,於是選擇器會把它解析成一位真實人物姓名,看起來像「已選好某人」 ——填完其他欄一存,資料就被歸到隨機的人身上。留空反而得到正確的「請確認主鍵欄位 已填寫完整」提示。 - 送出內容不變:清空選擇器得到空字串,與先前文字框可被清空一致。人物欄若同時是主鍵, 透過選擇器改值與先前用文字框改值走同一條 performUpdate(依 URL 主鍵定位、update() 就地換鍵),不是本次新增的能力。 - 測試:新增 CodesPersonPickerTest(15 tests)。另以 headless Chrome 對真實 MariaDB 驗證 7 項(姓名搜尋、ID 搜尋、非人物欄維持純輸入、MERGED_PERSON_DATA 不給選擇器)。 - 已執行 ./vendor/bin/phpunit(2478 tests 綠)、php-cs-fixer、npm run build。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- 舊版 Blade layout 底部的「本次查詢共 N 筆」+管理員明細 modal 在 React 遷移時漏移植,
而 DB::listen 仍無條件把每筆 SQL 與 bindings 累進記憶體——照樣收集卻沒人看得到。
- 權限分界對齊舊版:摘要行對所有人顯示(舊版該行無權限閘,含訪客),明細只給管理員。
故閘的是「保留明細」而非「整個收集」,flag 回退到 Blade 的頁面行為亦不變。
- 顯示端(HandleInertiaRequests)自己再檢查一次 isAdmin,不只依賴收集端閘門。
- shared prop 必須延後求值:inertia-laravel 在 $next() 之前呼叫 share(),直接求值只會
拿到 session/撈使用者那兩筆(實測 2 筆 vs 實際 6~7 筆);再包 Inertia::always 讓
局部重載也更新筆數,但刻意不夾帶明細(props 會進 window.history.state)。
- 前端沿用舊明細的條件由後端明說(details_omitted),非管理員永遠為 false,
避免登出/被降權後同一個 React 殼繼續顯示管理員的 SQL。
- QueryProfile 加明細上限(筆數與耗時另計、永遠精確)、先切再編碼,改為 scoped 綁定
且在回呼內解析;View::composer 由 '*' 收窄到 layouts.dashboard-v3。
- 閘門明確用 web guard(預設 guard 會被 OptionalAuthentication 改寫)、不做跨請求記憶、
例外只 report 第一次且 report 自身再包一層 try/catch。
- /app/manage/{id}/edit 刪除使用者恢復兩段確認(對齊舊版兩道 confirm),payload 不變。
測試:
- 新增 tests/Feature/QueryProfileGateTest.php(15 tests)
- 已執行 ./vendor/bin/phpunit(2493 tests 全綠)、npx vitest run(51 tests)
- 已執行 ./vendor/bin/php-cs-fixer fix 與 npm run build
- 另以 headless Chrome 對真實庫驗證 11 項(6 項查詢明細含非管理員 payload 無 SQL、
5 項兩段刪除含第一段繼續不刪除/第二段取消不刪除/兩段確認才刪除)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fe032f731c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return '500'; | ||
| $user = $this->resolveActiveUserByToken($keyword['token'] ?? null); | ||
| if (!$user) { | ||
| return '403'; |
There was a problem hiding this comment.
Return an actual 403 for token failures
When /api/operations/add is called with a missing, invalid, or inactive token, this branch returns the literal string "403"; because add() returns that value directly, Laravel will send an HTTP 200 response with body 403. Callers that rely on HTTP status will treat the rejected write as successful, and the same pattern is present in the update/delete token paths, so these branches should return a response with status 403 instead of a plain string.
Useful? React with 👍 / 👎.
- MutationController:store/create/delete/batchStore 入口統一加上 guardActiveUser(未登入回 401、帳號未啟用回 403);resubmit 補上「提案 本人」分支缺少的 isActive 檢查。各 handler 既有的 direct/proposal 角色 授權(canWriteDirectly/canPropose)維持不變,此層為最外層縱深防禦, 避免日後新增 handler 漏授權時未啟用帳號仍能進入寫入路徑。 - OperationsController:token 通道(add/update/destroy_operations) 改以 resolveActiveUserByToken 解析 confirmation_token 並要求帳號啟用, 未啟用或查無使用者一律回 403;一併收斂原先脆弱的 users 直查與取值邏輯。 - 只影響寫入端點;讀取端點(get/oppositeEdges)不變。 - 已以 php-cs-fixer 檢查格式(無變更)。
|
發錯倉庫,改於上游 cbdb-project/cbdb-online-main-server 重新提交。 |
fe032f7 to
ee0232f
Compare
目的
為所有資料寫入端點補上一致的「帳號需為啟用狀態(is_active=1)」授權檢查,作為最外層縱深防禦,避免日後新增 handler 時漏掉授權而讓未啟用帳號進入寫入路徑。
變更
store/create/delete/batchStore入口統一加上guardActiveUser()(未登入回 401、帳號未啟用回 403);resubmit補上原本「提案本人」分支缺少的isActive()檢查。各 handler 既有的 direct/proposal 角色授權(canWriteDirectly/canPropose)維持不變。add/update/destroy_operations)改以resolveActiveUserByToken()解析confirmation_token並要求帳號啟用,未啟用或查無使用者一律回 403;一併收斂原先脆弱的users直查與取值邏輯。get/oppositeEdges)行為不變。相容性 / 風險
401 Unauthenticated./403 該使用者沒有權限,請聯繫管理員),啟用中的正常使用者行為不變。測試
php-cs-fixer:無格式變更。./vendor/bin/phpunit --filter 'ApiV2Mutate|Proposal|Operations'。