1673 lines
60 KiB
Python
1673 lines
60 KiB
Python
"""REST API routes and SSE endpoint.
|
||
|
||
Multi-user rules enforced here:
|
||
- Every data route operates on `current_user_id()` (session user).
|
||
- User management (/api/admin/*) is Root-only and requires re-authentication
|
||
(TOTP first, Passkey fallback) for register/edit/delete.
|
||
- Temp accounts log in with a server-held temp TOTP; they are forced through
|
||
the MFA setup page until they configure (and verify) one real login method,
|
||
at which point the temp TOTP is invalidated.
|
||
"""
|
||
import json
|
||
import os
|
||
import queue
|
||
import io
|
||
import time as _time
|
||
import secrets
|
||
from datetime import datetime
|
||
from flask import (
|
||
Blueprint, request, jsonify, Response,
|
||
render_template, current_app, stream_with_context,
|
||
session, redirect, url_for,
|
||
)
|
||
from backend import calendar_mgr, weather_mgr, alarm_mgr, pomodoro_mgr, countdown_mgr
|
||
from backend.auth import (
|
||
login_required, login_or_temp_required, display_login_required, admin_required,
|
||
is_authenticated, is_temp_pending, is_admin, current_user_id,
|
||
set_authenticated, set_temp_pending, clear_session,
|
||
verify_login, verify_totp, verify_recovery_code,
|
||
check_rate_limit, record_failure, record_success,
|
||
require_reauth, mark_reauth, generate_recovery_codes, generate_temp_password,
|
||
)
|
||
from backend.models import (
|
||
get_auth_config, update_auth_config, set_temp_password, clear_temp_password,
|
||
set_recovery_codes, get_recovery_codes, has_mfa_configured,
|
||
get_display_config, set_display_mode, set_custom_html,
|
||
set_custom_html_template, apply_custom_html,
|
||
get_users, get_user, create_user, update_user, delete_user,
|
||
is_temp_account, is_admin_user, finalize_temp_account,
|
||
find_user_by_credential_id, bump_all_session_versions,
|
||
)
|
||
from backend.webauthn_mgr import (
|
||
begin_registration, complete_registration,
|
||
begin_authentication, complete_authentication,
|
||
)
|
||
|
||
|
||
api = Blueprint("api", __name__)
|
||
|
||
|
||
def _cur() -> int:
|
||
"""Current session user id (0 = Root default)."""
|
||
return current_user_id() or 0
|
||
|
||
|
||
# ════════════════════════════════════════════
|
||
# CSRF / Origin Check
|
||
# ════════════════════════════════════════════
|
||
|
||
@api.before_request
|
||
def _check_origin():
|
||
"""Protect state-changing requests against CSRF by verifying Origin/Referer.
|
||
Allows same-origin requests, extension-internal calls, and GET/HEAD/OPTIONS.
|
||
"""
|
||
if request.method in ("GET", "HEAD", "OPTIONS"):
|
||
return None
|
||
|
||
# Login / logout endpoints are exempt (they set/clear the cookie itself)
|
||
if request.path in ("/api/auth/login", "/api/auth/logout", "/api/auth/login/recovery",
|
||
"/api/auth/webauthn/login/begin", "/api/auth/webauthn/login/complete",
|
||
"/login", "/mfa-setup"):
|
||
return None
|
||
|
||
origin = request.headers.get("Origin", "")
|
||
referer = request.headers.get("Referer", "")
|
||
|
||
# Build expected origin from the request itself
|
||
expected = f"{request.scheme}://{request.host}"
|
||
|
||
if origin and origin != expected:
|
||
return jsonify({"error": "CSRF: Origin mismatch"}), 403
|
||
if referer and not referer.startswith(expected):
|
||
# Referer may include path, so we only check the prefix
|
||
return jsonify({"error": "CSRF: Referer mismatch"}), 403
|
||
|
||
return None
|
||
|
||
|
||
# ════════════════════════════════════════════
|
||
# AUTH
|
||
# ════════════════════════════════════════════
|
||
|
||
@api.route("/login")
|
||
def login_page():
|
||
"""Render login page. Control gets a full-input page; display gets a numpad."""
|
||
next_url = request.args.get("next", "/")
|
||
view = request.args.get("view", "display")
|
||
if view == "control" or "control" in next_url:
|
||
return render_template("login_control.html", next=next_url)
|
||
return render_template("login.html", next=next_url)
|
||
|
||
|
||
@api.route("/register-request")
|
||
def register_request_page():
|
||
"""Public page where a prospective user fills in their info (admin approves later)."""
|
||
return render_template("register_request.html")
|
||
|
||
|
||
@api.route("/passkey")
|
||
def passkey_page():
|
||
"""Passkey bridge page: loaded inside the ASWebAuthenticationSession sheet
|
||
(Safari environment supports WebAuthn/passkeys WITHOUT app associated
|
||
domains). It fetches the stashed {op, options}, runs the standard
|
||
WebAuthn ceremony, then redirects back via dashboard://passkey."""
|
||
return render_template("passkey.html")
|
||
|
||
|
||
# ─── 一次性 Passkey 参数暂存(小窗是独立 ephemeral 会话,读不到主容器 storage)───
|
||
_PASSKEY_STASH: dict = {} # sid -> {"op": str, "options": dict, "expires": float, "result": dict}
|
||
_PASSKEY_STASH_TTL = 300 # 秒(用户在小窗 Passkey 面板操作需要时间)
|
||
|
||
|
||
def _clean_passkey_stash():
|
||
now = _time.time()
|
||
expired = [k for k, v in _PASSKEY_STASH.items() if v["expires"] < now]
|
||
for k in expired:
|
||
_PASSKEY_STASH.pop(k, None)
|
||
|
||
|
||
@api.route("/api/passkey/stash", methods=["POST"])
|
||
def passkey_stash():
|
||
"""Store passkey ceremony parameters once, return a short-lived random id.
|
||
Called by the Passkey bridge middleware from the main WKWebView; the
|
||
ceremony itself runs inside the ephemeral sheet page which consumes the id."""
|
||
data = request.get_json(silent=True) or {}
|
||
op = data.get("op")
|
||
options = data.get("options")
|
||
if op not in ("creation", "assertion") or not isinstance(options, dict):
|
||
return jsonify({"error": "无效的 Passkey 请求"}), 400
|
||
_clean_passkey_stash()
|
||
sid = secrets.token_urlsafe(16)
|
||
_PASSKEY_STASH[sid] = {"op": op, "options": options, "expires": _time.time() + _PASSKEY_STASH_TTL}
|
||
return jsonify({"sid": sid}), 201
|
||
|
||
|
||
@api.route("/api/passkey/stash/<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")
|
||
|
||
result = verify_login(code)
|
||
if result is None:
|
||
record_failure()
|
||
return jsonify({"error": "验证码错误"}), 401
|
||
|
||
resp = {"ok": True, "method": result["method"]}
|
||
uid = result["user_id"]
|
||
temp_account = result["temp_account"]
|
||
|
||
if result["method"] == "totp":
|
||
if temp_account:
|
||
# Temp account: server-held temp TOTP matched → force registration flow.
|
||
record_success()
|
||
set_temp_pending(view, uid)
|
||
resp["must_configure_mfa"] = True
|
||
else:
|
||
cfg = get_auth_config(user_id=uid)
|
||
if not cfg.get("totp_enabled"):
|
||
return jsonify({"error": "TOTP 尚未启用"}), 403
|
||
record_success()
|
||
set_authenticated(view, uid)
|
||
|
||
else: # temp_password (Root initial bootstrap)
|
||
record_success()
|
||
set_temp_pending(view, uid)
|
||
resp["must_configure_mfa"] = True
|
||
|
||
return jsonify(resp)
|
||
|
||
|
||
# ─── Recovery Code Login ─────────────────────
|
||
|
||
@api.route("/api/auth/login/recovery", methods=["POST"])
|
||
def api_login_recovery():
|
||
"""Login with a recovery code (one-time use). Rate-limited (same pool as TOTP)."""
|
||
rl = check_rate_limit()
|
||
if not rl["ok"]:
|
||
return jsonify({"error": rl["message"], "retry_after": rl["retry_after"]}), 429
|
||
|
||
data = request.get_json() or {}
|
||
code = data.get("code", "")
|
||
view = data.get("view", "control")
|
||
|
||
uid = verify_recovery_code(code)
|
||
if uid is not None:
|
||
record_success()
|
||
set_authenticated(view, uid)
|
||
return jsonify({"ok": True})
|
||
|
||
record_failure()
|
||
return jsonify({"error": "恢复码错误或已使用"}), 401
|
||
|
||
|
||
# ─── Re-authentication ───────────────────────
|
||
# High-risk operations (user management, MFA changes) require re-confirming the
|
||
# identity of the CURRENT user. TOTP is primary; a Passkey ceremony is the
|
||
# fallback when the user has no TOTP configured.
|
||
|
||
@api.route("/api/auth/reauth", methods=["POST"])
|
||
def api_reauth():
|
||
"""Re-authenticate before sensitive operations. Requires TOTP code.
|
||
Only callable from an authenticated (or temp-pending) session — otherwise
|
||
this endpoint would be a public TOTP oracle. Rate-limited like login.
|
||
"""
|
||
if not is_authenticated() and not is_temp_pending():
|
||
return jsonify({"error": "Unauthorized", "login_required": True}), 401
|
||
|
||
rl = check_rate_limit()
|
||
if not rl["ok"]:
|
||
return jsonify({"error": rl["message"], "retry_after": rl["retry_after"]}), 429
|
||
|
||
data = request.get_json() or {}
|
||
code = data.get("code", "")
|
||
|
||
cfg = get_auth_config(user_id=_cur())
|
||
if cfg.get("totp_enabled"):
|
||
if verify_totp(code, user_id=_cur()):
|
||
record_success()
|
||
mark_reauth()
|
||
return jsonify({"ok": True})
|
||
record_failure()
|
||
return jsonify({"error": "验证码错误"}), 401
|
||
|
||
# No TOTP configured: temp-pending sessions pass without a code;
|
||
# passkey-only users must use the Passkey re-auth ceremony instead.
|
||
if is_temp_pending():
|
||
mark_reauth()
|
||
return jsonify({"ok": True})
|
||
return jsonify({"error": "未配置 TOTP,请使用 Passkey 重验证"}), 400
|
||
|
||
|
||
@api.route("/api/auth/reauth/webauthn/begin", methods=["POST"])
|
||
@login_required
|
||
def api_reauth_webauthn_begin():
|
||
"""Start a Passkey re-authentication ceremony (fallback when no TOTP)."""
|
||
cfg = get_auth_config(user_id=_cur())
|
||
stored_id = cfg.get("passkey_credential_id", "")
|
||
if not stored_id:
|
||
return jsonify({"error": "未注册 Passkey"}), 400
|
||
try:
|
||
options = begin_authentication(request, allow_credential_ids=[stored_id])
|
||
return jsonify(options)
|
||
except Exception as e:
|
||
return jsonify({"error": str(e)}), 500
|
||
|
||
|
||
@api.route("/api/auth/reauth/webauthn/complete", methods=["POST"])
|
||
@login_required
|
||
def api_reauth_webauthn_complete():
|
||
"""Complete a Passkey re-authentication ceremony."""
|
||
data = request.get_json() or {}
|
||
credential = data.get("credential", {})
|
||
|
||
cfg = get_auth_config(user_id=_cur())
|
||
stored_id = cfg.get("passkey_credential_id", "")
|
||
stored_key = cfg.get("passkey_public_key", "")
|
||
if not stored_id or not stored_key:
|
||
return jsonify({"error": "未注册 Passkey"}), 400
|
||
|
||
try:
|
||
result = complete_authentication(
|
||
request, credential,
|
||
stored_credential_id=stored_id,
|
||
stored_public_key=stored_key,
|
||
)
|
||
if result.get("credential_id") != stored_id:
|
||
return jsonify({"error": "Passkey 凭证不匹配"}), 401
|
||
mark_reauth()
|
||
return jsonify({"ok": True})
|
||
except ValueError as e:
|
||
return jsonify({"error": str(e)}), 401
|
||
except Exception as e:
|
||
return jsonify({"error": f"Passkey 验证失败: {e}"}), 500
|
||
|
||
|
||
# ─── Passkey (WebAuthn) Login ────────────────
|
||
|
||
@api.route("/api/auth/webauthn/login/begin", methods=["POST"])
|
||
def api_webauthn_login_begin():
|
||
"""Start WebAuthn authentication. Returns challenge options for the frontend."""
|
||
try:
|
||
options = begin_authentication(request)
|
||
return jsonify(options)
|
||
except Exception as e:
|
||
return jsonify({"error": str(e)}), 500
|
||
|
||
|
||
@api.route("/api/auth/webauthn/login/complete", methods=["POST"])
|
||
def api_webauthn_login_complete():
|
||
"""Complete WebAuthn authentication. Finds the owning user by credential id."""
|
||
rl = check_rate_limit()
|
||
if not rl["ok"]:
|
||
return jsonify({"error": rl["message"], "retry_after": rl["retry_after"]}), 429
|
||
|
||
data = request.get_json() or {}
|
||
view = data.get("view", "control")
|
||
credential = data.get("credential", {})
|
||
credential_b64 = credential.get("id", "")
|
||
|
||
# Locate the user that owns this credential (frontend sends base64url id).
|
||
uid = None
|
||
if credential_b64:
|
||
try:
|
||
import base64
|
||
padded = credential_b64 + "=" * ((4 - len(credential_b64) % 4) % 4)
|
||
raw = base64.urlsafe_b64decode(padded.encode("ascii"))
|
||
uid = find_user_by_credential_id(raw.hex())
|
||
except Exception:
|
||
uid = None
|
||
|
||
if uid is None:
|
||
record_failure()
|
||
return jsonify({"error": "未注册 Passkey"}), 400
|
||
|
||
cfg = get_auth_config(user_id=uid)
|
||
stored_id_hex = cfg.get("passkey_credential_id", "")
|
||
stored_public_key = cfg.get("passkey_public_key", "")
|
||
if not stored_id_hex or not stored_public_key:
|
||
record_failure()
|
||
return jsonify({"error": "未注册 Passkey"}), 400
|
||
|
||
try:
|
||
result = complete_authentication(
|
||
request, credential,
|
||
stored_credential_id=credential_b64,
|
||
stored_public_key=stored_public_key,
|
||
)
|
||
# complete_authentication returns the credential id in hex.
|
||
if result.get("credential_id") != stored_id_hex:
|
||
record_failure()
|
||
return jsonify({"error": "Passkey 凭证不匹配"}), 401
|
||
record_success()
|
||
set_authenticated(view, uid)
|
||
return jsonify({"ok": True})
|
||
except ValueError as e:
|
||
record_failure()
|
||
return jsonify({"error": str(e)}), 401
|
||
except Exception as e:
|
||
return jsonify({"error": f"Passkey 验证失败: {e}"}), 500
|
||
|
||
|
||
# ─── Logout ──────────────────────────────────
|
||
|
||
@api.route("/api/auth/logout", methods=["POST"])
|
||
def api_logout():
|
||
clear_session()
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
# ─── Auth Status ─────────────────────────────
|
||
|
||
def _user_payload(uid: int) -> dict:
|
||
u = get_user(uid) or {}
|
||
return {
|
||
"id": uid,
|
||
"remark": u.get("remark", "") or "",
|
||
"is_admin": is_admin_user(uid),
|
||
"is_temp": bool(u.get("is_temp", 0)),
|
||
}
|
||
|
||
|
||
@api.route("/api/auth/status", methods=["GET"])
|
||
def api_auth_status():
|
||
"""Return current auth status. Used by frontend to decide login flow."""
|
||
uid = current_user_id()
|
||
if is_authenticated():
|
||
return jsonify({
|
||
"authenticated": True,
|
||
"mfa_configured": has_mfa_configured(user_id=uid or 0),
|
||
"user": _user_payload(uid or 0),
|
||
})
|
||
if is_temp_pending():
|
||
return jsonify({
|
||
"authenticated": False,
|
||
"temp_pending": True,
|
||
"mfa_configured": has_mfa_configured(user_id=uid or 0),
|
||
"user": _user_payload(uid or 0),
|
||
})
|
||
return jsonify({
|
||
"authenticated": False,
|
||
"temp_pending": False,
|
||
"mfa_configured": False,
|
||
})
|
||
|
||
|
||
# ════════════════════════════════════════════
|
||
# USER SELF-SERVICE REQUEST
|
||
# ════════════════════════════════════════════
|
||
# The user fills in their info; the admin confirms it and a temp account
|
||
# (with a server-held temp TOTP) is generated automatically.
|
||
|
||
_REG_REQ_LIMIT: dict[str, list] = {} # ip -> [timestamps]
|
||
|
||
|
||
@api.route("/api/auth/register-request", methods=["POST"])
|
||
def api_register_request():
|
||
"""Public: a prospective user submits their remark for admin approval.
|
||
Rate-limited per IP (5 requests per 10 minutes)."""
|
||
ip = request.remote_addr or "unknown"
|
||
now = _time.time()
|
||
stamps = [t for t in _REG_REQ_LIMIT.get(ip, []) if now - t < 600]
|
||
if len(stamps) >= 5:
|
||
_REG_REQ_LIMIT[ip] = stamps
|
||
return jsonify({"error": "申请过于频繁,请稍后再试"}), 429
|
||
stamps.append(now)
|
||
_REG_REQ_LIMIT[ip] = stamps
|
||
|
||
data = request.get_json() or {}
|
||
remark = (data.get("remark") or "").strip()
|
||
if not remark:
|
||
return jsonify({"error": "请填写你的称呼/备注"}), 400
|
||
if len(remark) > 64:
|
||
return jsonify({"error": "备注过长"}), 400
|
||
|
||
create_user(remark=remark, role="user", is_temp=0, status="pending")
|
||
return jsonify({"ok": True, "message": "已提交申请,请等待管理员确认"}), 201
|
||
|
||
|
||
# ════════════════════════════════════════════
|
||
# MFA CONFIGURATION
|
||
# ════════════════════════════════════════════
|
||
|
||
@api.route("/mfa-setup")
|
||
def mfa_setup_page():
|
||
"""MFA configuration page. Shown after temp-password / temp-account login."""
|
||
if not is_temp_pending() and not is_authenticated():
|
||
return redirect(url_for("api.login_page", next="/mfa-setup"))
|
||
return render_template("mfa_setup.html")
|
||
|
||
|
||
# ─── WebAuthn Registration ───────────────────
|
||
|
||
@api.route("/api/auth/webauthn/register/begin", methods=["POST"])
|
||
@login_or_temp_required
|
||
def api_webauthn_register_begin():
|
||
"""Start WebAuthn registration for the current user."""
|
||
if not require_reauth():
|
||
return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403
|
||
uid = _cur()
|
||
cfg = get_auth_config(user_id=uid)
|
||
existing_id = cfg.get("passkey_credential_id", "")
|
||
user = get_user(uid) or {}
|
||
try:
|
||
options = begin_registration(request, uid, display_name=user.get("remark", ""), existing_credential_id=existing_id)
|
||
return jsonify(options)
|
||
except Exception as e:
|
||
return jsonify({"error": str(e)}), 500
|
||
|
||
|
||
@api.route("/api/auth/webauthn/register/complete", methods=["POST"])
|
||
@login_or_temp_required
|
||
def api_webauthn_register_complete():
|
||
"""Complete WebAuthn registration. Saves credential for the current user.
|
||
If the account is a temp account, finalizing it invalidates the temp TOTP.
|
||
"""
|
||
data = request.get_json() or {}
|
||
credential = data.get("credential", {})
|
||
uid = _cur()
|
||
|
||
try:
|
||
result = complete_registration(request, credential)
|
||
if result.get("user_id") != uid:
|
||
return jsonify({"error": "注册上下文不匹配,请重试"}), 400
|
||
update_auth_config(
|
||
uid,
|
||
passkey_credential_id=result["credential_id"],
|
||
passkey_public_key=result["public_key"],
|
||
)
|
||
finalized = False
|
||
if is_temp_account(uid):
|
||
finalize_temp_account(uid)
|
||
finalized = True
|
||
# The user proved access to the account — mark the session authenticated.
|
||
set_authenticated(session.get("mfa_view", "control"), uid)
|
||
return jsonify({"ok": True, "finalized": finalized})
|
||
except ValueError as e:
|
||
return jsonify({"error": str(e)}), 400
|
||
except Exception as e:
|
||
return jsonify({"error": f"Passkey 注册失败: {e}"}), 500
|
||
|
||
|
||
# ─── TOTP Setup ──────────────────────────────
|
||
|
||
@api.route("/api/auth/totp/setup", methods=["POST"])
|
||
@login_or_temp_required
|
||
def api_totp_setup_begin():
|
||
"""Generate a TOTP secret for the current user."""
|
||
if not require_reauth():
|
||
return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403
|
||
import pyotp
|
||
import qrcode
|
||
import base64
|
||
|
||
uid = _cur()
|
||
user = get_user(uid) or {}
|
||
|
||
# Generate a new secret
|
||
secret = pyotp.random_base32()
|
||
|
||
# Store temporarily (will be enabled after verification)
|
||
update_auth_config(uid, totp_secret=secret, totp_enabled=0)
|
||
|
||
# Build provisioning URI
|
||
issuer = "Cal Display"
|
||
name = (user.get("remark") or f"user_{uid}")[:64]
|
||
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=name, issuer_name=issuer)
|
||
|
||
# Generate QR code as base64 PNG
|
||
qr = qrcode.make(uri)
|
||
buf = io.BytesIO()
|
||
qr.save(buf, format="PNG")
|
||
qr_b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
|
||
|
||
return jsonify({
|
||
"secret": secret,
|
||
"uri": uri,
|
||
"qr_base64": qr_b64,
|
||
})
|
||
|
||
|
||
@api.route("/api/auth/totp/verify", methods=["POST"])
|
||
@login_or_temp_required
|
||
def api_totp_verify():
|
||
"""Verify a TOTP code to enable TOTP MFA for the current user.
|
||
For temp accounts this also converts the account (temp TOTP is discarded).
|
||
"""
|
||
data = request.get_json() or {}
|
||
code = data.get("code", "")
|
||
uid = _cur()
|
||
|
||
if verify_totp(code, user_id=uid):
|
||
update_auth_config(uid, totp_enabled=1)
|
||
# Generate recovery codes on first TOTP enable
|
||
cfg = get_auth_config(user_id=uid)
|
||
if not cfg.get("recovery_codes"):
|
||
codes = generate_recovery_codes()
|
||
set_recovery_codes(codes, user_id=uid)
|
||
|
||
finalized = False
|
||
if is_temp_account(uid):
|
||
finalize_temp_account(uid)
|
||
finalized = True
|
||
set_authenticated(session.get("mfa_view", "control"), uid)
|
||
|
||
return jsonify({
|
||
"ok": True,
|
||
"recovery_codes": get_recovery_codes(user_id=uid),
|
||
"finalized": finalized,
|
||
})
|
||
|
||
return jsonify({"error": "验证码错误,请重试"}), 400
|
||
|
||
|
||
# ─── MFA Complete / Skip Temp Password ─────
|
||
|
||
@api.route("/api/auth/mfa/complete", methods=["POST"])
|
||
def api_mfa_complete():
|
||
"""Called after user has configured at least one MFA method.
|
||
Works for temp-password (Root bootstrap) and temp-account sessions.
|
||
"""
|
||
if not is_temp_pending() and not is_authenticated():
|
||
return jsonify({"error": "请先登录"}), 401
|
||
uid = _cur()
|
||
cfg = get_auth_config(user_id=uid)
|
||
if not cfg.get("passkey_credential_id") and not cfg.get("totp_enabled"):
|
||
return jsonify({"error": "请至少配置一种 MFA 方式"}), 400
|
||
|
||
if is_temp_account(uid):
|
||
finalize_temp_account(uid)
|
||
clear_temp_password(user_id=uid)
|
||
update_auth_config(uid, must_configure_mfa=0)
|
||
set_authenticated(session.get("mfa_view", "control"), uid)
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
# ─── Recovery Codes ──────────────────────────
|
||
|
||
@api.route("/api/auth/recovery-codes", methods=["GET"])
|
||
@login_or_temp_required
|
||
def api_get_recovery_codes():
|
||
"""Get remaining recovery codes for the current user."""
|
||
uid = _cur()
|
||
cfg = get_auth_config(user_id=uid)
|
||
if not cfg.get("recovery_codes"):
|
||
return jsonify({"codes": [], "remaining": 0})
|
||
codes = get_recovery_codes(user_id=uid)
|
||
return jsonify({"codes": codes, "remaining": len(codes)})
|
||
|
||
|
||
@api.route("/api/auth/recovery-codes/regenerate", methods=["POST"])
|
||
@login_or_temp_required
|
||
def api_regenerate_recovery_codes():
|
||
"""Regenerate recovery codes. Requires recent re-authentication."""
|
||
if not require_reauth():
|
||
return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403
|
||
codes = generate_recovery_codes()
|
||
set_recovery_codes(codes, user_id=_cur())
|
||
return jsonify({"codes": codes, "remaining": len(codes)})
|
||
|
||
|
||
# ─── MFA Status (for control panel settings) ─
|
||
|
||
@api.route("/api/auth/mfa/status", methods=["GET"])
|
||
@login_required
|
||
def api_mfa_status():
|
||
"""Return what MFA methods are configured for the current user."""
|
||
uid = _cur()
|
||
cfg = get_auth_config(user_id=uid)
|
||
codes = get_recovery_codes(user_id=uid)
|
||
return jsonify({
|
||
"passkey_enabled": bool(cfg.get("passkey_credential_id")),
|
||
"totp_enabled": bool(cfg.get("totp_enabled")),
|
||
"recovery_codes_remaining": len(codes),
|
||
"is_temp_account": is_temp_account(uid),
|
||
})
|
||
|
||
|
||
# ════════════════════════════════════════════
|
||
# THEME(账户独立主题)
|
||
# ════════════════════════════════════════════
|
||
|
||
def _effective_theme(uid: int, mode: str) -> str:
|
||
"""解析主题模式为实际生效值:dark / light / auto。
|
||
auto 模式按用户天气配置的经纬度 + 本地时区计算日出日落,
|
||
白天→浅色,夜晚→深色;无位置数据时按本地 6:00-18:00 粗判。"""
|
||
if mode == "dark":
|
||
return "dark"
|
||
if mode == "light":
|
||
return "light"
|
||
try:
|
||
from backend.time_util import now as tz_now
|
||
from backend.sun_util import is_daytime
|
||
cfg = weather_mgr.get_config(user_id=uid)
|
||
lat = cfg.get("lat")
|
||
lon = cfg.get("lon")
|
||
now_local = tz_now()
|
||
if lat is not None and lon is not None:
|
||
return "light" if is_daytime(lat, lon, now_local) else "dark"
|
||
return "light" if 6 <= now_local.hour < 18 else "dark"
|
||
except Exception:
|
||
return "dark"
|
||
|
||
|
||
@api.route("/api/theme", methods=["GET"])
|
||
@login_required
|
||
def get_theme():
|
||
"""返回当前用户的主题设置:mode 为用户选择(dark/light/auto),
|
||
effective 为实际生效(auto 已解析为 dark/light)。"""
|
||
uid = _cur()
|
||
mode = (get_user(uid) or {}).get("theme") or "dark"
|
||
return jsonify({
|
||
"mode": mode,
|
||
"effective": _effective_theme(uid, mode),
|
||
})
|
||
|
||
|
||
@api.route("/api/theme", methods=["PUT"])
|
||
@login_required
|
||
def set_theme():
|
||
"""保存主题设置并广播给该用户的显示端。"""
|
||
data = request.get_json() or {}
|
||
mode = data.get("mode")
|
||
if mode not in ("dark", "light", "auto"):
|
||
return jsonify({"error": "无效的主题模式"}), 400
|
||
uid = _cur()
|
||
update_user(uid, theme=mode)
|
||
effective = _effective_theme(uid, mode)
|
||
current_app.event_bus.publish("theme_changed", {"mode": mode, "effective": effective}, user_id=uid)
|
||
return jsonify({"ok": True, "mode": mode, "effective": effective})
|
||
|
||
|
||
# ════════════════════════════════════════════
|
||
# USER MANAGEMENT (ADMIN)
|
||
# ════════════════════════════════════════════
|
||
|
||
@api.route("/api/admin/users", methods=["GET"])
|
||
@admin_required
|
||
def admin_list_users():
|
||
"""List all users (including pending requests). No secrets are exposed."""
|
||
users = []
|
||
for u in get_users():
|
||
users.append({
|
||
"id": u["id"],
|
||
"remark": u["remark"],
|
||
"role": u["role"],
|
||
"is_temp": bool(u["is_temp"]),
|
||
"status": u["status"],
|
||
"created_at": u["created_at"],
|
||
"has_mfa": has_mfa_configured(user_id=u["id"]),
|
||
})
|
||
return jsonify(users)
|
||
|
||
|
||
@api.route("/api/admin/users", methods=["POST"])
|
||
@admin_required
|
||
def admin_create_user():
|
||
"""Register a user. Only admins can do this — re-auth required.
|
||
|
||
mode='admin_fills' → formal account immediately (admin then provisions TOTP
|
||
or Passkey on the user's behalf).
|
||
mode='user_fills' → temp account with a server-held temp TOTP.
|
||
"""
|
||
if not require_reauth():
|
||
return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403
|
||
|
||
data = request.get_json() or {}
|
||
remark = (data.get("remark") or "").strip()[:64]
|
||
mode = data.get("mode", "admin_fills")
|
||
if mode not in ("admin_fills", "user_fills"):
|
||
return jsonify({"error": "无效的注册模式"}), 400
|
||
|
||
if mode == "user_fills":
|
||
import pyotp
|
||
uid = create_user(remark=remark, role="user", is_temp=1,
|
||
temp_totp_secret=pyotp.random_base32(), status="active")
|
||
return jsonify({"ok": True, "user": _user_payload(uid), "is_temp": True}), 201
|
||
|
||
uid = create_user(remark=remark, role="user", is_temp=0, status="active")
|
||
return jsonify({"ok": True, "user": _user_payload(uid)}), 201
|
||
|
||
|
||
@api.route("/api/admin/users/confirm", methods=["POST"])
|
||
@admin_required
|
||
def admin_confirm_request():
|
||
"""Confirm a pending self-service request → temp account with temp TOTP."""
|
||
if not require_reauth():
|
||
return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403
|
||
|
||
data = request.get_json() or {}
|
||
request_id = data.get("request_id")
|
||
if request_id is None:
|
||
return jsonify({"error": "request_id required"}), 400
|
||
|
||
u = get_user(request_id)
|
||
if not u:
|
||
return jsonify({"error": "申请不存在"}), 404
|
||
if u.get("status") != "pending":
|
||
return jsonify({"error": "该申请已处理"}), 400
|
||
|
||
import pyotp
|
||
secret = pyotp.random_base32()
|
||
update_user(request_id, is_temp=1, temp_totp_secret=secret, status="active")
|
||
return jsonify({"ok": True, "user": _user_payload(request_id)})
|
||
|
||
|
||
@api.route("/api/admin/users/<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)
|
||
if result.get("user_id") != user_id:
|
||
return jsonify({"error": "注册上下文不匹配,请重试"}), 400
|
||
update_auth_config(
|
||
user_id,
|
||
passkey_credential_id=result["credential_id"],
|
||
passkey_public_key=result["public_key"],
|
||
)
|
||
return jsonify({"ok": True})
|
||
except ValueError as e:
|
||
return jsonify({"error": str(e)}), 400
|
||
except Exception as e:
|
||
return jsonify({"error": f"Passkey 注册失败: {e}"}), 500
|
||
|
||
|
||
# ════════════════════════════════════════════
|
||
# DISPLAY PAGES
|
||
# ════════════════════════════════════════════
|
||
|
||
@api.route("/")
|
||
@api.route("/index")
|
||
def display_page():
|
||
cfg = get_display_config(user_id=_cur())
|
||
if cfg.get("mode") == "custom":
|
||
return render_template("display_custom.html", custom_html=cfg.get("custom_html", ""))
|
||
if not is_authenticated() and not is_temp_pending():
|
||
return redirect(url_for("api.login_page", next=request.path))
|
||
return render_template("display.html")
|
||
|
||
|
||
# ════════════════════════════════════════════
|
||
# CONTROL PAGE
|
||
# ════════════════════════════════════════════
|
||
|
||
@api.route("/control")
|
||
@login_required
|
||
def control_page():
|
||
return render_template("control.html")
|
||
|
||
|
||
# ════════════════════════════════════════════
|
||
# DISPLAY / SSE / STATE
|
||
# ════════════════════════════════════════════
|
||
|
||
@api.route("/api/events")
|
||
def sse_stream():
|
||
"""Server-Sent Events for real-time updates (scoped to the current user)."""
|
||
if not is_authenticated() and not is_temp_pending():
|
||
return jsonify({"error": "Unauthorized", "login_required": True}), 401
|
||
uid = _cur()
|
||
|
||
def generate():
|
||
q = current_app.event_bus.subscribe(user_id=uid)
|
||
try:
|
||
yield "data: {\"type\":\"connected\"}\n\n"
|
||
while True:
|
||
try:
|
||
msg = q.get(timeout=15)
|
||
yield f"data: {msg}\n\n"
|
||
except queue.Empty:
|
||
yield "data: {\"type\":\"heartbeat\"}\n\n"
|
||
finally:
|
||
current_app.event_bus.unsubscribe(q)
|
||
|
||
return Response(
|
||
stream_with_context(generate()),
|
||
mimetype="text/event-stream",
|
||
headers={
|
||
"Cache-Control": "no-cache",
|
||
"X-Accel-Buffering": "no",
|
||
},
|
||
)
|
||
|
||
|
||
# ─── Display State (needed by display page) ─
|
||
|
||
@api.route("/api/display/state", methods=["GET"])
|
||
@display_login_required
|
||
def get_display_state():
|
||
"""Return all data needed by the display page (read-only for display, full for control)."""
|
||
uid = _cur()
|
||
events = calendar_mgr.get_events(days=7, user_id=uid)
|
||
weather = weather_mgr.get_cached_weather(user_id=uid)
|
||
alarms = alarm_mgr.get_alarms(user_id=uid)
|
||
countdowns = countdown_mgr.get_countdowns(user_id=uid)
|
||
groups = calendar_mgr.get_groups(user_id=uid)
|
||
pomodoro = pomodoro_mgr.get_state(user_id=uid)
|
||
hourly = weather_mgr.get_cached_hourly(user_id=uid)
|
||
if not hourly:
|
||
hourly = weather_mgr.fetch_hourly(user_id=uid)
|
||
update_schedule = weather_mgr.get_next_update_times(user_id=uid)
|
||
minutely = weather_mgr.get_cached_minutely(user_id=uid)
|
||
warnings = weather_mgr.get_cached_warnings(user_id=uid)
|
||
|
||
return jsonify({
|
||
"events": events,
|
||
"weather": weather,
|
||
"hourly": hourly,
|
||
"alarms": alarms,
|
||
"countdowns": countdowns,
|
||
"groups": [g for g in groups if g["enabled"]],
|
||
"pomodoro": pomodoro,
|
||
"update_schedule": update_schedule,
|
||
"minutely": minutely,
|
||
"warnings": warnings,
|
||
})
|
||
|
||
|
||
@api.route("/api/display/mode", methods=["GET"])
|
||
@login_required
|
||
def get_display_mode():
|
||
cfg = get_display_config(user_id=_cur())
|
||
return jsonify(cfg)
|
||
|
||
|
||
@api.route("/api/display/mode", methods=["PUT"])
|
||
@login_required
|
||
def update_display_mode():
|
||
data = request.get_json() or {}
|
||
mode = data.get("mode", "")
|
||
uid = _cur()
|
||
if set_display_mode(mode, user_id=uid):
|
||
current_app.event_bus.publish("display_mode_changed", {"mode": mode}, user_id=uid)
|
||
return jsonify({"ok": True})
|
||
return jsonify({"error": "Invalid mode"}), 400
|
||
|
||
|
||
@api.route("/api/display/custom_html", methods=["PUT"])
|
||
@login_required
|
||
def update_custom_html():
|
||
data = request.get_json() or {}
|
||
html = data.get("html", "")
|
||
uid = _cur()
|
||
set_custom_html(html, user_id=uid)
|
||
current_app.event_bus.publish("display_mode_changed", {"mode": "custom"}, user_id=uid)
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@api.route("/api/display/custom_html/<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"])
|
||
@admin_required
|
||
def kick_all_displays():
|
||
"""Kick ALL sessions (including all users) by bumping every user's version.
|
||
Old session cookies become invalid immediately. Also broadcasts SSE.
|
||
"""
|
||
bump_all_session_versions()
|
||
current_app.event_bus.publish("kick_all", {})
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@api.route("/api/display/notify", methods=["POST"])
|
||
@login_required
|
||
def push_notification():
|
||
data = request.get_json() or {}
|
||
uid = _cur()
|
||
current_app.event_bus.publish("custom_notification", {
|
||
"title": data.get("title", "提醒"),
|
||
"message": data.get("message", ""),
|
||
"icon": data.get("icon", "🔔"),
|
||
"sound": data.get("sound", True),
|
||
}, user_id=uid)
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
# ════════════════════════════════════════════
|
||
# CALENDAR
|
||
# ════════════════════════════════════════════
|
||
|
||
@api.route("/api/calendar/groups", methods=["GET"])
|
||
@login_required
|
||
def get_calendar_groups():
|
||
return jsonify(calendar_mgr.get_groups(user_id=_cur()))
|
||
|
||
|
||
@api.route("/api/calendar/groups", methods=["POST"])
|
||
@login_required
|
||
def create_calendar_group():
|
||
data = request.get_json() or {}
|
||
group = calendar_mgr.create_group(
|
||
name=data.get("name", "New Group"),
|
||
color=data.get("color", "#4A90D9"),
|
||
user_id=_cur(),
|
||
)
|
||
return jsonify(group), 201
|
||
|
||
|
||
@api.route("/api/calendar/groups/<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):
|
||
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)
|