lobbster1234/error-code-search
0
1"""2Admin API — management of the error-code master files, protected by3account+password verified against the 【管理員登入】 sheet (see admin_auth).4 5On login we mint a stateless signed token carrying the admin's identity, sent6back as an HttpOnly cookie and accepted on later requests (or via X-Admin-Token).7No server-side session store, so it survives restarts and multiple workers.8"""9from __future__ import annotations10 11import os12import uuid13from html import escape14 15from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, UploadFile16from fastapi.responses import FileResponse, HTMLResponse, JSONResponse17from pydantic import BaseModel18 19import mailer20from admin_auth import (21 apps_script_call,22 auth_configured,23 check_login,24 fetch_approvers,25 fetch_members,26 make_action_token,27 make_token,28 password_strength_error,29 setup_password,30 verify_action_token,31 verify_token,32)33from errcode_store import (34 CodeExistsError,35 CodeNotFoundError,36 ErrCodeStore,37 split_prefix,38)39from publisher import publish_change40 41router = APIRouter(prefix="/admin", tags=["admin"])42 43_store: ErrCodeStore | None = None44 45 46def get_store() -> ErrCodeStore:47 global _store48 if _store is None:49 _store = ErrCodeStore()50 return _store51 52 53# ── Auth ────────────────────────────────────────────────────────────────────54 55COOKIE_NAME = "errcode_admin"56 57 58def require_admin(request: Request) -> dict:59 """Verify the signed cookie/header token; return the admin identity dict."""60 token = request.cookies.get(COOKIE_NAME) or request.headers.get("X-Admin-Token", "")61 identity = verify_token(token) if token else None62 if not identity:63 raise HTTPException(401, "Admin authentication required")64 return identity65 66 67# ── Models ──────────────────────────────────────────────────────────────────68 69class LoginReq(BaseModel):70 account: str = ""71 password: str72 73 74class SetupPasswordReq(BaseModel):75 account: str76 newPassword: str77 78 79class AddCodeReq(BaseModel):80 code: str81 description: str82 author: str = ""83 project: str = ""84 category: str = "" # title of a brand-new category ("" = existing category)85 86 87class EditCodeReq(BaseModel):88 description: str89 author: str = ""90 project: str = ""91 92 93# ── Auth routes ─────────────────────────────────────────────────────────────94 95def _login_response(identity: dict) -> JSONResponse:96 resp = JSONResponse({"ok": True, "name": identity["name"], "email": identity.get("email", "")})97 resp.set_cookie(98 COOKIE_NAME, make_token(identity),99 httponly=True, samesite="strict", max_age=60 * 60 * 12,100 )101 return resp102 103 104@router.post("/login")105async def login(req: LoginReq):106 if not auth_configured():107 raise HTTPException(503, "Admin login is not configured (set APPS_SCRIPT_URL or ADMIN_PASSWORD)")108 result = await check_login(req.account.strip(), req.password)109 status = result["status"]110 if status == "needs_setup":111 # First login (password cell empty) — the page switches to the112 # set-password step. This flag is only a UX hint; the actual guard113 # (write-only-when-empty) is enforced in Apps Script.114 return JSONResponse({"ok": False, "needsSetup": True})115 if status == "locked":116 raise HTTPException(423, "登入失敗次數過多,帳號已鎖定,請 15 分鐘後再試")117 if status != "ok":118 raise HTTPException(401, "帳號或密碼錯誤(連續錯誤 5 次將鎖定 15 分鐘)")119 return _login_response(result["identity"])120 121 122@router.post("/setup-password")123async def setup_password_route(req: SetupPasswordReq):124 """First-time password setup; on success the user is logged in directly."""125 if not auth_configured():126 raise HTTPException(503, "Admin login is not configured (set APPS_SCRIPT_URL or ADMIN_PASSWORD)")127 account = req.account.strip()128 if not account:129 raise HTTPException(400, "請輸入帳號")130 err = password_strength_error(req.newPassword)131 if err:132 raise HTTPException(400, err)133 134 res = await setup_password(account, req.newPassword)135 if res.get("ok"):136 # Apps Script confirmed the cell was empty and wrote the password —137 # equivalent to a successful login, so mint the session now.138 return _login_response({139 "name": (res.get("name") or account).strip(),140 "email": (res.get("email") or "").strip(),141 "account": account,142 })143 error = res.get("error", "")144 if error == "already_set":145 raise HTTPException(409, "此帳號已設定過密碼,無法覆蓋。忘記密碼請聯絡管理員清空密碼欄後重設")146 if error == "weak_password":147 raise HTTPException(400, res.get("message") or "密碼強度不足")148 if res.get("locked"):149 raise HTTPException(423, "嘗試次數過多,帳號已鎖定 15 分鐘,請稍後再試")150 if error == "not_found":151 raise HTTPException(404, "查無此帳號,請確認帳號是否已由管理員建立")152 raise HTTPException(502, "無法連線至帳號資料庫,請稍後再試")153 154 155@router.post("/logout")156async def logout():157 resp = JSONResponse({"ok": True})158 resp.delete_cookie(COOKIE_NAME)159 return resp160 161 162@router.get("/me")163async def me(identity: dict = Depends(require_admin)):164 return {"admin": True, "name": identity.get("name", ""), "email": identity.get("email", "")}165 166 167# ── Read ────────────────────────────────────────────────────────────────────168 169@router.get("/codes")170async def list_codes(_: None = Depends(require_admin)):171 store = get_store()172 cats = store.categories()173 return {"categories": cats, "count": sum(len(c["codes"]) for c in cats)}174 175 176# ── Mutations (all go through approval) ─────────────────────────────────────177 178@router.post("/codes")179async def add_code(req: AddCodeReq, request: Request, identity: dict = Depends(require_admin)):180 store = get_store()181 store.reload()182 cu = req.code.upper()183 if not split_prefix(cu):184 raise HTTPException(400, "代碼格式錯誤(應為字母+數字,例 NT001)")185 if cu in store.all_codes():186 raise HTTPException(409, f"代碼 {cu} 已存在")187 desc = req.description.strip()188 try:189 store._check_ini_text(desc)190 except ValueError as exc:191 raise HTTPException(400, str(exc))192 category = req.category.strip()193 if category:194 try:195 store.check_new_category(category, split_prefix(cu)[0])196 except ValueError as exc:197 raise HTTPException(400, str(exc))198 requester = req.author.strip() or identity.get("name") or "admin"199 return await _submit_change(200 request, change_type="add", code=cu, description=desc,201 project=req.project.strip() or "-",202 requester=requester, requester_email=identity.get("email", ""),203 category=category,204 )205 206 207@router.put("/codes/{code}")208async def edit_code(code: str, req: EditCodeReq, request: Request,209 identity: dict = Depends(require_admin)):210 store = get_store()211 store.reload()212 cu = code.upper()213 if cu not in store.all_codes():214 raise HTTPException(404, f"代碼 {cu} 不存在")215 desc = req.description.strip()216 try:217 store._check_ini_text(desc)218 except ValueError as exc:219 raise HTTPException(400, str(exc))220 requester = req.author.strip() or identity.get("name") or "admin"221 return await _submit_change(222 request, change_type="edit", code=cu, description=desc,223 project=req.project.strip() or "(edit)",224 requester=requester, requester_email=identity.get("email", ""),225 )226 227 228# ── Import / export ─────────────────────────────────────────────────────────229 230@router.get("/export/ini")231async def export_ini(_: dict = Depends(require_admin)):232 store = get_store()233 if await store.reload_from_github():234 store.save() # refresh local disk so the download reflects GitHub235 return FileResponse(236 store.ini_path, filename="ErrCodeTable.ini", media_type="text/plain"237 )238 239 240@router.get("/export/revision")241async def export_revision(_: dict = Depends(require_admin)):242 store = get_store()243 if await store.reload_from_github():244 store.save() # refresh local disk so the download reflects GitHub245 return FileResponse(246 store.revision_path,247 filename="Error Code Revision.txt",248 media_type="text/plain",249 )250 251 252@router.post("/import/ini")253async def import_ini(file: UploadFile, author: str = "", _: dict = Depends(require_admin)):254 store = get_store()255 raw = await file.read()256 try:257 store.import_ini(raw)258 except ValueError as exc:259 raise HTTPException(400, str(exc))260 store.save()261 result = await publish_change(262 store, revision_block="", summary=f"Import ini ({file.filename})"263 )264 return {"ok": True, "count": len(store.all_codes()), "publish": result}265 266 267# ── Change-request approval workflow (add / edit / delete) ──────────────────268 269class DeleteReq(BaseModel):270 reason: str = ""271 272 273def _base_url(request: Request) -> str:274 return (os.getenv("PUBLIC_BASE_URL") or str(request.base_url)).rstrip("/")275 276 277_TYPE_LABEL = {"add": "新增", "edit": "修改", "delete": "刪除"}278 279 280async def _submit_change(request: Request, *, change_type: str, code: str,281 description: str, project: str, requester: str,282 requester_email: str = "", category: str = "") -> dict:283 """Create a pending change request and email the approver one review link."""284 if not mailer.email_configured():285 raise HTTPException(503, "寄信尚未設定,無法送出審核")286 287 approvers = await fetch_approvers()288 if approvers:289 approver_emails = [a["email"] for a in approvers]290 names = [a.get("name", "").strip() for a in approvers if a.get("name", "").strip()]291 approver_name = "、".join(names) if names else "審核員"292 else:293 env_email = os.getenv("APPROVER_EMAIL", "").strip()294 approver_emails = [env_email] if env_email else []295 approver_name = os.getenv("APPROVER_NAME", "審核員").strip()296 if not approver_emails:297 raise HTTPException(503, "找不到審核員(請在 Google Sheet 的【審核員】分頁填入姓名與 email)")298 299 label = _TYPE_LABEL.get(change_type, change_type)300 req_id = uuid.uuid4().hex[:12]301 302 created = await apps_script_call("createChangeRequest", {303 "id": req_id, "type": change_type, "code": code,304 "description": description, "project": project, "requester": requester,305 })306 if not (created and created.get("ok")):307 raise HTTPException(502, "無法建立審核申請紀錄,請稍後再試")308 309 token = make_action_token({310 "act": "chg", "id": req_id, "type": change_type, "code": code,311 "desc": description, "project": project, "by": requester, "by_email": requester_email,312 "cat": category,313 })314 review_url = f"{_base_url(request)}/admin/review-change?token={token}"315 316 desc_label = "新描述" if change_type == "edit" else "描述"317 subject = f"[Error Code 審核] {requester} 申請{label} {code}"318 body = (319 f"{approver_name} 您好,\n\n"320 f"{requester} 申請{label}一筆 error code,請點以下連結審核(同意 / 拒絕):\n\n"321 f" 動作:{label}\n"322 f" 代碼:{code}\n"323 + (f" 新分類:{category}\n" if category else "")324 + f" {desc_label}:{description or '—'}\n"325 f" 專案/原因:{project or '(未填寫)'}\n\n"326 f"👉 前往審核:\n{review_url}\n\n"327 f"(連結 7 天內有效,且只能使用一次)\n\n— Error Code System 自動通知"328 )329 try:330 await mailer.send_email(approver_emails, subject, body)331 except Exception as exc: # noqa: BLE001332 raise HTTPException(502, f"寄信失敗:{exc}")333 334 return {"ok": True, "message": f"已送出{label}審核給 {approver_name},等待核准"}335 336 337@router.post("/codes/{code}/request-delete")338async def request_delete(code: str, req: DeleteReq, request: Request,339 identity: dict = Depends(require_admin)):340 store = get_store()341 store.reload()342 cu = code.upper()343 if cu not in store.all_codes():344 raise HTTPException(404, f"代碼 {cu} 不存在")345 requester = identity.get("name") or identity.get("account") or "admin"346 return await _submit_change(347 request, change_type="delete", code=cu, description=store.get(cu) or "",348 project=req.reason.strip() or "(未填寫)",349 requester=requester, requester_email=identity.get("email", ""),350 )351 352 353# ── Approval pages ──────────────────────────────────────────────────────────354 355# Same bench-instrument palette as the query page and admin console: cool356# paper, steel ink, blue (always with white text on it — the blue is deep357# enough that dark ink on it would not clear 4.5:1).358_PAGE_CSS = """body{font-family:-apple-system,"Segoe UI","Noto Sans TC",sans-serif;background:#eef1f7;359display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:20px;color:#141a24;}360.card{background:#ffffff;border-radius:16px;box-shadow:0 8px 28px rgba(18,32,60,.14);padding:36px;max-width:460px;width:100%;text-align:center;}361.ic{font-size:2.6rem;}h1{font-size:1.25rem;margin:12px 0 8px;}p{color:#3a4453;line-height:1.6;}362.info{text-align:left;background:#f5f8fc;border:1px solid #e0e5ee;border-radius:10px;padding:14px 16px;margin:18px 0;font-size:.92rem;}363.info b{color:#141a24;}.code{font-family:Consolas,monospace;font-weight:700;color:#1a4c9c;}364.btns{display:flex;gap:10px;margin-top:8px;}365.btn{flex:1;padding:13px;border-radius:10px;font-size:.98rem;font-weight:700;text-decoration:none;display:block;}366.approve{background:#2563c9;color:#fff;box-shadow:0 4px 12px rgba(37,99,201,.30);}367.reject{background:#ffffff;color:#c2453d;border:1.5px solid #f0d3cf;}"""368 369 370def _result_page(title: str, message: str, color: str = "#16a34a") -> HTMLResponse:371 icon = "✅" if color == "#16a34a" else ("⚠️" if color == "#b87503" else "⛔")372 html = f"""<!DOCTYPE html><html lang="zh-TW"><head><meta charset="UTF-8">373<meta name="viewport" content="width=device-width, initial-scale=1.0"><title>{title}</title>374<style>{_PAGE_CSS}</style></head>375<body><div class="card"><div class="ic">{icon}</div>376<h1 style="color:{color};">{title}</h1><p style="white-space:pre-wrap;">{message}</p></div></body></html>"""377 return HTMLResponse(html)378 379 380@router.get("/review-change", response_class=HTMLResponse)381async def review_change(token: str):382 data = verify_action_token(token)383 if not data or data.get("act") != "chg":384 return _result_page("連結無效或已過期", "這個審核連結無效,可能已過期(7 天)或被竄改。", "#c2453d")385 386 info = await apps_script_call("getChangeRequest", {"id": data["id"]})387 if info and info.get("found") and info.get("status") != "pending":388 st = {"approved": "已核准", "rejected": "已拒絕"}.get(info.get("status"), "已處理")389 return _result_page("此連結已使用過", f"這筆申請先前已被處理({st})。\n連結只能使用一次。", "#b87503")390 391 typ = data.get("type", "")392 label = _TYPE_LABEL.get(typ, typ)393 cu = data["code"]394 desc = data.get("desc", "")395 project = data.get("project", "")396 desc_label = "新描述" if typ == "edit" else "描述"397 base = "/admin/resolve-change?token=" + token398 html = f"""<!DOCTYPE html><html lang="zh-TW"><head><meta charset="UTF-8">399<meta name="viewport" content="width=device-width, initial-scale=1.0"><title>變更審核</title>400<style>{_PAGE_CSS}</style></head>401<body><div class="card"><div class="ic">📝</div>402<h1>{label}申請審核</h1>403<p>{data.get('by','管理者')} 申請<b>{label}</b>這筆錯誤代碼,請決定:</p>404<div class="info">405 <div>動作:<b>{label}</b></div>406 <div style="margin-top:6px;">代碼:<span class="code">{cu}</span></div>407 {f'<div style="margin-top:6px;">新分類:<b>{escape(data["cat"])}</b></div>' if data.get("cat") else ''}408 <div style="margin-top:6px;">{desc_label}:<b>{desc or '—'}</b></div>409 <div style="margin-top:6px;">專案/原因:<b>{project or '—'}</b></div>410</div>411<div class="btns">412 <a class="btn approve" href="{base}&d=approve">✓ 同意</a>413 <a class="btn reject" href="{base}&d=reject">✗ 拒絕</a>414</div>415<p style="font-size:.78rem;color:#94a3b8;margin-top:16px;">此連結只能使用一次</p>416</div></body></html>"""417 return HTMLResponse(html)418 419 420async def _notify_mt(subject: str, body: str) -> None:421 members = await fetch_members()422 recipients = [m["email"] for m in members] or mailer.mt_recipients()423 if recipients:424 try:425 await mailer.send_email(recipients, subject, body)426 except Exception: # noqa: BLE001427 pass428 429 430@router.get("/resolve-change", response_class=HTMLResponse)431async def resolve_change(token: str, d: str, background_tasks: BackgroundTasks):432 data = verify_action_token(token)433 if not data or data.get("act") != "chg":434 return _result_page("連結無效或已過期", "這個連結無效,可能已過期或被竄改。", "#c2453d")435 decision = {"approve": "approved", "reject": "rejected"}.get(d)436 if not decision:437 return _result_page("無效操作", "請從信件連結重新操作。", "#c2453d")438 439 # Atomically claim the request (enforces one-time-use).440 res = await apps_script_call("resolveChangeRequest", {"id": data["id"], "decision": decision})441 if res and res.get("alreadyHandled"):442 st = {"approved": "已核准", "rejected": "已拒絕"}.get(res.get("status"), "已處理")443 return _result_page("此連結已使用過", f"這筆申請先前已被處理({st})。連結只能使用一次。", "#b87503")444 if not (res and res.get("ok")):445 return _result_page("處理失敗", "找不到這筆申請,或暫時無法處理,請稍後再試。", "#c2453d")446 447 typ = data.get("type", "")448 label = _TYPE_LABEL.get(typ, typ)449 cu = data["code"]450 desc = data.get("desc", "")451 project = data.get("project", "-")452 requester = data.get("by", "管理者")453 454 if decision == "approved":455 # Apply the change in-memory now (fast); push the heavy work (Sheets sync,456 # index rebuild, MT email, Git) to the background so this page returns fast.457 # Reload from GitHub (source of truth) so we never clobber a recent commit458 # with stale local-disk data after a redeploy.459 store = get_store()460 if not await store.reload_from_github():461 store.reload()462 try:463 if typ == "add":464 if cu not in store.all_codes():465 store.add_code(cu, desc, data.get("cat") or None)466 block = store.prepend_revision(requester, project or "-", [(cu, desc)])467 entries = [{"code": cu, "description": desc, "category": store.category_of(cu)}]468 background_tasks.add_task(publish_change, store, revision_block=block, summary=f"Add {cu}", entries=entries)469 elif typ == "edit":470 store.edit_code(cu, desc)471 block = store.prepend_revision(requester, project or "(edit)", [(cu, desc)])472 entries = [{"code": cu, "description": desc, "category": store.category_of(cu)}]473 background_tasks.add_task(publish_change, store, revision_block=block, summary=f"Edit {cu}", entries=entries)474 elif typ == "delete":475 if cu in store.all_codes():476 real = store.delete_code(cu)477 block = store.prepend_revision(requester, "刪除", [(cu, f"[已刪除] {real}")])478 background_tasks.add_task(publish_change, store, revision_block=block, summary=f"Delete {cu}", deleted_codes=[cu])479 except Exception as exc: # noqa: BLE001480 return _result_page("套用失敗", f"代碼 {cu} 的{label}套用時發生錯誤:{exc}", "#c2453d")481 return _result_page(482 f"已核准({label})",483 f"代碼 {cu} 的{label}已核准 ✓\n資料同步、MT 通知與 Git 備份正在背景處理(約數十秒內完成)。",484 )485 486 # rejected → notify the requester in the background (best-effort), not MT487 by_email = data.get("by_email", "")488 if by_email:489 background_tasks.add_task(490 mailer.send_email, [by_email], f"[Error Code] 您的{label}申請已被拒絕",491 f"代碼 {cu} 的{label}申請已被審核員拒絕,資料未變更。\n\n— Error Code System",492 )493 return _result_page(f"已拒絕({label})", f"已拒絕代碼 {cu} 的{label}申請,資料未變更。", "#b87503")494 