diff --git a/app/routers/web.py b/app/routers/web.py index 59228ef..49c91f3 100644 --- a/app/routers/web.py +++ b/app/routers/web.py @@ -94,6 +94,18 @@ from app.i18n import get_languages as _get_languages templates.env.globals["get_languages"] = _get_languages +def _render(request: Request, name: str, ctx: dict) -> HTMLResponse: + """Render a Jinja2 template and return HTMLResponse. + + Bypasses Starlette TemplateResponse to avoid a Jinja2 cache bug where + the context dict is passed as ``globals`` and used as an unhashable + cache key (``TypeError: unhashable type: 'dict'``). + """ + ctx["request"] = request + html = templates.get_template(name).render(ctx) + return HTMLResponse(html) + + def _get_copyright() -> str: from app.models import SystemConfig as _SC from app.database import SessionLocal @@ -151,8 +163,7 @@ def index_page(request: Request, db: Session = Depends(get_db)): if not lang or lang not in avail: lang = "zh" - return templates.TemplateResponse("index.html", { - "request": request, "postcards": all_postcards[:20], "lang": lang, + return _render(request, "index.html", {"postcards": all_postcards[:20], "lang": lang, "has_more": len(all_postcards) > 20, }) @@ -248,7 +259,7 @@ def login_page(request: Request, db: Session = Depends(get_db)): user = redirect_if_not_logged_in(request, db) if user: return _redirect("/dashboard") - return templates.TemplateResponse("login.html", {"request": request}) + return _render(request, "login.html", {"request": request}) @router.post("/login") @@ -263,8 +274,7 @@ def login_submit( cleanup_expired_sessions(db) user = db.query(User).filter(User.username == username).first() if not user or not verify_password(password, user.password_hash): - return templates.TemplateResponse( - "login.html", {"request": request, "error": "用户名或密码错误"}, status_code=401 + return _render(request, "login.html", {"error": "用户名或密码错误"}, status_code=401 ) is_remember = remember_me == "on" token = create_session(user.id, db, remember_me=is_remember) @@ -283,7 +293,7 @@ def register_page(request: Request, db: Session = Depends(get_db)): user = redirect_if_not_logged_in(request, db) if user: return _redirect("/dashboard") - return templates.TemplateResponse("register.html", {"request": request}) + return _render(request, "register.html", {"request": request}) @router.post("/register") @@ -298,30 +308,24 @@ def register_submit( # Validate invite code code_obj = db.query(InviteCode).filter(InviteCode.code == invite_code).first() if not code_obj: - return templates.TemplateResponse( - "register.html", {"request": request, "error": "邀请码无效"}, status_code=400 + return _render(request, "register.html", {"error": "邀请码无效"}, status_code=400 ) if code_obj.expires_at and code_obj.expires_at < datetime.utcnow(): - return templates.TemplateResponse( - "register.html", {"request": request, "error": "邀请码已过期"}, status_code=400 + return _render(request, "register.html", {"error": "邀请码已过期"}, status_code=400 ) if code_obj.max_uses is not None and code_obj.use_count >= code_obj.max_uses: - return templates.TemplateResponse( - "register.html", {"request": request, "error": "邀请码使用次数已用尽"}, status_code=400 + return _render(request, "register.html", {"error": "邀请码使用次数已用尽"}, status_code=400 ) # Validate passwords if password != confirm_password: - return templates.TemplateResponse( - "register.html", {"request": request, "error": "两次输入的密码不一致"}, status_code=400 + return _render(request, "register.html", {"error": "两次输入的密码不一致"}, status_code=400 ) if len(password) < 6: - return templates.TemplateResponse( - "register.html", {"request": request, "error": "密码至少6个字符"}, status_code=400 + return _render(request, "register.html", {"error": "密码至少6个字符"}, status_code=400 ) # Check username if db.query(User).filter(User.username == username).first(): - return templates.TemplateResponse( - "register.html", {"request": request, "error": "用户名已存在"}, status_code=400 + return _render(request, "register.html", {"error": "用户名已存在"}, status_code=400 ) # Create user new_user = User(username=username, password_hash=hash_password(password), invited_by_code_id=code_obj.id) @@ -359,7 +363,9 @@ def _admin_context(user, db, active_tab="invites", **extra): def _admin_render(request, user, db, active_tab="invites", **extra): - return templates.TemplateResponse("admin.html", _admin_context(user, db, active_tab, request=request, **extra)) + ctx = _admin_context(user, db, active_tab, **extra) + ctx["request"] = request + return _render(request, "admin.html", ctx) @router.get("/admin", response_class=HTMLResponse) @@ -501,8 +507,7 @@ def settings_page( for p in profiles: profile_cards[p.id] = db.query(Postcard).filter(Postcard.profile_id == p.id).all() api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all() - return templates.TemplateResponse("settings.html", { - "request": request, "user": user, "profiles": profiles, + return _render(request, "settings.html", {"user": user, "profiles": profiles, "profile_cards": profile_cards, "active_tab": active_tab, "api_keys": api_keys, }) @@ -516,13 +521,11 @@ def change_username( db: Session = Depends(get_db), ): if db.query(User).filter(User.username == new_username, User.id != user.id).first(): - return templates.TemplateResponse( - "settings.html", {"request": request, "user": user, "error": "用户名已存在"}, status_code=400 + return _render(request, "settings.html", {"user": user, "error": "用户名已存在"}, status_code=400 ) user.username = new_username db.commit() - return templates.TemplateResponse( - "settings.html", {"request": request, "user": user, "success": "用户名修改成功"} + return _render(request, "settings.html", {"user": user, "success": "用户名修改成功"} ) @@ -536,21 +539,17 @@ def change_password( db: Session = Depends(get_db), ): if not verify_password(old_password, user.password_hash): - return templates.TemplateResponse( - "settings.html", {"request": request, "user": user, "error": "当前密码错误"}, status_code=400 + return _render(request, "settings.html", {"user": user, "error": "当前密码错误"}, status_code=400 ) if new_password != confirm_password: - return templates.TemplateResponse( - "settings.html", {"request": request, "user": user, "error": "两次输入的新密码不一致"}, status_code=400 + return _render(request, "settings.html", {"user": user, "error": "两次输入的新密码不一致"}, status_code=400 ) if len(new_password) < 6: - return templates.TemplateResponse( - "settings.html", {"request": request, "user": user, "error": "新密码长度至少6位"}, status_code=400 + return _render(request, "settings.html", {"user": user, "error": "新密码长度至少6位"}, status_code=400 ) user.password_hash = hash_password(new_password) db.commit() - return templates.TemplateResponse( - "settings.html", {"request": request, "user": user, "success": "密码修改成功"} + return _render(request, "settings.html", {"user": user, "success": "密码修改成功"} ) @@ -626,8 +625,7 @@ def dashboard(request: Request, user: User = Depends(get_current_user_web), db: from datetime import datetime, timezone as _tz - return templates.TemplateResponse("dashboard.html", { - "request": request, "user": user, "profiles": profiles, + return _render(request, "dashboard.html", {"user": user, "profiles": profiles, "total": total, "sent": sent, "delivered": delivered, "received": received, "countries": countries, "recent": recent, "sent_pct": sent_pct, "delivered_pct": delivered_pct, "received_pct": received_pct, @@ -651,7 +649,7 @@ def profile_list(request: Request, user: User = Depends(get_current_user_web), d "received": sum(1 for c in cards if c.status == "received"), "countries": len({c.country_to for c in cards if c.country_to}), } - return templates.TemplateResponse("profiles.html", {"request": request, "user": user, "profiles": profiles, "extras": extras}) + return _render(request, "profiles.html", {"user": user, "profiles": profiles, "extras": extras}) @router.post("/profiles") @@ -719,9 +717,7 @@ def postcard_list( sent = [pc for pc in postcards if pc.status in ("pending", "sent", "delivered")] received = [pc for pc in postcards if pc.status == "received"] - return templates.TemplateResponse( - "postcards.html", - {"request": request, "user": user, "profile": profile, "sent": sent, "received": received, "countries": all_countries, "country": country}, + return _render(request, "postcards.html", {"user": user, "profile": profile, "sent": sent, "received": received, "countries": all_countries, "country": country}, ) @@ -731,7 +727,7 @@ def postcard_new(request: Request, profile_id: int, create_type: str = Query("se if not profile: return _redirect("/profiles") today = datetime.now().strftime("%Y-%m-%d") - return templates.TemplateResponse("postcard_form.html", {"request": request, "user": user, "profile": profile, "postcard": None, "create_type": create_type, "today": today}) + return _render(request, "postcard_form.html", {"user": user, "profile": profile, "postcard": None, "create_type": create_type, "today": today}) @router.post("/profiles/{profile_id}/postcards") @@ -760,8 +756,7 @@ def postcard_create( Postcard.card_number == cn, Profile.user_id == user.id ).first() if dup: - return templates.TemplateResponse("postcard_form.html", { - "request": request, "user": user, "profile": profile, "postcard": None, + return _render(request, "postcard_form.html", {"user": user, "profile": profile, "postcard": None, "error": _t("pc_list.duplicate", user.language), }) @@ -805,7 +800,7 @@ def postcard_detail(request: Request, postcard_id: int, user: User = Depends(get ) if not pc: return _redirect("/profiles") - return templates.TemplateResponse("postcard_detail.html", {"request": request, "user": user, "postcard": pc}) + return _render(request, "postcard_detail.html", {"user": user, "postcard": pc}) @router.get("/postcards/{postcard_id}/edit", response_class=HTMLResponse) @@ -818,7 +813,7 @@ def postcard_edit(request: Request, postcard_id: int, user: User = Depends(get_c ) if not pc: return _redirect("/profiles") - return templates.TemplateResponse("postcard_form.html", {"request": request, "user": user, "profile": pc.profile, "postcard": pc}) + return _render(request, "postcard_form.html", {"user": user, "profile": pc.profile, "postcard": pc}) @router.post("/postcards/{postcard_id}/edit") @@ -853,8 +848,7 @@ def postcard_update( Profile.user_id == user.id ).first() if dup: - return templates.TemplateResponse("postcard_form.html", { - "request": request, "user": user, "profile": pc.profile, "postcard": pc, + return _render(request, "postcard_form.html", {"user": user, "profile": pc.profile, "postcard": pc, "error": _t("pc_list.duplicate", user.language), }) @@ -1000,8 +994,7 @@ def profile_showcase_page( if not p: return _redirect("/settings?tab=showcase") cards = db.query(Postcard).filter(Postcard.profile_id == profile_id).all() - return templates.TemplateResponse("profile_showcase.html", { - "request": request, "user": user, "profile": p, "cards": cards, + return _render(request, "profile_showcase.html", {"user": user, "profile": p, "cards": cards, }) @@ -1073,8 +1066,7 @@ def public_showcase(request: Request, username: str, db: Session = Depends(get_d profile_groups.append({"profile": p, "sent": p_sent, "received": p_received}) sent_all.sort(key=lambda c: c.send_time or c.created_at, reverse=True) received_all.sort(key=lambda c: c.receive_time or c.created_at, reverse=True) - resp = templates.TemplateResponse("showcase.html", { - "request": request, "profile_user": user, "mode": user.showcase_mode, + resp = _render(request, "showcase.html", {"profile_user": user, "mode": user.showcase_mode, "profile_groups": profile_groups, "sent_all": sent_all, "received_all": received_all, "lang": visitor_lang, "available_languages": _avail, }) @@ -1104,8 +1096,7 @@ def apikey_create( db.commit() db.refresh(ak) api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all() - return templates.TemplateResponse("settings.html", { - "request": request, "user": user, "active_tab": "apikeys", + return _render(request, "settings.html", {"user": user, "active_tab": "apikeys", "api_keys": api_keys, "new_key": ak.key, "success": "API Key created", }) @@ -1153,6 +1144,5 @@ def api_docs_page( user: User = Depends(get_current_user_web), db: Session = Depends(get_db), ): - return templates.TemplateResponse("api_docs.html", { - "request": request, "user": user, + return _render(request, "api_docs.html", {"user": user, })