Team Ai
Apppublic

lobbster1234/error-code-search

sourceHugging Faceupdated 18d agoView on Hugging Face
0likes
admin.py494 linesDownload Raw Back to root
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}&amp;d=approve">✓ 同意</a>413  <a class="btn reject" href="{base}&amp;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