"""REST API routes and SSE endpoint. Multi-user rules enforced here: - Every data route operates on `current_user_id()` (session user). - User management (/api/admin/*) is Root-only and requires re-authentication (TOTP first, Passkey fallback) for register/edit/delete. - Temp accounts log in with a server-held temp TOTP; they are forced through the MFA setup page until they configure (and verify) one real login method, at which point the temp TOTP is invalidated. """ import json import os import queue import io import time as _time import secrets from datetime import datetime from flask import ( Blueprint, request, jsonify, Response, render_template, current_app, stream_with_context, session, redirect, url_for, ) from backend import calendar_mgr, weather_mgr, alarm_mgr, pomodoro_mgr, countdown_mgr from backend.auth import ( login_required, login_or_temp_required, display_login_required, admin_required, is_authenticated, is_temp_pending, is_admin, current_user_id, set_authenticated, set_temp_pending, clear_session, verify_login, verify_totp, verify_recovery_code, check_rate_limit, record_failure, record_success, require_reauth, mark_reauth, generate_recovery_codes, generate_temp_password, ) from backend.models import ( get_auth_config, update_auth_config, set_temp_password, clear_temp_password, set_recovery_codes, get_recovery_codes, has_mfa_configured, get_display_config, set_display_mode, set_custom_html, set_custom_html_template, apply_custom_html, get_users, get_user, create_user, update_user, delete_user, is_temp_account, is_admin_user, finalize_temp_account, find_user_by_credential_id, bump_all_session_versions, ) from backend.webauthn_mgr import ( begin_registration, complete_registration, begin_authentication, complete_authentication, ) api = Blueprint("api", __name__) def _cur() -> int: """Current session user id (0 = Root default).""" return current_user_id() or 0 # ════════════════════════════════════════════ # CSRF / Origin Check # ════════════════════════════════════════════ @api.before_request def _check_origin(): """Protect state-changing requests against CSRF by verifying Origin/Referer. Allows same-origin requests, extension-internal calls, and GET/HEAD/OPTIONS. """ if request.method in ("GET", "HEAD", "OPTIONS"): return None # Login / logout endpoints are exempt (they set/clear the cookie itself) if request.path in ("/api/auth/login", "/api/auth/logout", "/api/auth/login/recovery", "/api/auth/webauthn/login/begin", "/api/auth/webauthn/login/complete", "/login", "/mfa-setup"): return None origin = request.headers.get("Origin", "") referer = request.headers.get("Referer", "") # Build expected origin from the request itself expected = f"{request.scheme}://{request.host}" if origin and origin != expected: return jsonify({"error": "CSRF: Origin mismatch"}), 403 if referer and not referer.startswith(expected): # Referer may include path, so we only check the prefix return jsonify({"error": "CSRF: Referer mismatch"}), 403 return None # ════════════════════════════════════════════ # AUTH # ════════════════════════════════════════════ @api.route("/login") def login_page(): """Render login page. Control gets a full-input page; display gets a numpad.""" next_url = request.args.get("next", "/") view = request.args.get("view", "display") if view == "control" or "control" in next_url: return render_template("login_control.html", next=next_url) return render_template("login.html", next=next_url) @api.route("/register-request") def register_request_page(): """Public page where a prospective user fills in their info (admin approves later).""" return render_template("register_request.html") @api.route("/passkey") def passkey_page(): """Passkey bridge page: loaded inside the ASWebAuthenticationSession sheet (Safari environment supports WebAuthn/passkeys WITHOUT app associated domains). It fetches the stashed {op, options}, runs the standard WebAuthn ceremony, then redirects back via dashboard://passkey.""" return render_template("passkey.html") # ─── 一次性 Passkey 参数暂存(小窗是独立 ephemeral 会话,读不到主容器 storage)─── _PASSKEY_STASH: dict = {} # sid -> {"op": str, "options": dict, "expires": float, "result": dict} _PASSKEY_STASH_TTL = 300 # 秒(用户在小窗 Passkey 面板操作需要时间) def _clean_passkey_stash(): now = _time.time() expired = [k for k, v in _PASSKEY_STASH.items() if v["expires"] < now] for k in expired: _PASSKEY_STASH.pop(k, None) @api.route("/api/passkey/stash", methods=["POST"]) def passkey_stash(): """Store passkey ceremony parameters once, return a short-lived random id. Called by the Passkey bridge middleware from the main WKWebView; the ceremony itself runs inside the ephemeral sheet page which consumes the id.""" data = request.get_json(silent=True) or {} op = data.get("op") options = data.get("options") if op not in ("creation", "assertion") or not isinstance(options, dict): return jsonify({"error": "无效的 Passkey 请求"}), 400 _clean_passkey_stash() sid = secrets.token_urlsafe(16) _PASSKEY_STASH[sid] = {"op": op, "options": options, "expires": _time.time() + _PASSKEY_STASH_TTL} return jsonify({"sid": sid}), 201 @api.route("/api/passkey/stash/", methods=["GET"]) def passkey_stash_get(sid): """Read the stashed parameters. 只读不删——entry 必须存活到小窗存回结果 (result 接口),真正的"一次性"由 result GET 取走即删保证。""" entry = _PASSKEY_STASH.get(sid) if not entry or entry["expires"] < _time.time(): return jsonify({"error": "参数已过期,请重试"}), 410 return jsonify({"op": entry["op"], "options": entry["options"]}) @api.route("/api/passkey/stash//result", methods=["POST"]) def passkey_stash_result(sid): """小窗页面完成 WebAuthn 后把结果存回服务端。 credential 较大,走 POST body(回调 URL 只带短 sid,避免截断)。""" entry = _PASSKEY_STASH.get(sid) if not entry or entry["expires"] < _time.time(): return jsonify({"error": "参数已过期,请重试"}), 410 data = request.get_json(silent=True) or {} credential = data.get("credential") if not isinstance(credential, dict): return jsonify({"error": "缺少 credential"}), 400 entry["result"] = { "credential": credential, "user_name": data.get("user_name", ""), } return jsonify({"ok": True}) @api.route("/api/passkey/stash//result", methods=["GET"]) def passkey_stash_result_get(sid): """App 取回小窗完成的结果(一次性,取走即删)。""" entry = _PASSKEY_STASH.get(sid) if not entry or entry["expires"] < _time.time(): return jsonify({"error": "结果已过期或不存在"}), 410 result = entry.pop("result", None) _PASSKEY_STASH.pop(sid, None) # 结果取走即清,防重放 if not result: return jsonify({"error": "结果不存在"}), 410 return jsonify(result) # ─── Unified Login (TOTP + Temp Password) ──── @api.route("/api/auth/login", methods=["POST"]) def api_login(): """Unified login: accepts 6-digit code. Backend auto-detects whether it's a user TOTP, a temp-account TOTP, or Root's temp password. Rate-limited (progressive ban: 5→5min, 10→15min, 15+→24hr). """ # Check rate limit before anything else rl = check_rate_limit() if not rl["ok"]: return jsonify({"error": rl["message"], "retry_after": rl["retry_after"]}), 429 data = request.get_json() or {} code = data.get("code", "") view = data.get("view", "control") result = verify_login(code) if result is None: record_failure() return jsonify({"error": "验证码错误"}), 401 resp = {"ok": True, "method": result["method"]} uid = result["user_id"] temp_account = result["temp_account"] if result["method"] == "totp": if temp_account: # Temp account: server-held temp TOTP matched → force registration flow. record_success() set_temp_pending(view, uid) resp["must_configure_mfa"] = True else: cfg = get_auth_config(user_id=uid) if not cfg.get("totp_enabled"): return jsonify({"error": "TOTP 尚未启用"}), 403 record_success() set_authenticated(view, uid) else: # temp_password (Root initial bootstrap) record_success() set_temp_pending(view, uid) resp["must_configure_mfa"] = True return jsonify(resp) # ─── Recovery Code Login ───────────────────── @api.route("/api/auth/login/recovery", methods=["POST"]) def api_login_recovery(): """Login with a recovery code (one-time use). Rate-limited (same pool as TOTP).""" rl = check_rate_limit() if not rl["ok"]: return jsonify({"error": rl["message"], "retry_after": rl["retry_after"]}), 429 data = request.get_json() or {} code = data.get("code", "") view = data.get("view", "control") uid = verify_recovery_code(code) if uid is not None: record_success() set_authenticated(view, uid) return jsonify({"ok": True}) record_failure() return jsonify({"error": "恢复码错误或已使用"}), 401 # ─── Re-authentication ─────────────────────── # High-risk operations (user management, MFA changes) require re-confirming the # identity of the CURRENT user. TOTP is primary; a Passkey ceremony is the # fallback when the user has no TOTP configured. @api.route("/api/auth/reauth", methods=["POST"]) def api_reauth(): """Re-authenticate before sensitive operations. Requires TOTP code. Only callable from an authenticated (or temp-pending) session — otherwise this endpoint would be a public TOTP oracle. Rate-limited like login. """ if not is_authenticated() and not is_temp_pending(): return jsonify({"error": "Unauthorized", "login_required": True}), 401 rl = check_rate_limit() if not rl["ok"]: return jsonify({"error": rl["message"], "retry_after": rl["retry_after"]}), 429 data = request.get_json() or {} code = data.get("code", "") cfg = get_auth_config(user_id=_cur()) if cfg.get("totp_enabled"): if verify_totp(code, user_id=_cur()): record_success() mark_reauth() return jsonify({"ok": True}) record_failure() return jsonify({"error": "验证码错误"}), 401 # No TOTP configured: temp-pending sessions pass without a code; # passkey-only users must use the Passkey re-auth ceremony instead. if is_temp_pending(): mark_reauth() return jsonify({"ok": True}) return jsonify({"error": "未配置 TOTP,请使用 Passkey 重验证"}), 400 @api.route("/api/auth/reauth/webauthn/begin", methods=["POST"]) @login_required def api_reauth_webauthn_begin(): """Start a Passkey re-authentication ceremony (fallback when no TOTP).""" cfg = get_auth_config(user_id=_cur()) stored_id = cfg.get("passkey_credential_id", "") if not stored_id: return jsonify({"error": "未注册 Passkey"}), 400 try: options = begin_authentication(request, allow_credential_ids=[stored_id]) return jsonify(options) except Exception as e: return jsonify({"error": str(e)}), 500 @api.route("/api/auth/reauth/webauthn/complete", methods=["POST"]) @login_required def api_reauth_webauthn_complete(): """Complete a Passkey re-authentication ceremony.""" data = request.get_json() or {} credential = data.get("credential", {}) cfg = get_auth_config(user_id=_cur()) stored_id = cfg.get("passkey_credential_id", "") stored_key = cfg.get("passkey_public_key", "") if not stored_id or not stored_key: return jsonify({"error": "未注册 Passkey"}), 400 try: result = complete_authentication( request, credential, stored_credential_id=stored_id, stored_public_key=stored_key, ) if result.get("credential_id") != stored_id: return jsonify({"error": "Passkey 凭证不匹配"}), 401 mark_reauth() return jsonify({"ok": True}) except ValueError as e: return jsonify({"error": str(e)}), 401 except Exception as e: return jsonify({"error": f"Passkey 验证失败: {e}"}), 500 # ─── Passkey (WebAuthn) Login ──────────────── @api.route("/api/auth/webauthn/login/begin", methods=["POST"]) def api_webauthn_login_begin(): """Start WebAuthn authentication. Returns challenge options for the frontend.""" try: options = begin_authentication(request) return jsonify(options) except Exception as e: return jsonify({"error": str(e)}), 500 @api.route("/api/auth/webauthn/login/complete", methods=["POST"]) def api_webauthn_login_complete(): """Complete WebAuthn authentication. Finds the owning user by credential id.""" rl = check_rate_limit() if not rl["ok"]: return jsonify({"error": rl["message"], "retry_after": rl["retry_after"]}), 429 data = request.get_json() or {} view = data.get("view", "control") credential = data.get("credential", {}) credential_b64 = credential.get("id", "") # Locate the user that owns this credential (frontend sends base64url id). uid = None if credential_b64: try: import base64 padded = credential_b64 + "=" * ((4 - len(credential_b64) % 4) % 4) raw = base64.urlsafe_b64decode(padded.encode("ascii")) uid = find_user_by_credential_id(raw.hex()) except Exception: uid = None if uid is None: record_failure() return jsonify({"error": "未注册 Passkey"}), 400 cfg = get_auth_config(user_id=uid) stored_id_hex = cfg.get("passkey_credential_id", "") stored_public_key = cfg.get("passkey_public_key", "") if not stored_id_hex or not stored_public_key: record_failure() return jsonify({"error": "未注册 Passkey"}), 400 try: result = complete_authentication( request, credential, stored_credential_id=credential_b64, stored_public_key=stored_public_key, ) # complete_authentication returns the credential id in hex. if result.get("credential_id") != stored_id_hex: record_failure() return jsonify({"error": "Passkey 凭证不匹配"}), 401 record_success() set_authenticated(view, uid) return jsonify({"ok": True}) except ValueError as e: record_failure() return jsonify({"error": str(e)}), 401 except Exception as e: return jsonify({"error": f"Passkey 验证失败: {e}"}), 500 # ─── Logout ────────────────────────────────── @api.route("/api/auth/logout", methods=["POST"]) def api_logout(): clear_session() return jsonify({"ok": True}) # ─── Auth Status ───────────────────────────── def _user_payload(uid: int) -> dict: u = get_user(uid) or {} return { "id": uid, "remark": u.get("remark", "") or "", "is_admin": is_admin_user(uid), "is_temp": bool(u.get("is_temp", 0)), } @api.route("/api/auth/status", methods=["GET"]) def api_auth_status(): """Return current auth status. Used by frontend to decide login flow.""" uid = current_user_id() if is_authenticated(): return jsonify({ "authenticated": True, "mfa_configured": has_mfa_configured(user_id=uid or 0), "user": _user_payload(uid or 0), }) if is_temp_pending(): return jsonify({ "authenticated": False, "temp_pending": True, "mfa_configured": has_mfa_configured(user_id=uid or 0), "user": _user_payload(uid or 0), }) return jsonify({ "authenticated": False, "temp_pending": False, "mfa_configured": False, }) # ════════════════════════════════════════════ # USER SELF-SERVICE REQUEST # ════════════════════════════════════════════ # The user fills in their info; the admin confirms it and a temp account # (with a server-held temp TOTP) is generated automatically. _REG_REQ_LIMIT: dict[str, list] = {} # ip -> [timestamps] @api.route("/api/auth/register-request", methods=["POST"]) def api_register_request(): """Public: a prospective user submits their remark for admin approval. Rate-limited per IP (5 requests per 10 minutes).""" ip = request.remote_addr or "unknown" now = _time.time() stamps = [t for t in _REG_REQ_LIMIT.get(ip, []) if now - t < 600] if len(stamps) >= 5: _REG_REQ_LIMIT[ip] = stamps return jsonify({"error": "申请过于频繁,请稍后再试"}), 429 stamps.append(now) _REG_REQ_LIMIT[ip] = stamps data = request.get_json() or {} remark = (data.get("remark") or "").strip() if not remark: return jsonify({"error": "请填写你的称呼/备注"}), 400 if len(remark) > 64: return jsonify({"error": "备注过长"}), 400 create_user(remark=remark, role="user", is_temp=0, status="pending") return jsonify({"ok": True, "message": "已提交申请,请等待管理员确认"}), 201 # ════════════════════════════════════════════ # MFA CONFIGURATION # ════════════════════════════════════════════ @api.route("/mfa-setup") def mfa_setup_page(): """MFA configuration page. Shown after temp-password / temp-account login.""" if not is_temp_pending() and not is_authenticated(): return redirect(url_for("api.login_page", next="/mfa-setup")) return render_template("mfa_setup.html") # ─── WebAuthn Registration ─────────────────── @api.route("/api/auth/webauthn/register/begin", methods=["POST"]) @login_or_temp_required def api_webauthn_register_begin(): """Start WebAuthn registration for the current user.""" if not require_reauth(): return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 uid = _cur() cfg = get_auth_config(user_id=uid) existing_id = cfg.get("passkey_credential_id", "") user = get_user(uid) or {} try: options = begin_registration(request, uid, display_name=user.get("remark", ""), existing_credential_id=existing_id) return jsonify(options) except Exception as e: return jsonify({"error": str(e)}), 500 @api.route("/api/auth/webauthn/register/complete", methods=["POST"]) @login_or_temp_required def api_webauthn_register_complete(): """Complete WebAuthn registration. Saves credential for the current user. If the account is a temp account, finalizing it invalidates the temp TOTP. """ data = request.get_json() or {} credential = data.get("credential", {}) uid = _cur() try: result = complete_registration(request, credential) if result.get("user_id") != uid: return jsonify({"error": "注册上下文不匹配,请重试"}), 400 update_auth_config( uid, passkey_credential_id=result["credential_id"], passkey_public_key=result["public_key"], ) finalized = False if is_temp_account(uid): finalize_temp_account(uid) finalized = True # The user proved access to the account — mark the session authenticated. set_authenticated(session.get("mfa_view", "control"), uid) return jsonify({"ok": True, "finalized": finalized}) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": f"Passkey 注册失败: {e}"}), 500 # ─── TOTP Setup ────────────────────────────── @api.route("/api/auth/totp/setup", methods=["POST"]) @login_or_temp_required def api_totp_setup_begin(): """Generate a TOTP secret for the current user.""" if not require_reauth(): return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 import pyotp import qrcode import base64 uid = _cur() user = get_user(uid) or {} # Generate a new secret secret = pyotp.random_base32() # Store temporarily (will be enabled after verification) update_auth_config(uid, totp_secret=secret, totp_enabled=0) # Build provisioning URI issuer = "Cal Display" name = (user.get("remark") or f"user_{uid}")[:64] uri = pyotp.totp.TOTP(secret).provisioning_uri(name=name, issuer_name=issuer) # Generate QR code as base64 PNG qr = qrcode.make(uri) buf = io.BytesIO() qr.save(buf, format="PNG") qr_b64 = base64.b64encode(buf.getvalue()).decode("utf-8") return jsonify({ "secret": secret, "uri": uri, "qr_base64": qr_b64, }) @api.route("/api/auth/totp/verify", methods=["POST"]) @login_or_temp_required def api_totp_verify(): """Verify a TOTP code to enable TOTP MFA for the current user. For temp accounts this also converts the account (temp TOTP is discarded). """ data = request.get_json() or {} code = data.get("code", "") uid = _cur() if verify_totp(code, user_id=uid): update_auth_config(uid, totp_enabled=1) # Generate recovery codes on first TOTP enable cfg = get_auth_config(user_id=uid) if not cfg.get("recovery_codes"): codes = generate_recovery_codes() set_recovery_codes(codes, user_id=uid) finalized = False if is_temp_account(uid): finalize_temp_account(uid) finalized = True set_authenticated(session.get("mfa_view", "control"), uid) return jsonify({ "ok": True, "recovery_codes": get_recovery_codes(user_id=uid), "finalized": finalized, }) return jsonify({"error": "验证码错误,请重试"}), 400 # ─── MFA Complete / Skip Temp Password ───── @api.route("/api/auth/mfa/complete", methods=["POST"]) def api_mfa_complete(): """Called after user has configured at least one MFA method. Works for temp-password (Root bootstrap) and temp-account sessions. """ if not is_temp_pending() and not is_authenticated(): return jsonify({"error": "请先登录"}), 401 uid = _cur() cfg = get_auth_config(user_id=uid) if not cfg.get("passkey_credential_id") and not cfg.get("totp_enabled"): return jsonify({"error": "请至少配置一种 MFA 方式"}), 400 if is_temp_account(uid): finalize_temp_account(uid) clear_temp_password(user_id=uid) update_auth_config(uid, must_configure_mfa=0) set_authenticated(session.get("mfa_view", "control"), uid) return jsonify({"ok": True}) # ─── Recovery Codes ────────────────────────── @api.route("/api/auth/recovery-codes", methods=["GET"]) @login_or_temp_required def api_get_recovery_codes(): """Get remaining recovery codes for the current user.""" uid = _cur() cfg = get_auth_config(user_id=uid) if not cfg.get("recovery_codes"): return jsonify({"codes": [], "remaining": 0}) codes = get_recovery_codes(user_id=uid) return jsonify({"codes": codes, "remaining": len(codes)}) @api.route("/api/auth/recovery-codes/regenerate", methods=["POST"]) @login_or_temp_required def api_regenerate_recovery_codes(): """Regenerate recovery codes. Requires recent re-authentication.""" if not require_reauth(): return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 codes = generate_recovery_codes() set_recovery_codes(codes, user_id=_cur()) return jsonify({"codes": codes, "remaining": len(codes)}) # ─── MFA Status (for control panel settings) ─ @api.route("/api/auth/mfa/status", methods=["GET"]) @login_required def api_mfa_status(): """Return what MFA methods are configured for the current user.""" uid = _cur() cfg = get_auth_config(user_id=uid) codes = get_recovery_codes(user_id=uid) return jsonify({ "passkey_enabled": bool(cfg.get("passkey_credential_id")), "totp_enabled": bool(cfg.get("totp_enabled")), "recovery_codes_remaining": len(codes), "is_temp_account": is_temp_account(uid), }) # ════════════════════════════════════════════ # THEME(账户独立主题) # ════════════════════════════════════════════ def _effective_theme(uid: int, mode: str) -> str: """解析主题模式为实际生效值:dark / light / auto。 auto 模式按用户天气配置的经纬度 + 本地时区计算日出日落, 白天→浅色,夜晚→深色;无位置数据时按本地 6:00-18:00 粗判。""" if mode == "dark": return "dark" if mode == "light": return "light" try: from backend.time_util import now as tz_now from backend.sun_util import is_daytime cfg = weather_mgr.get_config(user_id=uid) lat = cfg.get("lat") lon = cfg.get("lon") now_local = tz_now() if lat is not None and lon is not None: return "light" if is_daytime(lat, lon, now_local) else "dark" return "light" if 6 <= now_local.hour < 18 else "dark" except Exception: return "dark" @api.route("/api/theme", methods=["GET"]) @login_required def get_theme(): """返回当前用户的主题设置:mode 为用户选择(dark/light/auto), effective 为实际生效(auto 已解析为 dark/light)。""" uid = _cur() mode = (get_user(uid) or {}).get("theme") or "dark" return jsonify({ "mode": mode, "effective": _effective_theme(uid, mode), }) @api.route("/api/theme", methods=["PUT"]) @login_required def set_theme(): """保存主题设置并广播给该用户的显示端。""" data = request.get_json() or {} mode = data.get("mode") if mode not in ("dark", "light", "auto"): return jsonify({"error": "无效的主题模式"}), 400 uid = _cur() update_user(uid, theme=mode) effective = _effective_theme(uid, mode) current_app.event_bus.publish("theme_changed", {"mode": mode, "effective": effective}, user_id=uid) return jsonify({"ok": True, "mode": mode, "effective": effective}) # ════════════════════════════════════════════ # USER MANAGEMENT (ADMIN) # ════════════════════════════════════════════ @api.route("/api/admin/users", methods=["GET"]) @admin_required def admin_list_users(): """List all users (including pending requests). No secrets are exposed.""" users = [] for u in get_users(): users.append({ "id": u["id"], "remark": u["remark"], "role": u["role"], "is_temp": bool(u["is_temp"]), "status": u["status"], "created_at": u["created_at"], "has_mfa": has_mfa_configured(user_id=u["id"]), }) return jsonify(users) @api.route("/api/admin/users", methods=["POST"]) @admin_required def admin_create_user(): """Register a user. Only admins can do this — re-auth required. mode='admin_fills' → formal account immediately (admin then provisions TOTP or Passkey on the user's behalf). mode='user_fills' → temp account with a server-held temp TOTP. """ if not require_reauth(): return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 data = request.get_json() or {} remark = (data.get("remark") or "").strip()[:64] mode = data.get("mode", "admin_fills") if mode not in ("admin_fills", "user_fills"): return jsonify({"error": "无效的注册模式"}), 400 if mode == "user_fills": import pyotp uid = create_user(remark=remark, role="user", is_temp=1, temp_totp_secret=pyotp.random_base32(), status="active") return jsonify({"ok": True, "user": _user_payload(uid), "is_temp": True}), 201 uid = create_user(remark=remark, role="user", is_temp=0, status="active") return jsonify({"ok": True, "user": _user_payload(uid)}), 201 @api.route("/api/admin/users/confirm", methods=["POST"]) @admin_required def admin_confirm_request(): """Confirm a pending self-service request → temp account with temp TOTP.""" if not require_reauth(): return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 data = request.get_json() or {} request_id = data.get("request_id") if request_id is None: return jsonify({"error": "request_id required"}), 400 u = get_user(request_id) if not u: return jsonify({"error": "申请不存在"}), 404 if u.get("status") != "pending": return jsonify({"error": "该申请已处理"}), 400 import pyotp secret = pyotp.random_base32() update_user(request_id, is_temp=1, temp_totp_secret=secret, status="active") return jsonify({"ok": True, "user": _user_payload(request_id)}) @api.route("/api/admin/users/", methods=["PUT"]) @admin_required def admin_update_user(user_id): """Edit a user's remark. Re-auth required.""" if not require_reauth(): return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 u = get_user(user_id) if not u: return jsonify({"error": "用户不存在"}), 404 data = request.get_json() or {} remark = (data.get("remark") or "").strip()[:64] if not remark: return jsonify({"error": "备注不能为空"}), 400 update_user(user_id, remark=remark) return jsonify({"ok": True, "user": _user_payload(user_id)}) @api.route("/api/admin/users/", methods=["DELETE"]) @admin_required def admin_delete_user(user_id): """Delete a user and ALL of their data. Re-auth required.""" if not require_reauth(): return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 if user_id == 0: return jsonify({"error": "无法删除 Root 用户"}), 400 if user_id == _cur(): return jsonify({"error": "不能删除当前登录的管理员"}), 400 u = get_user(user_id) if not u: return jsonify({"error": "用户不存在"}), 404 delete_user(user_id) return jsonify({"ok": True}) @api.route("/api/admin/users//totp", methods=["GET"]) @admin_required def admin_user_temp_totp(user_id): """Real-time temp TOTP code for a temp account (admin helper). Only the live code is returned — the secret itself never leaves the server. High-risk view: requires re-authentication. """ if not require_reauth(): return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 u = get_user(user_id) if not u: return jsonify({"error": "用户不存在"}), 404 if not u.get("is_temp"): return jsonify({"error": "该账户不是临时账户"}), 400 from backend.auth import current_temp_totp_code return jsonify({ "code": current_temp_totp_code(user_id), "remark": u.get("remark", ""), }) @api.route("/api/admin/users//totp/begin", methods=["POST"]) @admin_required def admin_provision_totp_begin(user_id): """Admin-fills mode: provision a TOTP for a formal user (secret + QR shown to the admin, who relays it to the user).""" if not require_reauth(): return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 u = get_user(user_id) if not u: return jsonify({"error": "用户不存在"}), 404 if u.get("is_temp"): return jsonify({"error": "临时账户由用户本人配置登录方式"}), 400 import pyotp import qrcode import base64 secret = pyotp.random_base32() update_auth_config(user_id, totp_secret=secret, totp_enabled=0) name = (u.get("remark") or f"user_{user_id}")[:64] uri = pyotp.totp.TOTP(secret).provisioning_uri(name=name, issuer_name="Cal Display") qr = qrcode.make(uri) buf = io.BytesIO() qr.save(buf, format="PNG") qr_b64 = base64.b64encode(buf.getvalue()).decode("utf-8") return jsonify({"secret": secret, "uri": uri, "qr_base64": qr_b64}) @api.route("/api/admin/users//totp/verify", methods=["POST"]) @admin_required def admin_provision_totp_verify(user_id): """Verify the user's TOTP code (entered by the user into their app) and enable it.""" if not require_reauth(): return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 data = request.get_json() or {} code = data.get("code", "") u = get_user(user_id) if not u: return jsonify({"error": "用户不存在"}), 404 if u.get("is_temp"): return jsonify({"error": "临时账户由用户本人配置登录方式"}), 400 if not verify_totp(code, user_id=user_id): return jsonify({"error": "验证码错误,请让用户重试"}), 400 update_auth_config(user_id, totp_enabled=1) cfg = get_auth_config(user_id=user_id) if not cfg.get("recovery_codes"): codes = generate_recovery_codes() set_recovery_codes(codes, user_id=user_id) return jsonify({"ok": True, "recovery_codes": get_recovery_codes(user_id=user_id)}) @api.route("/api/admin/users//passkey/begin", methods=["POST"]) @admin_required def admin_provision_passkey_begin(user_id): """Admin-fills mode: start a Passkey registration ceremony for the target user. The ceremony completes in the admin's browser (literal interpretation of '由管理员电脑代为注册').""" if not require_reauth(): return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 u = get_user(user_id) if not u: return jsonify({"error": "用户不存在"}), 404 if u.get("is_temp"): return jsonify({"error": "临时账户由用户本人配置登录方式"}), 400 cfg = get_auth_config(user_id=user_id) existing_id = cfg.get("passkey_credential_id", "") try: options = begin_registration(request, user_id, display_name=u.get("remark", ""), existing_credential_id=existing_id) return jsonify(options) except Exception as e: return jsonify({"error": str(e)}), 500 @api.route("/api/admin/users//passkey/complete", methods=["POST"]) @admin_required def admin_provision_passkey_complete(user_id): """Complete the passkey ceremony and store the credential for the target user.""" if not require_reauth(): return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 data = request.get_json() or {} credential = data.get("credential", {}) u = get_user(user_id) if not u: return jsonify({"error": "用户不存在"}), 404 try: result = complete_registration(request, credential) if result.get("user_id") != user_id: return jsonify({"error": "注册上下文不匹配,请重试"}), 400 update_auth_config( user_id, passkey_credential_id=result["credential_id"], passkey_public_key=result["public_key"], ) return jsonify({"ok": True}) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": f"Passkey 注册失败: {e}"}), 500 # ════════════════════════════════════════════ # DISPLAY PAGES # ════════════════════════════════════════════ @api.route("/") @api.route("/index") def display_page(): cfg = get_display_config(user_id=_cur()) if cfg.get("mode") == "custom": return render_template("display_custom.html", custom_html=cfg.get("custom_html", "")) if not is_authenticated() and not is_temp_pending(): return redirect(url_for("api.login_page", next=request.path)) return render_template("display.html") # ════════════════════════════════════════════ # CONTROL PAGE # ════════════════════════════════════════════ @api.route("/control") @login_required def control_page(): return render_template("control.html") # ════════════════════════════════════════════ # DISPLAY / SSE / STATE # ════════════════════════════════════════════ @api.route("/api/events") def sse_stream(): """Server-Sent Events for real-time updates (scoped to the current user).""" if not is_authenticated() and not is_temp_pending(): return jsonify({"error": "Unauthorized", "login_required": True}), 401 uid = _cur() def generate(): q = current_app.event_bus.subscribe(user_id=uid) try: yield "data: {\"type\":\"connected\"}\n\n" while True: try: msg = q.get(timeout=15) yield f"data: {msg}\n\n" except queue.Empty: yield "data: {\"type\":\"heartbeat\"}\n\n" finally: current_app.event_bus.unsubscribe(q) return Response( stream_with_context(generate()), mimetype="text/event-stream", headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", }, ) # ─── Display State (needed by display page) ─ @api.route("/api/display/state", methods=["GET"]) @display_login_required def get_display_state(): """Return all data needed by the display page (read-only for display, full for control).""" uid = _cur() events = calendar_mgr.get_events(days=7, user_id=uid) weather = weather_mgr.get_cached_weather(user_id=uid) alarms = alarm_mgr.get_alarms(user_id=uid) countdowns = countdown_mgr.get_countdowns(user_id=uid) groups = calendar_mgr.get_groups(user_id=uid) pomodoro = pomodoro_mgr.get_state(user_id=uid) hourly = weather_mgr.get_cached_hourly(user_id=uid) if not hourly: hourly = weather_mgr.fetch_hourly(user_id=uid) update_schedule = weather_mgr.get_next_update_times(user_id=uid) minutely = weather_mgr.get_cached_minutely(user_id=uid) warnings = weather_mgr.get_cached_warnings(user_id=uid) return jsonify({ "events": events, "weather": weather, "hourly": hourly, "alarms": alarms, "countdowns": countdowns, "groups": [g for g in groups if g["enabled"]], "pomodoro": pomodoro, "update_schedule": update_schedule, "minutely": minutely, "warnings": warnings, }) @api.route("/api/display/mode", methods=["GET"]) @login_required def get_display_mode(): cfg = get_display_config(user_id=_cur()) return jsonify(cfg) @api.route("/api/display/mode", methods=["PUT"]) @login_required def update_display_mode(): data = request.get_json() or {} mode = data.get("mode", "") uid = _cur() if set_display_mode(mode, user_id=uid): current_app.event_bus.publish("display_mode_changed", {"mode": mode}, user_id=uid) return jsonify({"ok": True}) return jsonify({"error": "Invalid mode"}), 400 @api.route("/api/display/custom_html", methods=["PUT"]) @login_required def update_custom_html(): data = request.get_json() or {} html = data.get("html", "") uid = _cur() set_custom_html(html, user_id=uid) current_app.event_bus.publish("display_mode_changed", {"mode": "custom"}, user_id=uid) return jsonify({"ok": True}) @api.route("/api/display/custom_html/", methods=["PUT"]) @login_required def update_custom_html_template(index): data = request.get_json() or {} html = data.get("html", "") uid = _cur() if index == 0: set_custom_html(html, user_id=uid) return jsonify({"ok": True}) if set_custom_html_template(index, html, user_id=uid): return jsonify({"ok": True}) return jsonify({"error": "Invalid template index"}), 400 @api.route("/api/display/apply_html/", methods=["POST"]) @login_required def apply_html_template(index): uid = _cur() if apply_custom_html(index, user_id=uid): current_app.event_bus.publish("display_mode_changed", {"mode": "custom"}, user_id=uid) return jsonify({"ok": True}) return jsonify({"error": "Invalid template index"}), 400 @api.route("/api/display/refresh", methods=["POST"]) @login_required def force_refresh_display(): uid = _cur() current_app.event_bus.publish("force_refresh", {}, user_id=uid) return jsonify({"ok": True}) @api.route("/api/display/kick_all", methods=["POST"]) @admin_required def kick_all_displays(): """Kick ALL sessions (including all users) by bumping every user's version. Old session cookies become invalid immediately. Also broadcasts SSE. """ bump_all_session_versions() current_app.event_bus.publish("kick_all", {}) return jsonify({"ok": True}) @api.route("/api/display/notify", methods=["POST"]) @login_required def push_notification(): data = request.get_json() or {} uid = _cur() current_app.event_bus.publish("custom_notification", { "title": data.get("title", "提醒"), "message": data.get("message", ""), "icon": data.get("icon", "🔔"), "sound": data.get("sound", True), }, user_id=uid) return jsonify({"ok": True}) # ════════════════════════════════════════════ # CALENDAR # ════════════════════════════════════════════ @api.route("/api/calendar/groups", methods=["GET"]) @login_required def get_calendar_groups(): return jsonify(calendar_mgr.get_groups(user_id=_cur())) @api.route("/api/calendar/groups", methods=["POST"]) @login_required def create_calendar_group(): data = request.get_json() or {} group = calendar_mgr.create_group( name=data.get("name", "New Group"), color=data.get("color", "#4A90D9"), user_id=_cur(), ) return jsonify(group), 201 @api.route("/api/calendar/groups/", methods=["PUT"]) @login_required def update_calendar_group(group_id): data = request.get_json() or {} ok = calendar_mgr.update_group( group_id, name=data.get("name"), color=data.get("color"), enabled=data.get("enabled"), user_id=_cur(), ) if not ok: return jsonify({"error": "分组不存在或无权访问"}), 404 return jsonify({"ok": True}) @api.route("/api/calendar/groups/", methods=["DELETE"]) @login_required def delete_calendar_group(group_id): ok = calendar_mgr.delete_group(group_id, user_id=_cur()) if not ok: return jsonify({"error": "分组不存在或无权访问"}), 404 return jsonify({"ok": True}) @api.route("/api/calendar/groups//reminder", methods=["PUT"]) @login_required def set_calendar_reminder(group_id): data = request.get_json() or {} try: calendar_mgr.set_reminder( group_id, minutes_before=data.get("minutes_before", 15), enabled=data.get("enabled", True), user_id=_cur(), ) except ValueError as e: return jsonify({"error": str(e)}), 400 return jsonify({"ok": True}) @api.route("/api/calendar/sources/upload", methods=["POST"]) @login_required def upload_source(): group_id = request.form.get("group_id", type=int) name = request.form.get("name", "uploaded") file = request.files.get("file") if not group_id or not file: return jsonify({"error": "group_id and file required"}), 400 ics_content = file.read().decode("utf-8", errors="replace") try: calendar_mgr.upload_ics(group_id, name, ics_content, user_id=_cur()) except ValueError as e: return jsonify({"error": str(e)}), 400 return jsonify({"ok": True}), 201 @api.route("/api/calendar/sources/subscribe", methods=["POST"]) @login_required def subscribe_source(): data = request.get_json() or {} group_id = data.get("group_id") name = data.get("name", "Subscription") url = data.get("url") if not group_id or not url: return jsonify({"error": "group_id and url required"}), 400 try: calendar_mgr.subscribe_ics(group_id, name, url, user_id=_cur()) except ValueError as e: return jsonify({"error": str(e)}), 400 return jsonify({"ok": True}), 201 @api.route("/api/calendar/sources/", methods=["DELETE"]) @login_required def delete_source(source_id): ok = calendar_mgr.delete_source(source_id, user_id=_cur()) if not ok: return jsonify({"error": "来源不存在或无权访问"}), 404 return jsonify({"ok": True}) @api.route("/api/calendar/sources//sync", methods=["POST"]) @login_required def sync_source(source_id): calendar_mgr.sync_subscription(source_id, user_id=_cur()) return jsonify({"ok": True}) @api.route("/api/calendar/events", methods=["GET"]) @login_required def get_calendar_events(): group_ids = request.args.getlist("group_ids", type=int) or None days = request.args.get("days", 7, type=int) events = calendar_mgr.get_events(group_ids=group_ids, days=days, user_id=_cur()) return jsonify(events) @api.route("/api/calendar/events", methods=["POST"]) @login_required def create_event(): data = request.get_json() or {} group_id = data.get("group_id") if not group_id: return jsonify({"error": "group_id required"}), 400 try: event = calendar_mgr.add_event( group_id=group_id, summary=data.get("summary", ""), dtstart=data.get("dtstart", ""), dtend=data.get("dtend"), description=data.get("description", ""), location=data.get("location", ""), is_all_day=data.get("is_all_day", False), user_id=_cur(), ) except ValueError as e: return jsonify({"error": str(e)}), 400 return jsonify(event), 201 @api.route("/api/calendar/groups//events", methods=["GET"]) @login_required def get_group_events(group_id): days = request.args.get("days", 30, type=int) events = calendar_mgr.get_events_by_group(group_id, days=days, user_id=_cur()) return jsonify(events) @api.route("/api/calendar/events/", methods=["PUT"]) @login_required def update_calendar_event(event_id): data = request.get_json() or {} ok = calendar_mgr.update_event( event_id, summary=data.get("summary"), dtstart=data.get("dtstart"), dtend=data.get("dtend"), description=data.get("description"), location=data.get("location"), user_id=_cur(), ) if ok: return jsonify({"ok": True}) return jsonify({"error": "日程不存在或无权访问"}), 404 @api.route("/api/calendar/events/", methods=["DELETE"]) @login_required def delete_calendar_event(event_id): ok = calendar_mgr.delete_event(event_id, user_id=_cur()) if not ok: return jsonify({"error": "日程不存在或无权访问"}), 404 return jsonify({"ok": True}) # ════════════════════════════════════════════ # WEATHER # ════════════════════════════════════════════ @api.route("/api/weather/config", methods=["GET"]) @login_required def get_weather_config(): cfg = weather_mgr.get_config(user_id=_cur()) cfg["api_key"] = "***" if cfg.get("api_key") else "" cfg["jwt_private_key"] = "***" if cfg.get("jwt_private_key") else "" return jsonify(cfg) @api.route("/api/weather/config", methods=["PUT"]) @login_required def update_weather_config(): data = request.get_json() or {} try: weather_mgr.update_config( api_key=data.get("api_key"), city=data.get("city"), lat=data.get("lat"), lon=data.get("lon"), units=data.get("units"), update_interval=data.get("update_interval"), credential_id=data.get("credential_id"), project_id=data.get("project_id"), qweather_host=data.get("qweather_host"), user_id=_cur(), ) except ValueError as e: return jsonify({"error": str(e)}), 400 return jsonify({"ok": True}) @api.route("/api/weather/current", methods=["GET"]) @display_login_required def get_weather(): data = weather_mgr.get_cached_weather(user_id=_cur()) if "error" in data and "API key" not in data.get("error", ""): data = weather_mgr.fetch_weather(user_id=_cur()) return jsonify(data) @api.route("/api/weather/refresh", methods=["POST"]) @login_required def refresh_weather(): data = weather_mgr.fetch_weather(force=True, user_id=_cur()) return jsonify(data) @api.route("/api/weather/hourly", methods=["GET"]) @display_login_required def get_hourly(): data = weather_mgr.get_cached_hourly(user_id=_cur()) if not data: data = weather_mgr.fetch_hourly(user_id=_cur()) return jsonify(data or []) @api.route("/api/weather/hourly", methods=["POST"]) @login_required def refresh_hourly(): data = weather_mgr.fetch_hourly(user_id=_cur()) return jsonify(data or []) @api.route("/api/weather/minutely", methods=["GET"]) @display_login_required def get_minutely(): data = weather_mgr.get_cached_minutely(user_id=_cur()) return jsonify(data or {}) @api.route("/api/weather/minutely", methods=["POST"]) @login_required def refresh_minutely(): data = weather_mgr.fetch_minutely(user_id=_cur()) return jsonify(data) @api.route("/api/weather/warnings", methods=["GET"]) @display_login_required def get_warnings(): data = weather_mgr.get_cached_warnings(user_id=_cur()) return jsonify(data or {}) @api.route("/api/weather/warnings", methods=["POST"]) @login_required def refresh_warnings(): data = weather_mgr.fetch_warnings(user_id=_cur()) return jsonify(data) @api.route("/api/weather/city_lookup", methods=["GET"]) @login_required def weather_city_lookup(): query = request.args.get("q", "") if not query: return jsonify([]) results = weather_mgr.city_lookup(query) return jsonify(results) @api.route("/api/weather/generate_keys", methods=["POST"]) @login_required def weather_generate_keys(): result = weather_mgr.generate_weather_keys() return jsonify(result) # ════════════════════════════════════════════ # TIME CONFIG # ════════════════════════════════════════════ @api.route("/api/time/config", methods=["GET"]) @login_required def get_time_config(): """Return current timezone configuration.""" from backend.time_util import get_tz_str return jsonify({ "timezone": get_tz_str(), }) @api.route("/api/time/config", methods=["PUT"]) @login_required def set_time_config(): """Update timezone configuration.""" from backend.time_util import set_tz data = request.get_json() or {} tz_str = data.get("timezone", "").strip() if not tz_str: return jsonify({"error": "timezone is required"}), 400 if set_tz(tz_str): return jsonify({"ok": True}) return jsonify({"error": f"时区 '{tz_str}' 无效,请使用 IANA 名称(如 Asia/Shanghai)或 UTC+X 格式"}), 400 # ════════════════════════════════════════════ # ALARMS # ════════════════════════════════════════════ @api.route("/api/alarms", methods=["GET"]) @login_required def get_alarms(): return jsonify(alarm_mgr.get_alarms(user_id=_cur())) @api.route("/api/alarms", methods=["POST"]) @login_required def add_alarm(): data = request.get_json() or {} alarm = alarm_mgr.add_alarm( label=data.get("label", ""), time=data.get("time", "08:00"), days=data.get("days", "1,2,3,4,5,6,7"), enabled=data.get("enabled", True), once=data.get("once", False), user_id=_cur(), ) return jsonify(alarm), 201 @api.route("/api/alarms/", methods=["PUT"]) @login_required def update_alarm(alarm_id): data = request.get_json() or {} ok = alarm_mgr.update_alarm( alarm_id, label=data.get("label"), time=data.get("time"), days=data.get("days"), enabled=data.get("enabled"), once=data.get("once"), user_id=_cur(), ) if not ok: return jsonify({"error": "闹钟不存在或无权访问"}), 404 return jsonify({"ok": True}) @api.route("/api/alarms/", methods=["DELETE"]) @login_required def delete_alarm(alarm_id): ok = alarm_mgr.delete_alarm(alarm_id, user_id=_cur()) if not ok: return jsonify({"error": "闹钟不存在或无权访问"}), 404 return jsonify({"ok": True}) @api.route("/api/alarms/ringtone", methods=["POST"]) @login_required def upload_ringtone(): """Upload a custom ringtone. Only audio formats allowed.""" ALLOWED_EXT = {".mp3", ".wav", ".ogg", ".flac", ".m4a"} ALLOWED_MIME = {"audio/mpeg", "audio/wav", "audio/ogg", "audio/flac", "audio/mp4", "audio/x-m4a"} file = request.files.get("file") if not file: return jsonify({"error": "file required"}), 400 ext = os.path.splitext(file.filename or "")[1].lower() if ext not in ALLOWED_EXT: return jsonify({"error": f"文件类型 {ext} 不允许,仅支持 {', '.join(sorted(ALLOWED_EXT))}"}), 400 mime = file.content_type or "" if mime and mime not in ALLOWED_MIME: return jsonify({"error": f"MIME 类型 {mime} 不允许"}), 400 ringtone_dir = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend", "static", "ringtones" ) os.makedirs(ringtone_dir, exist_ok=True) safe_name = "".join(c for c in file.filename if c.isalnum() or c in "._-") if not safe_name: safe_name = "ringtone" + ext # Avoid overwriting another user's file with the same name (ringtones are shared). filepath = os.path.join(ringtone_dir, safe_name) if os.path.exists(filepath): import uuid safe_name = f"{os.path.splitext(safe_name)[0]}-{uuid.uuid4().hex[:6]}{ext}" filepath = os.path.join(ringtone_dir, safe_name) file.save(filepath) return jsonify({"ok": True, "filename": safe_name}), 201 # ════════════════════════════════════════════ # COUNTDOWNS # ════════════════════════════════════════════ @api.route("/api/countdowns", methods=["GET"]) @login_required def get_countdowns(): return jsonify(countdown_mgr.get_countdowns(user_id=_cur())) @api.route("/api/countdowns", methods=["POST"]) @login_required def add_countdown(): data = request.get_json() or {} cd = countdown_mgr.add_countdown( label=data.get("label", ""), duration_seconds=data.get("duration_seconds", 300), enabled=data.get("enabled", True), user_id=_cur(), ) return jsonify(cd), 201 @api.route("/api/countdowns/", methods=["PUT"]) @login_required def update_countdown(cd_id): data = request.get_json() or {} ok = countdown_mgr.update_countdown( cd_id, label=data.get("label"), duration_seconds=data.get("duration_seconds"), enabled=data.get("enabled"), user_id=_cur(), ) if not ok: return jsonify({"error": "倒计时不存在或无权访问"}), 404 return jsonify({"ok": True}) @api.route("/api/countdowns/", methods=["DELETE"]) @login_required def delete_countdown(cd_id): ok = countdown_mgr.delete_countdown(cd_id, user_id=_cur()) if not ok: return jsonify({"error": "倒计时不存在或无权访问"}), 404 return jsonify({"ok": True}) # ════════════════════════════════════════════ # POMODORO # ════════════════════════════════════════════ @api.route("/api/pomodoro", methods=["GET"]) @login_required def get_pomodoro(): return jsonify(pomodoro_mgr.get_state(user_id=_cur())) @api.route("/api/pomodoro", methods=["POST"]) @login_required def create_pomodoro(): data = request.get_json() or {} state = pomodoro_mgr.create_or_update( work_minutes=data.get("work_minutes"), break_minutes=data.get("break_minutes"), long_break_minutes=data.get("long_break_minutes"), cycles_before_long_break=data.get("cycles_before_long_break"), enabled=data.get("enabled"), display_controls_enabled=data.get("display_controls_enabled"), work_label=data.get("work_label"), break_label=data.get("break_label"), user_id=_cur(), ) return jsonify(state), 201 @api.route("/api/pomodoro/start", methods=["POST"]) @login_required def start_pomodoro(): return jsonify(pomodoro_mgr.start(user_id=_cur())) @api.route("/api/pomodoro/pause", methods=["POST"]) @login_required def pause_pomodoro(): return jsonify(pomodoro_mgr.pause(user_id=_cur())) @api.route("/api/pomodoro/stop", methods=["POST"]) @login_required def stop_pomodoro(): return jsonify(pomodoro_mgr.stop(user_id=_cur())) @api.route("/api/pomodoro/skip", methods=["POST"]) @login_required def skip_pomodoro(): return jsonify(pomodoro_mgr.skip(user_id=_cur())) @api.route("/api/pomodoro/dismiss", methods=["POST"]) @login_required def dismiss_pomodoro(): return jsonify(pomodoro_mgr.dismiss(user_id=_cur())) # ─── Register Blueprint ────────────────────── def register_routes(app): app.register_blueprint(api)