Files
iot-dboard/backend/routes.py
T
2026-08-13 12:50:18 +08:00

1873 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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/<sid>", 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/<sid>/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/<sid>/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],
challenge_key=f"reauth_{_cur()}",
)
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,
challenge_key=f"reauth_{_cur()}",
)
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, user_id=uid)
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
@api.route("/api/auth/passkey", methods=["DELETE"])
@login_required
def api_remove_passkey():
"""Remove the current user's Passkey (re-auth required).
If this leaves the account with no MFA method at all, the account is
flagged must_configure_mfa so the next login forces MFA setup again
(same guard as a fresh temp account).
"""
if not require_reauth():
return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403
uid = _cur()
cfg = get_auth_config(user_id=uid)
if not cfg.get("passkey_credential_id"):
return jsonify({"error": "未配置 Passkey"}), 400
update_auth_config(uid, passkey_credential_id="", passkey_public_key="")
if not has_mfa_configured(user_id=uid):
update_auth_config(uid, must_configure_mfa=1)
return jsonify({"ok": True})
# ─── 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/<int:user_id>", 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/<int:user_id>", 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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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, user_id=user_id)
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/<int:index>", 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/<int:index>", 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", "&#128276;"),
"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/<int:group_id>", 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/<int:group_id>", 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/<int:group_id>/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/<int:source_id>", 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/<int:source_id>/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/<int:group_id>/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/<int:event_id>", 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/<int:event_id>", 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):
# 广播给该用户的显示端,立即刷新(跨 worker 由 EventBus 远程广播保证)
current_app.event_bus.publish("timezone_changed", {"timezone": tz_str}, user_id=_cur())
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/<int:alarm_id>", 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/<int:alarm_id>", 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/<int:cd_id>", 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/<int:cd_id>", 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)