"""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 os import queue import io import time as _time import secrets from flask import ( Blueprint, request, jsonify, Response, render_template, current_app, stream_with_context, g, 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, current_user_id, verify_login, verify_totp, verify_recovery_code, check_rate_limit, record_failure, record_success, require_reauth, mark_reauth, generate_recovery_codes, load_auth_context, issue_tokens, set_refresh_cookie, clear_refresh_cookie, rotate_refresh, logout_current_device, load_page_context, REFRESH_COOKIE, ) from backend.models import ( get_auth_config, update_auth_config, 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, bump_user_session_version, revoke_user_tokens, revoke_all_tokens, get_db, _read_db_version, ) from backend.db_admin import ( export_database_json, import_blob, detect_format, ) 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 # ════════════════════════════════════════════ # AUTH CONTEXT / CSRF # ════════════════════════════════════════════ @api.before_request def _load_token(): """Resolve the access token (Authorization header) into g.user_id / view / temp.""" load_auth_context() @api.before_request def _adjust_upload_limit(): """Database import accepts large legacy .db files; raise this request's own body limit (per-request setter, no global race) instead of the global 5MB cap.""" if request.path == "/api/admin/db/import" and request.method == "POST": request.max_content_length = 200 * 1024 * 1024 @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("/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") if view not in ("control", "display"): view = "control" # 未认证端点:非法值降级,避免触发 DB CHECK 约束 500 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() tokens = issue_tokens(uid, view=view, temp=True) resp["access_token"] = tokens["access_token"] 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() tokens = issue_tokens(uid, view=view) resp["access_token"] = tokens["access_token"] else: # temp_password (Root initial bootstrap) record_success() tokens = issue_tokens(uid, view=view, temp=True) resp["access_token"] = tokens["access_token"] resp["must_configure_mfa"] = True response = jsonify(resp) set_refresh_cookie(response, tokens["refresh_token"]) return response # ─── 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") if view not in ("control", "display"): view = "control" uid = verify_recovery_code(code) if uid is not None: record_success() tokens = issue_tokens(uid, view=view) response = jsonify({"ok": True, "access_token": tokens["access_token"]}) set_refresh_cookie(response, tokens["refresh_token"]) return response 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") if view not in ("control", "display"): 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() tokens = issue_tokens(uid, view=view) response = jsonify({"ok": True, "access_token": tokens["access_token"]}) set_refresh_cookie(response, tokens["refresh_token"]) return response except ValueError as e: record_failure() return jsonify({"error": str(e)}), 401 except Exception as e: return jsonify({"error": f"Passkey 验证失败: {e}"}), 500 # ─── Refresh / Logout ─────────────────────── @api.route("/api/auth/refresh", methods=["POST"]) def api_refresh(): """Exchange the httpOnly refresh cookie for a fresh access+refresh pair. The refresh token is rotated: the old one is consumed immediately. CSRF: not exempted from the Origin/Referer check (cookie-authenticated). """ raw = request.cookies.get(REFRESH_COOKIE) if not raw: return jsonify({"error": "Unauthorized", "login_required": True}), 401 tokens = rotate_refresh(raw) if not tokens: response = jsonify({"error": "Unauthorized", "login_required": True}) clear_refresh_cookie(response) return response, 401 response = jsonify({"ok": True, "access_token": tokens["access_token"]}) set_refresh_cookie(response, tokens["refresh_token"]) return response @api.route("/api/auth/logout", methods=["POST"]) def api_logout(): """Revoke this device's refresh token and clear its cookie. Other devices of the same account are unaffected. """ logout_current_device() response = jsonify({"ok": True}) clear_refresh_cookie(response) return response # ─── 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, }) # ════════════════════════════════════════════ # MFA CONFIGURATION # ════════════════════════════════════════════ @api.route("/mfa-setup") def mfa_setup_page(): """MFA configuration page. Shown after temp-password / temp-account login. Authenticated via the httpOnly refresh cookie (page navigation). """ if not load_page_context(): return redirect(url_for("api.login_page", next="/mfa-setup", view="control")) 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 — issue a formal token pair. tokens = issue_tokens(uid, view=getattr(g, "auth_view", "control")) response = jsonify({"ok": True, "finalized": True, "access_token": tokens["access_token"]}) set_refresh_cookie(response, tokens["refresh_token"]) return response 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 resp = { "ok": True, "recovery_codes": get_recovery_codes(user_id=uid), "finalized": finalized, } if finalized: tokens = issue_tokens(uid, view=getattr(g, "auth_view", "control")) resp["access_token"] = tokens["access_token"] response = jsonify(resp) set_refresh_cookie(response, tokens["refresh_token"]) return response return jsonify(resp) 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) tokens = issue_tokens(uid, view=getattr(g, "auth_view", "control")) response = jsonify({"ok": True, "access_token": tokens["access_token"]}) set_refresh_cookie(response, tokens["refresh_token"]) return response # ─── 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/", 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 @api.route("/api/admin/kick_all_users", methods=["POST"]) @admin_required def admin_kick_all_users(): """Root: kick EVERY user's control + display devices. All sessions end immediately (including Root's own devices). High-risk → re-auth required. """ if not require_reauth(): return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 bump_all_session_versions() # invalidates every access token revoke_all_tokens() # invalidates every refresh token current_app.event_bus.publish("kick_all", {"reason": "admin_kick_all"}) return jsonify({"ok": True}) # ════════════════════════════════════════════ # DATABASE ADMINISTRATION (Root) # ════════════════════════════════════════════ # Importing an old SQLite database, exporting a backup, or importing a backup # replaces ALL current data — Root-only and re-auth required. def _require_root(): """Database administration is Root-only (id 0).""" if _cur() != 0: return jsonify({"error": "仅 Root 用户可执行数据库管理操作"}), 403 return None @api.route("/api/admin/db/status", methods=["GET"]) @admin_required def admin_db_status(): denied = _require_root() if denied: return denied db_path = current_app.config.get("DB_PATH", "data.db") conn = get_db(db_path) try: version = _read_db_version(conn) or 0 server = conn.execute("SELECT version()").fetchone()[0] rows = conn.execute( "SELECT table_name FROM information_schema.tables " "WHERE table_schema = current_schema() ORDER BY table_name" ).fetchall() tables = [r["table_name"] for r in rows] finally: conn.close() return jsonify({ "database": "postgresql", "db_version": version, "server": server, "tables": tables, "backup_format": "iot-dboard-backup", }) @api.route("/api/admin/db/export", methods=["GET"]) @admin_required def admin_db_export(): denied = _require_root() if denied: return denied if not require_reauth(): return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 db_path = current_app.config.get("DB_PATH", "data.db") try: payload = export_database_json(db_path) except Exception as e: return jsonify({"error": f"导出失败: {e}"}), 500 from datetime import datetime fname = f"iot-dboard-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json" return Response( payload, mimetype="application/json", headers={"Content-Disposition": f'attachment; filename="{fname}"'}, ) @api.route("/api/admin/db/import", methods=["POST"]) @admin_required def admin_db_import(): denied = _require_root() if denied: return denied if not require_reauth(): return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 file = request.files.get("file") if not file or not file.filename: return jsonify({"error": "请选择要导入的数据库文件"}), 400 data = file.read() if not data: return jsonify({"error": "文件为空"}), 400 if len(data) > 200 * 1024 * 1024: return jsonify({"error": "文件超过 200MB 限制"}), 413 db_path = current_app.config.get("DB_PATH", "data.db") fmt = detect_format(data) try: stats = import_blob(db_path, data) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": f"导入失败(已回滚,当前数据未受影响): {e}"}), 500 total = sum(v for k, v in (stats or {}).items() if isinstance(v, int)) skipped = (stats or {}).get("_skipped_tables") or [] msg = f"导入成功:{total} 行数据已恢复。所有设备已下线,请重新登录。" if skipped: msg += f" 以下表因结构与当前版本不兼容被跳过:{', '.join(skipped)}。" return jsonify({ "ok": True, "format": fmt, "stats": stats, "message": msg, "session_invalidated": True, }) # ════════════════════════════════════════════ # DISPLAY PAGES # ════════════════════════════════════════════ @api.route("/") @api.route("/index") def display_page(): # 页面导航无法发送 Authorization header → 用 refresh cookie 鉴权。 # custom 模式同样需要登录(避免匿名访问泄露账户自定义 HTML)。 if not load_page_context(): return redirect(url_for("api.login_page", next=request.path)) cfg = get_display_config(user_id=_cur()) if cfg.get("mode") == "custom": return render_template("display_custom.html", custom_html=cfg.get("custom_html", "")) return render_template("display.html") # ════════════════════════════════════════════ # CONTROL PAGE # ════════════════════════════════════════════ @api.route("/control") def control_page(): """Control panel. Page navigation authenticates via the refresh cookie; temp-pending users are sent to MFA setup.""" if not load_page_context(): return redirect(url_for("api.login_page", next="/control", view="control")) if getattr(g, "temp_pending", False): return redirect(url_for("api.mfa_setup_page")) return render_template("control.html") # ════════════════════════════════════════════ # DISPLAY / SSE / STATE # ════════════════════════════════════════════ # One-time, short-lived tickets used to open the SSE stream (EventSource # cannot send Authorization headers). The ticket is consumed on connect. _SSE_TICKETS: dict = {} # ticket -> {"user_id": int, "view": str, "expires": float} _SSE_TICKET_TTL = 60 def _clean_sse_tickets(): now = _time.time() expired = [k for k, v in _SSE_TICKETS.items() if v["expires"] < now] for k in expired: _SSE_TICKETS.pop(k, None) @api.route("/api/events/ticket", methods=["POST"]) @display_login_required def sse_ticket(): """Issue a one-time ticket for opening the SSE stream.""" _clean_sse_tickets() from backend.models import get_user_session_version ticket = secrets.token_urlsafe(24) _SSE_TICKETS[ticket] = { "user_id": _cur(), "view": getattr(g, "auth_view", "display"), "session_version": get_user_session_version(_cur()), "expires": _time.time() + _SSE_TICKET_TTL, } return jsonify({"ticket": ticket}), 201 @api.route("/api/events") def sse_stream(): """Server-Sent Events for real-time updates (scoped to the current user). Authenticated via a one-time ticket from /api/events/ticket. """ ticket = request.args.get("ticket", "") entry = _SSE_TICKETS.pop(ticket, None) if ticket else None if not entry or entry["expires"] < _time.time(): return jsonify({"error": "Unauthorized", "login_required": True}), 401 # A ticket is void once the user was kicked / deleted (version bumped). from backend.models import get_user_session_version if get_user_session_version(entry["user_id"]) != entry.get("session_version"): return jsonify({"error": "Unauthorized", "login_required": True}), 401 uid = entry["user_id"] 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) from backend.time_util import get_tz_str 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, "timezone": get_tz_str(), }) @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"]) @login_required def kick_own_devices(): """Kick every device logged into the CURRENT account — control AND display devices, including this control device itself. All sessions end immediately. """ uid = _cur() bump_user_session_version(uid) # invalidates every access token of this user revoke_user_tokens(uid) # invalidates every refresh token of this user current_app.event_bus.publish("kick_all", {"reason": "self_kick"}, user_id=uid) 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)