First Push
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
"""Alarm and clock management."""
|
||||
import os
|
||||
from backend.time_util import now as tz_now
|
||||
from flask import current_app
|
||||
from backend.models import get_db
|
||||
|
||||
# Per-user "already triggered today" state: {user_id: (date_str, set)}
|
||||
_triggered_state: dict[int, tuple[str, set]] = {}
|
||||
|
||||
|
||||
|
||||
|
||||
def _uid() -> int:
|
||||
"""Resolve the current user (request session or scheduler thread)."""
|
||||
try:
|
||||
from backend.auth import current_user_id
|
||||
return current_user_id() or 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def get_alarms(user_id: int = None) -> list:
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
rows = [dict(r) for r in db.execute(
|
||||
"SELECT * FROM alarms WHERE user_id=? ORDER BY time", (user_id,)
|
||||
).fetchall()]
|
||||
db.close()
|
||||
return rows
|
||||
|
||||
|
||||
def add_alarm(label: str, time: str, days: str = "1,2,3,4,5,6,7", enabled: bool = True,
|
||||
sound: str = "default", once: bool = False, user_id: int = None) -> dict:
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
cur = db.execute(
|
||||
"INSERT INTO alarms (user_id, label, time, days, enabled, sound, once) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(user_id, label, time, days, 1 if enabled else 0, sound, 1 if once else 0)
|
||||
)
|
||||
db.commit()
|
||||
row = dict(db.execute("SELECT * FROM alarms WHERE id=?", (cur.lastrowid,)).fetchone())
|
||||
db.close()
|
||||
return row
|
||||
|
||||
|
||||
def update_alarm(alarm_id: int, label: str = None, time: str = None,
|
||||
days: str = None, enabled: bool = None, sound: str = None, once: bool = None,
|
||||
user_id: int = None):
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
fields = []
|
||||
vals = []
|
||||
for key, val in [("label", label), ("time", time), ("days", days),
|
||||
("enabled", 1 if enabled else 0 if enabled is not None else None),
|
||||
("sound", sound),
|
||||
("once", 1 if once else 0 if once is not None else None)]:
|
||||
if val is not None:
|
||||
fields.append(f"{key}=?")
|
||||
vals.append(val)
|
||||
ok = False
|
||||
if fields:
|
||||
vals.append(user_id)
|
||||
vals.append(alarm_id)
|
||||
cur = db.execute(f"UPDATE alarms SET {', '.join(fields)} WHERE user_id=? AND id=?", vals)
|
||||
db.commit()
|
||||
ok = cur.rowcount > 0
|
||||
db.close()
|
||||
return ok
|
||||
|
||||
|
||||
def delete_alarm(alarm_id: int, user_id: int = None) -> bool:
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
cur = db.execute("DELETE FROM alarms WHERE user_id=? AND id=?", (user_id, alarm_id))
|
||||
db.commit()
|
||||
db.close()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def check_alarms(user_id: int = None) -> list:
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
global _triggered_state
|
||||
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
now = tz_now()
|
||||
today_str = now.strftime("%Y%m%d")
|
||||
|
||||
triggered_date, triggered_today = _triggered_state.get(user_id, ("", set()))
|
||||
if triggered_date != today_str:
|
||||
triggered_today = set()
|
||||
triggered_date = today_str
|
||||
|
||||
current_time = now.strftime("%H:%M")
|
||||
weekday = str(now.isoweekday())
|
||||
|
||||
rows = db.execute(
|
||||
"SELECT * FROM alarms WHERE user_id=? AND enabled=1 AND time=?",
|
||||
(user_id, current_time)
|
||||
).fetchall()
|
||||
|
||||
triggered = []
|
||||
for r in rows:
|
||||
days_list = r["days"].split(",")
|
||||
if weekday in days_list:
|
||||
alarm_id = r["id"]
|
||||
if alarm_id in triggered_today:
|
||||
continue
|
||||
triggered_today.add(alarm_id)
|
||||
triggered.append(dict(r))
|
||||
# Once-only alarms: auto-disable after trigger
|
||||
if r["once"]:
|
||||
db.execute("UPDATE alarms SET enabled=0 WHERE user_id=? AND id=?", (user_id, alarm_id))
|
||||
db.commit()
|
||||
|
||||
_triggered_state[user_id] = (triggered_date, triggered_today)
|
||||
db.close()
|
||||
|
||||
if triggered:
|
||||
current_app.logger.info(f"Triggering {len(triggered)} alarms: {[a['label'] for a in triggered]}")
|
||||
current_app.event_bus.publish("alarm_triggered", {
|
||||
"alarms": triggered,
|
||||
"ts": now.isoformat(),
|
||||
}, user_id=user_id)
|
||||
return triggered
|
||||
|
||||
|
||||
def get_ringtone_path(sound_name: str) -> str | None:
|
||||
if not sound_name or sound_name == "default":
|
||||
return None
|
||||
ringtone_dir = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"frontend", "static", "ringtones"
|
||||
)
|
||||
path = os.path.join(ringtone_dir, sound_name)
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
return None
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
"""Flask application factory and SSE event system."""
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from backend.time_util import now as tz_now, strptime as tz_strptime
|
||||
from flask import Flask
|
||||
from flask_cors import CORS
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from backend.models import init_db
|
||||
from backend.routes import register_routes
|
||||
from backend.events import EventBus
|
||||
from backend.weather_mgr import fetch_weather, fetch_hourly
|
||||
|
||||
|
||||
def create_app(db_path: str = "data.db") -> Flask:
|
||||
app = Flask(
|
||||
__name__,
|
||||
template_folder="../frontend/templates",
|
||||
static_folder="../frontend/static",
|
||||
)
|
||||
# Generate a secure random key at startup.
|
||||
# Set FLASK_SECRET_KEY env var for a fixed key (sessions survive restarts).
|
||||
app.config["SECRET_KEY"] = os.environ.get("FLASK_SECRET_KEY") or secrets.token_hex(32)
|
||||
# Defensive check: ensure key is never empty
|
||||
if not app.config["SECRET_KEY"]:
|
||||
app.config["SECRET_KEY"] = secrets.token_hex(32)
|
||||
app.config["DB_PATH"] = db_path
|
||||
# Trust Nginx X-Forwarded-* headers (x_for=1, x_proto=1, x_host=1)
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||
|
||||
# Session hardening for production
|
||||
https = os.environ.get("FLASK_HTTPS", "").lower() in ("1", "true", "yes")
|
||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
app.config["SESSION_COOKIE_SECURE"] = https
|
||||
app.config["SESSION_PERMANENT"] = False
|
||||
|
||||
# Strict CORS: only allow same-origin requests (no wildcard for cookie auth)
|
||||
CORS(app, supports_credentials=True, origins=[]) # empty = same-origin only
|
||||
|
||||
# Content size limit: 5MB for uploads
|
||||
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024
|
||||
|
||||
# Session lifetime: 24 hours idle timeout
|
||||
app.config["PERMANENT_SESSION_LIFETIME"] = 86400
|
||||
|
||||
# Global event bus
|
||||
app.event_bus = EventBus()
|
||||
|
||||
init_db(db_path)
|
||||
register_routes(app)
|
||||
|
||||
# Security headers on every response
|
||||
@app.after_request
|
||||
def _security_headers(resp):
|
||||
resp.headers["X-Content-Type-Options"] = "nosniff"
|
||||
resp.headers["X-Frame-Options"] = "DENY"
|
||||
resp.headers["Referrer-Policy"] = "same-origin"
|
||||
# Strict CSP limits inline styles/scripts — relax if custom HTML used
|
||||
resp.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
"connect-src 'self'; "
|
||||
"frame-ancestors 'none'"
|
||||
)
|
||||
return resp
|
||||
|
||||
# Startup weather fetch: only if cache is stale (>= 30 min) or empty
|
||||
with app.app_context():
|
||||
_startup_weather(app, db_path)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _startup_weather(app, db_path: str):
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
row = conn.execute(
|
||||
"SELECT updated_at FROM weather_cache WHERE user_id=0 ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
except Exception:
|
||||
row = None
|
||||
|
||||
should_fetch = True
|
||||
if row and row[0]:
|
||||
try:
|
||||
updated = tz_strptime(row[0], "%Y-%m-%d %H:%M:%S")
|
||||
age = tz_now() - updated
|
||||
if age < timedelta(minutes=30):
|
||||
should_fetch = False
|
||||
app.logger.info(
|
||||
f"Startup: weather cache is {int(age.total_seconds()/60)} min old, skip fetch"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if should_fetch:
|
||||
try:
|
||||
fetch_weather()
|
||||
fetch_hourly()
|
||||
app.logger.info("Startup: weather fetched (cache stale or empty)")
|
||||
except Exception as e:
|
||||
app.logger.warning(f"Startup weather fetch failed: {e}")
|
||||
+446
@@ -0,0 +1,446 @@
|
||||
"""MFA-based authentication: TOTP, Passkey (WebAuthn), recovery codes, temp password.
|
||||
|
||||
Multi-user support:
|
||||
- Sessions carry the authenticated `user_id` (Root = 0).
|
||||
- The unified login (6-digit code) searches every active user's TOTP secret,
|
||||
then temp accounts' temp-TOTP secrets, then Root's temp password.
|
||||
- Session version is per-user (stored in the users table), so deleting a user
|
||||
or kicking all sessions immediately invalidates their cookies.
|
||||
"""
|
||||
import functools
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
from flask import session, redirect, url_for, request, jsonify, current_app, g
|
||||
from backend.models import (
|
||||
get_auth_config, update_auth_config, get_temp_password, set_temp_password,
|
||||
clear_temp_password, get_recovery_codes, set_recovery_codes, has_mfa_configured,
|
||||
get_user_session_version, user_exists, is_admin_user, get_db,
|
||||
)
|
||||
|
||||
# ─── Session keys ────────────────────────────
|
||||
SKEY_AUTH = "mfa_authenticated" # bool: fully authenticated
|
||||
SKEY_TEMP = "mfa_temp_pending" # bool: logged in with temp pw / temp account, config required
|
||||
SKEY_VIEW = "mfa_view" # str: "control" or "display"
|
||||
SKEY_REAUTH = "mfa_reauth_at" # float: timestamp of last re-authentication
|
||||
SKEY_VERSION = "mfa_session_ver" # int: session version (for kick_all invalidation)
|
||||
SKEY_USER = "user_id" # int: id of the authenticated user
|
||||
|
||||
|
||||
def _db_path(preferred: str = None) -> str:
|
||||
if preferred:
|
||||
return preferred
|
||||
return current_app.config.get("DB_PATH", "data.db")
|
||||
|
||||
|
||||
def _validate_version():
|
||||
"""If the session version is stale (kick_all / user deleted), clear the session."""
|
||||
uid = session.get(SKEY_USER)
|
||||
if uid is None:
|
||||
return
|
||||
# Cache the validation result for the duration of this request.
|
||||
if getattr(g, "_sv_ok", False) and getattr(g, "_sv_uid", None) == uid:
|
||||
return
|
||||
ver = session.get(SKEY_VERSION)
|
||||
if ver is None:
|
||||
return
|
||||
stored = get_user_session_version(uid)
|
||||
if stored == -1 or ver != stored:
|
||||
clear_session()
|
||||
return
|
||||
g._sv_ok = True
|
||||
g._sv_uid = uid
|
||||
|
||||
|
||||
def is_authenticated() -> bool:
|
||||
"""Returns True if the user has completed MFA authentication."""
|
||||
_validate_version()
|
||||
return session.get(SKEY_AUTH) is True
|
||||
|
||||
|
||||
def is_temp_pending() -> bool:
|
||||
"""True if logged in with a temp password or temp account and must set up a login method."""
|
||||
_validate_version()
|
||||
return session.get(SKEY_TEMP) is True
|
||||
|
||||
|
||||
def current_user_id() -> int | None:
|
||||
"""Return the id of the current user (session user, or scheduler thread user)."""
|
||||
try:
|
||||
from flask import has_request_context
|
||||
if has_request_context():
|
||||
_validate_version()
|
||||
uid = session.get(SKEY_USER)
|
||||
if uid is not None:
|
||||
return int(uid)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from backend import time_util
|
||||
return time_util.get_thread_user()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def current_remark() -> str:
|
||||
"""Return the current user's remark for display (never the raw id unless it IS the remark)."""
|
||||
uid = current_user_id()
|
||||
if uid is None:
|
||||
return ""
|
||||
try:
|
||||
from backend.models import get_user
|
||||
u = get_user(uid)
|
||||
return (u or {}).get("remark", "") or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def is_admin() -> bool:
|
||||
"""Only Root (id 0) — and any future admin role — can manage users."""
|
||||
uid = current_user_id()
|
||||
if uid is None:
|
||||
return False
|
||||
return is_admin_user(uid)
|
||||
|
||||
|
||||
def set_authenticated(view: str = "control", user_id: int = None):
|
||||
if user_id is None:
|
||||
user_id = session.get(SKEY_USER) or 0
|
||||
session[SKEY_AUTH] = True
|
||||
session[SKEY_TEMP] = False
|
||||
session[SKEY_VIEW] = view
|
||||
session[SKEY_USER] = user_id
|
||||
session[SKEY_VERSION] = get_user_session_version(user_id)
|
||||
# A fresh authentication must not inherit the temp-setup re-auth marker.
|
||||
session.pop(SKEY_REAUTH, None)
|
||||
session.permanent = True
|
||||
|
||||
|
||||
def set_temp_pending(view: str = "control", user_id: int = None):
|
||||
"""Mark the session as logged-in-but-must-configure (temp password / temp account)."""
|
||||
if user_id is None:
|
||||
user_id = session.get(SKEY_USER) or 0
|
||||
session[SKEY_AUTH] = False
|
||||
session[SKEY_TEMP] = True
|
||||
session[SKEY_VIEW] = view
|
||||
session[SKEY_USER] = user_id
|
||||
session[SKEY_VERSION] = get_user_session_version(user_id)
|
||||
session.permanent = True
|
||||
|
||||
|
||||
def clear_session():
|
||||
session.pop(SKEY_AUTH, None)
|
||||
session.pop(SKEY_TEMP, None)
|
||||
session.pop(SKEY_VIEW, None)
|
||||
session.pop(SKEY_REAUTH, None)
|
||||
session.pop(SKEY_USER, None)
|
||||
session.pop(SKEY_VERSION, None)
|
||||
|
||||
|
||||
# ─── Re-authentication guard ─────────────────
|
||||
# Sensitive operations (user management, MFA changes) require re-confirming
|
||||
# identity within the last N seconds. TOTP-first with a Passkey fallback
|
||||
# (the fallback ceremony is handled by the routes layer).
|
||||
|
||||
_REAUTH_TTL = 300 # 5 minutes
|
||||
|
||||
|
||||
def require_reauth():
|
||||
"""Check if recent re-authentication is still valid.
|
||||
Skips the check for temp_pending users (first-time MFA setup flow).
|
||||
"""
|
||||
if is_temp_pending():
|
||||
mark_reauth()
|
||||
return True
|
||||
last = session.get(SKEY_REAUTH)
|
||||
if last and (time.time() - last) < _REAUTH_TTL:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def mark_reauth():
|
||||
"""Mark re-authentication as done now."""
|
||||
session[SKEY_REAUTH] = time.time()
|
||||
|
||||
|
||||
# ─── Decorators ──────────────────────────────
|
||||
|
||||
def _unauthorized(request):
|
||||
if request.is_json or request.path.startswith("/api/"):
|
||||
return jsonify({"error": "Unauthorized", "login_required": True}), 401
|
||||
view_param = session.get(SKEY_VIEW, "control")
|
||||
return redirect(url_for("api.login_page", next=request.path, view=view_param))
|
||||
|
||||
|
||||
def login_required(view):
|
||||
"""Require full MFA authentication. Redirects to login if not authenticated."""
|
||||
@functools.wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if is_authenticated():
|
||||
return view(*args, **kwargs)
|
||||
# If logged in with temp password / temp account, redirect to MFA setup
|
||||
if is_temp_pending():
|
||||
if request.is_json or request.path.startswith("/api/"):
|
||||
return jsonify({"error": "MFA configuration required", "mfa_required": True}), 403
|
||||
return redirect(url_for("api.mfa_setup_page"))
|
||||
return _unauthorized(request)
|
||||
return wrapped
|
||||
|
||||
|
||||
def login_or_temp_required(view):
|
||||
"""Allow access if fully authenticated OR in temp-pending state (MFA setup)."""
|
||||
@functools.wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if is_authenticated() or is_temp_pending():
|
||||
return view(*args, **kwargs)
|
||||
return _unauthorized(request)
|
||||
return wrapped
|
||||
|
||||
|
||||
def display_login_required(view):
|
||||
"""Lighter auth check for display-end API calls."""
|
||||
@functools.wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if is_authenticated():
|
||||
return view(*args, **kwargs)
|
||||
return _unauthorized(request)
|
||||
return wrapped
|
||||
|
||||
|
||||
def admin_required(view):
|
||||
"""Require full authentication AND administrator (Root) role."""
|
||||
@functools.wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not is_authenticated():
|
||||
return _unauthorized(request)
|
||||
if not is_admin():
|
||||
return jsonify({"error": "Forbidden: 仅管理员可执行此操作"}), 403
|
||||
return view(*args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
# ─── Unified Login ───────────────────────────
|
||||
|
||||
def verify_login(raw: str, db_path: str = None) -> dict | None:
|
||||
"""Unified login: tries every user's TOTP, then temp accounts' temp-TOTP,
|
||||
then Root's temp password.
|
||||
|
||||
Returns:
|
||||
{'method': 'totp'|'temp_password', 'user_id': int, 'temp_account': bool}
|
||||
or None on failure.
|
||||
"""
|
||||
if not raw or not isinstance(raw, str) or not raw.isdigit() or len(raw) != 6:
|
||||
return None
|
||||
db_path = db_path or current_app.config.get("DB_PATH", "data.db")
|
||||
conn = get_db(db_path)
|
||||
try:
|
||||
import pyotp
|
||||
# Load all users + their auth rows in ONE query (avoids per-user connections).
|
||||
rows = conn.execute(
|
||||
"SELECT u.id AS user_id, u.is_temp, u.temp_totp_secret, "
|
||||
"a.totp_secret, a.totp_enabled, a.temp_password "
|
||||
"FROM users u LEFT JOIN auth_config a ON a.user_id = u.id "
|
||||
"WHERE u.status='active'"
|
||||
).fetchall()
|
||||
|
||||
# 1. Formal users' TOTP secrets
|
||||
for r in rows:
|
||||
if r["totp_enabled"] and r["totp_secret"]:
|
||||
if pyotp.TOTP(r["totp_secret"]).verify(raw, valid_window=1):
|
||||
return {"method": "totp", "user_id": r["user_id"], "temp_account": False}
|
||||
# 2. Temp accounts' temp-TOTP (server-only secret; admin sees only the live code)
|
||||
for r in rows:
|
||||
if r["is_temp"] and r["temp_totp_secret"]:
|
||||
if pyotp.TOTP(r["temp_totp_secret"]).verify(raw, valid_window=1):
|
||||
return {"method": "totp", "user_id": r["user_id"], "temp_account": True}
|
||||
# 3. Root temp password (initial bootstrap)
|
||||
root_row = next((r for r in rows if r["user_id"] == 0), None)
|
||||
stored_pw = (root_row["temp_password"] or "") if root_row else ""
|
||||
if stored_pw and secrets.compare_digest(raw, stored_pw):
|
||||
return {"method": "temp_password", "user_id": 0, "temp_account": False}
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Temp Password ───────────────────────────
|
||||
|
||||
def generate_temp_password(length: int = 6) -> str:
|
||||
"""Generate a random numeric temp password."""
|
||||
return "".join(secrets.choice(string.digits) for _ in range(length))
|
||||
|
||||
|
||||
def verify_temp_password(raw: str, db_path: str = None, user_id: int = 0) -> bool:
|
||||
"""Verify a temporary password for a given user (only Root uses one)."""
|
||||
if not raw or not isinstance(raw, str) or not raw.isdigit() or len(raw) != 6:
|
||||
return False
|
||||
db_path = db_path or current_app.config.get("DB_PATH", "data.db")
|
||||
stored = get_temp_password(db_path, user_id)
|
||||
if not stored:
|
||||
return False
|
||||
return secrets.compare_digest(raw, stored)
|
||||
|
||||
|
||||
# ─── Rate Limiter ─────────────────────────────
|
||||
# Progressive per-IP ban: 5err→5min, 10err→15min, 15+err→24hr
|
||||
|
||||
_RATE_LIMIT_STORE: dict[str, dict] = {} # ip -> {"failures": int, "banned_until": float}
|
||||
|
||||
|
||||
def _get_client_ip() -> str:
|
||||
"""Extract client IP for rate limiting.
|
||||
Relies on ProxyFix + Nginx config to set remote_addr correctly.
|
||||
"""
|
||||
if not current_app:
|
||||
return "unknown"
|
||||
return request.remote_addr or "unknown"
|
||||
|
||||
|
||||
def _ban_duration_sec(failures: int) -> int:
|
||||
"""Progressive ban duration based on consecutive failure count."""
|
||||
if failures < 5:
|
||||
return 0
|
||||
if failures < 10:
|
||||
return 5 * 60 # 5 minutes
|
||||
if failures < 15:
|
||||
return 15 * 60 # 15 minutes
|
||||
return 24 * 60 * 60 # 24 hours
|
||||
|
||||
|
||||
def check_rate_limit(ip: str = None) -> dict:
|
||||
"""Check if this IP is currently rate-limited."""
|
||||
if ip is None:
|
||||
ip = _get_client_ip()
|
||||
entry = _RATE_LIMIT_STORE.get(ip)
|
||||
if not entry:
|
||||
return {"ok": True}
|
||||
|
||||
now = time.time()
|
||||
banned_until = entry.get("banned_until", 0)
|
||||
if now < banned_until:
|
||||
remaining = int(banned_until - now)
|
||||
if remaining < 60:
|
||||
msg = f"登录尝试过多,请等待 {remaining} 秒"
|
||||
elif remaining < 3600:
|
||||
msg = f"登录尝试过多,请等待 {remaining // 60} 分钟"
|
||||
else:
|
||||
msg = f"登录尝试过多,请等待 {remaining // 3600} 小时"
|
||||
return {"ok": False, "message": msg, "retry_after": remaining}
|
||||
|
||||
# Ban expired — reset to current tier (keep failure count so next ban is higher tier)
|
||||
entry["banned_until"] = 0
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def record_failure(ip: str = None):
|
||||
"""Record a failed login attempt. Applies progressive ban if threshold reached."""
|
||||
if ip is None:
|
||||
ip = _get_client_ip()
|
||||
now = time.time()
|
||||
entry = _RATE_LIMIT_STORE.get(ip, {"failures": 0, "banned_until": 0})
|
||||
entry["failures"] += 1
|
||||
|
||||
duration = _ban_duration_sec(entry["failures"])
|
||||
if duration > 0:
|
||||
entry["banned_until"] = now + duration
|
||||
if current_app:
|
||||
current_app.logger.warning(
|
||||
f"Rate limit: IP {ip} banned for {duration // 60}min "
|
||||
f"after {entry['failures']} failures"
|
||||
)
|
||||
|
||||
_RATE_LIMIT_STORE[ip] = entry
|
||||
|
||||
|
||||
def record_success(ip: str = None):
|
||||
"""Reset failure counter on successful login."""
|
||||
if ip is None:
|
||||
ip = _get_client_ip()
|
||||
_RATE_LIMIT_STORE.pop(ip, None)
|
||||
|
||||
|
||||
# ─── TOTP ────────────────────────────────────
|
||||
|
||||
def verify_totp(raw: str, db_path: str = None, user_id: int = 0) -> bool:
|
||||
"""Verify a TOTP 6-digit code against a specific user's stored secret."""
|
||||
if not raw or not isinstance(raw, str) or not raw.isdigit() or len(raw) != 6:
|
||||
return False
|
||||
db_path = db_path or current_app.config.get("DB_PATH", "data.db")
|
||||
cfg = get_auth_config(db_path, user_id)
|
||||
secret = cfg.get("totp_secret", "")
|
||||
if not secret:
|
||||
return False
|
||||
import pyotp
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.verify(raw, valid_window=1)
|
||||
|
||||
|
||||
def current_totp_code(user_id: int, db_path: str = None) -> str:
|
||||
"""Compute the current live TOTP code for a user's secret (admin helper)."""
|
||||
db_path = db_path or current_app.config.get("DB_PATH", "data.db")
|
||||
cfg = get_auth_config(db_path, user_id)
|
||||
secret = cfg.get("totp_secret", "")
|
||||
if not secret:
|
||||
return ""
|
||||
import pyotp
|
||||
return pyotp.TOTP(secret).now()
|
||||
|
||||
|
||||
def current_temp_totp_code(user_id: int, db_path: str = None) -> str:
|
||||
"""Compute the current live temp-TOTP code for a temp account (admin helper)."""
|
||||
db_path = db_path or current_app.config.get("DB_PATH", "data.db")
|
||||
from backend.models import get_temp_totp_secret
|
||||
secret = get_temp_totp_secret(user_id, db_path)
|
||||
if not secret:
|
||||
return ""
|
||||
import pyotp
|
||||
return pyotp.TOTP(secret).now()
|
||||
|
||||
|
||||
# ─── Recovery Codes ──────────────────────────
|
||||
|
||||
def generate_recovery_codes(count: int = 8) -> list:
|
||||
"""Generate N random alphanumeric recovery codes, each 10 chars."""
|
||||
alphabet = string.ascii_uppercase + string.digits
|
||||
codes = []
|
||||
for _ in range(count):
|
||||
code = "".join(secrets.choice(alphabet) for _ in range(10))
|
||||
formatted = f"{code[:4]}-{code[4:]}"
|
||||
codes.append(formatted)
|
||||
return codes
|
||||
|
||||
|
||||
def verify_recovery_code(raw: str, db_path: str = None) -> int | None:
|
||||
"""Verify and consume a recovery code. Returns the user id on success, else None."""
|
||||
if not raw or not isinstance(raw, str):
|
||||
return None
|
||||
normalized = raw.replace("-", "").upper()
|
||||
if len(normalized) != 10:
|
||||
return None
|
||||
|
||||
db_path = db_path or current_app.config.get("DB_PATH", "data.db")
|
||||
conn = get_db(db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT u.id AS user_id, a.recovery_codes FROM users u "
|
||||
"LEFT JOIN auth_config a ON a.user_id = u.id "
|
||||
"WHERE u.status='active'"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
raw_codes = r["recovery_codes"] or ""
|
||||
if not raw_codes:
|
||||
continue
|
||||
codes = raw_codes.split(",")
|
||||
for i, stored in enumerate(codes):
|
||||
stored_norm = stored.replace("-", "").upper()
|
||||
if secrets.compare_digest(normalized, stored_norm):
|
||||
codes.pop(i)
|
||||
conn.execute(
|
||||
"UPDATE auth_config SET recovery_codes=? WHERE user_id=?",
|
||||
(",".join(codes), r["user_id"])
|
||||
)
|
||||
conn.commit()
|
||||
return r["user_id"]
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,471 @@
|
||||
"""Calendar management: ICS upload, subscription, groups, reminders.
|
||||
|
||||
Every function is scoped to an explicit `user_id` so each user only ever
|
||||
sees/mutates their own data (defense in depth: rows are filtered by user_id
|
||||
even though they also join user-scoped groups/sources).
|
||||
"""
|
||||
import os
|
||||
import hashlib
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, date
|
||||
from backend.time_util import now as tz_now
|
||||
from flask import current_app
|
||||
from backend.models import get_db
|
||||
|
||||
|
||||
def _uid() -> int:
|
||||
"""Resolve the current user for scheduler-thread calls."""
|
||||
try:
|
||||
from backend.auth import current_user_id
|
||||
return current_user_id() or 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _require_group_owner(db, user_id: int, group_id: int):
|
||||
"""Return the group row only if it belongs to the user (else None).
|
||||
Prevents cross-user writes through unvalidated group ids."""
|
||||
return db.execute(
|
||||
"SELECT id FROM calendar_groups WHERE id=? AND user_id=?",
|
||||
(group_id, user_id)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def get_groups(user_id: int = None):
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
groups = [dict(r) for r in db.execute(
|
||||
"SELECT * FROM calendar_groups WHERE user_id=? ORDER BY id", (user_id,)
|
||||
).fetchall()]
|
||||
for g in groups:
|
||||
g["sources"] = [dict(r) for r in db.execute(
|
||||
"SELECT id, type, name, url, last_sync FROM calendar_sources "
|
||||
"WHERE group_id=? AND user_id=?", (g["id"], user_id)
|
||||
).fetchall()]
|
||||
db.close()
|
||||
return groups
|
||||
|
||||
|
||||
def create_group(name: str, color: str = "#4A90D9", user_id: int = None) -> dict:
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
cur = db.execute(
|
||||
"INSERT INTO calendar_groups (user_id, name, color) VALUES (?, ?, ?)",
|
||||
(user_id, name, color)
|
||||
)
|
||||
db.commit()
|
||||
gid = cur.lastrowid
|
||||
row = dict(db.execute("SELECT * FROM calendar_groups WHERE id=?", (gid,)).fetchone())
|
||||
db.close()
|
||||
return row
|
||||
|
||||
|
||||
def update_group(group_id: int, name: str = None, color: str = None,
|
||||
enabled: bool = None, user_id: int = None):
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
fields = []
|
||||
vals = []
|
||||
if name is not None:
|
||||
fields.append("name=?")
|
||||
vals.append(name)
|
||||
if color is not None:
|
||||
fields.append("color=?")
|
||||
vals.append(color)
|
||||
if enabled is not None:
|
||||
fields.append("enabled=?")
|
||||
vals.append(1 if enabled else 0)
|
||||
ok = False
|
||||
if fields:
|
||||
vals.append(user_id)
|
||||
vals.append(group_id)
|
||||
cur = db.execute(f"UPDATE calendar_groups SET {', '.join(fields)} WHERE user_id=? AND id=?", vals)
|
||||
db.commit()
|
||||
ok = cur.rowcount > 0
|
||||
db.close()
|
||||
return ok
|
||||
|
||||
|
||||
def delete_group(group_id: int, user_id: int = None) -> bool:
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
db.execute("DELETE FROM calendar_events WHERE user_id=? AND source_id IN "
|
||||
"(SELECT id FROM calendar_sources WHERE user_id=? AND group_id=?)",
|
||||
(user_id, user_id, group_id))
|
||||
db.execute("DELETE FROM calendar_sources WHERE user_id=? AND group_id=?", (user_id, group_id))
|
||||
db.execute("DELETE FROM reminders WHERE user_id=? AND calendar_group_id=?", (user_id, group_id))
|
||||
cur = db.execute("DELETE FROM calendar_groups WHERE user_id=? AND id=?", (user_id, group_id))
|
||||
db.commit()
|
||||
db.close()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def upload_ics(group_id: int, name: str, ics_content: str, user_id: int = None):
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
if not _require_group_owner(db, user_id, group_id):
|
||||
db.close()
|
||||
raise ValueError("分组不存在或无权访问")
|
||||
cur = db.execute(
|
||||
"INSERT INTO calendar_sources (user_id, group_id, type, name, ics_data) "
|
||||
"VALUES (?, ?, 'upload', ?, ?)",
|
||||
(user_id, group_id, name, ics_content)
|
||||
)
|
||||
source_id = cur.lastrowid
|
||||
db.commit()
|
||||
_parse_and_store_events(db, user_id, source_id, ics_content)
|
||||
db.close()
|
||||
current_app.event_bus.publish("calendar_updated", {"group_id": group_id}, user_id=user_id)
|
||||
|
||||
|
||||
def subscribe_ics(group_id: int, name: str, url: str, user_id: int = None):
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
from backend.url_util import is_safe_public_url
|
||||
if not is_safe_public_url(url):
|
||||
raise ValueError("订阅地址无效或不安全(仅允许公网 http/https 地址)")
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
if not _require_group_owner(db, user_id, group_id):
|
||||
db.close()
|
||||
raise ValueError("分组不存在或无权访问")
|
||||
cur = db.execute(
|
||||
"INSERT INTO calendar_sources (user_id, group_id, type, name, url) "
|
||||
"VALUES (?, ?, 'subscription', ?, ?)",
|
||||
(user_id, group_id, name, url)
|
||||
)
|
||||
source_id = cur.lastrowid
|
||||
db.commit()
|
||||
db.close()
|
||||
sync_subscription(source_id, user_id)
|
||||
|
||||
|
||||
def sync_subscription(source_id: int, user_id: int = None):
|
||||
"""Pull and parse a remote ICS subscription."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
row = db.execute("SELECT * FROM calendar_sources WHERE id=? AND user_id=?",
|
||||
(source_id, user_id)).fetchone()
|
||||
if not row or row["type"] != "subscription" or not row["url"]:
|
||||
db.close()
|
||||
return
|
||||
try:
|
||||
from backend.url_util import safe_http_get
|
||||
resp = safe_http_get(row["url"], timeout=15)
|
||||
if resp is None:
|
||||
current_app.logger.warning(f"Sync skipped: unsafe URL for source {source_id}")
|
||||
return
|
||||
ics_data = resp.text
|
||||
# Clear old events for this source
|
||||
db.execute("DELETE FROM calendar_events WHERE user_id=? AND source_id=?",
|
||||
(user_id, source_id))
|
||||
_parse_and_store_events(db, user_id, source_id, ics_data)
|
||||
db.execute("UPDATE calendar_sources SET last_sync=datetime('now') WHERE id=? AND user_id=?",
|
||||
(source_id, user_id))
|
||||
db.commit()
|
||||
current_app.event_bus.publish("calendar_updated", {"group_id": row["group_id"]}, user_id=user_id)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Sync failed for source {source_id}: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def sync_all_subscriptions(user_id: int = None):
|
||||
"""Sync all subscription sources for one user (or all users when user_id is None)."""
|
||||
if user_id is None:
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
rows = db.execute("SELECT user_id, id FROM calendar_sources WHERE type='subscription' AND url IS NOT NULL").fetchall()
|
||||
db.close()
|
||||
for r in rows:
|
||||
sync_subscription(r["id"], r["user_id"])
|
||||
return
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
sources = db.execute(
|
||||
"SELECT id FROM calendar_sources WHERE user_id=? AND type='subscription' AND url IS NOT NULL",
|
||||
(user_id,)
|
||||
).fetchall()
|
||||
db.close()
|
||||
for s in sources:
|
||||
sync_subscription(s["id"], user_id)
|
||||
|
||||
|
||||
def delete_source(source_id: int, user_id: int = None) -> bool:
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
db.execute("DELETE FROM calendar_events WHERE user_id=? AND source_id=?", (user_id, source_id))
|
||||
cur = db.execute("DELETE FROM calendar_sources WHERE user_id=? AND id=?", (user_id, source_id))
|
||||
db.commit()
|
||||
db.close()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def get_events(group_ids: list = None, days: int = 7, user_id: int = None):
|
||||
"""Get events for enabled groups, limited to N days ahead."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
now = tz_now().strftime("%Y%m%dT000000")
|
||||
end = (tz_now() + timedelta(days=days)).strftime("%Y%m%dT235959")
|
||||
|
||||
if group_ids:
|
||||
placeholders = ','.join('?' * len(group_ids))
|
||||
rows = db.execute(f"""
|
||||
SELECT e.*, s.group_id FROM calendar_events e
|
||||
JOIN calendar_sources s ON e.source_id = s.id
|
||||
JOIN calendar_groups g ON s.group_id = g.id
|
||||
WHERE e.user_id=? AND g.user_id=? AND g.enabled=1
|
||||
AND s.group_id IN ({placeholders})
|
||||
AND e.dtend >= ? AND e.dtstart <= ?
|
||||
ORDER BY e.dtstart
|
||||
""", (user_id, user_id, *group_ids, now, end)).fetchall()
|
||||
else:
|
||||
rows = db.execute("""
|
||||
SELECT e.*, s.group_id FROM calendar_events e
|
||||
JOIN calendar_sources s ON e.source_id = s.id
|
||||
JOIN calendar_groups g ON s.group_id = g.id
|
||||
WHERE e.user_id=? AND g.user_id=? AND g.enabled=1
|
||||
AND e.dtend >= ? AND e.dtstart <= ?
|
||||
ORDER BY e.dtstart
|
||||
""", (user_id, user_id, now, end)).fetchall()
|
||||
|
||||
db.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_upcoming_reminders(user_id: int = None):
|
||||
"""Check for calendar events whose reminders are due."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
now = tz_now()
|
||||
rows = db.execute("""
|
||||
SELECT e.*, r.minutes_before, g.name as group_name FROM calendar_events e
|
||||
JOIN calendar_sources s ON e.source_id = s.id
|
||||
JOIN calendar_groups g ON s.group_id = g.id
|
||||
JOIN reminders r ON r.calendar_group_id = g.id
|
||||
WHERE e.user_id=? AND g.user_id=? AND g.enabled=1 AND r.enabled=1
|
||||
""", (user_id, user_id)).fetchall()
|
||||
db.close()
|
||||
|
||||
due = []
|
||||
for r in rows:
|
||||
try:
|
||||
dt = datetime.strptime(r["dtstart"], "%Y%m%dT%H%M%S")
|
||||
remind_at = dt - timedelta(minutes=r["minutes_before"])
|
||||
if remind_at <= now < dt:
|
||||
due.append(dict(r))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return due
|
||||
|
||||
|
||||
def set_reminder(group_id: int, minutes_before: int, enabled: bool = True, user_id: int = None):
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
if not _require_group_owner(db, user_id, group_id):
|
||||
db.close()
|
||||
raise ValueError("分组不存在或无权访问")
|
||||
existing = db.execute("SELECT id FROM reminders WHERE user_id=? AND calendar_group_id=?",
|
||||
(user_id, group_id)).fetchone()
|
||||
if existing:
|
||||
db.execute("UPDATE reminders SET minutes_before=?, enabled=? WHERE user_id=? AND calendar_group_id=?",
|
||||
(minutes_before, 1 if enabled else 0, user_id, group_id))
|
||||
else:
|
||||
db.execute("INSERT INTO reminders (user_id, calendar_group_id, minutes_before, enabled) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(user_id, group_id, minutes_before, 1 if enabled else 0))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def get_or_create_manual_source(group_id: int, user_id: int = None) -> int:
|
||||
"""Get or create a 'manual' source for the group."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
row = db.execute(
|
||||
"SELECT id FROM calendar_sources WHERE user_id=? AND group_id=? AND type='manual'",
|
||||
(user_id, group_id)
|
||||
).fetchone()
|
||||
if row:
|
||||
db.close()
|
||||
return row["id"]
|
||||
cur = db.execute(
|
||||
"INSERT INTO calendar_sources (user_id, group_id, type, name) VALUES (?, ?, 'manual', '手动添加')",
|
||||
(user_id, group_id)
|
||||
)
|
||||
db.commit()
|
||||
sid = cur.lastrowid
|
||||
db.close()
|
||||
return sid
|
||||
|
||||
|
||||
def _normalize_dt(dt_str: str) -> str:
|
||||
"""Ensure date string has the 'T' separator if it looks like YYYYMMDDHHMMSS."""
|
||||
if not dt_str or 'T' in dt_str:
|
||||
return dt_str
|
||||
if len(dt_str) >= 14:
|
||||
return dt_str[:8] + 'T' + dt_str[8:]
|
||||
return dt_str
|
||||
|
||||
|
||||
def add_event(group_id: int, summary: str, dtstart: str, dtend: str = None,
|
||||
description: str = "", location: str = "", is_all_day: bool = False,
|
||||
user_id: int = None) -> dict:
|
||||
"""Manually add a single event to a group."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
dtstart = _normalize_dt(dtstart)
|
||||
dtend = _normalize_dt(dtend)
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
if not _require_group_owner(db, user_id, group_id):
|
||||
db.close()
|
||||
raise ValueError("分组不存在或无权访问")
|
||||
source_id = get_or_create_manual_source(group_id, user_id)
|
||||
if dtend is None:
|
||||
dtend = dtstart
|
||||
cur = db.execute("""
|
||||
INSERT INTO calendar_events (user_id, source_id, summary, dtstart, dtend, description, location, is_all_day)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (user_id, source_id, summary, dtstart, dtend, description, location, 1 if is_all_day else 0))
|
||||
db.commit()
|
||||
eid = cur.lastrowid
|
||||
row = dict(db.execute("SELECT * FROM calendar_events WHERE id=?", (eid,)).fetchone())
|
||||
db.close()
|
||||
current_app.event_bus.publish("calendar_updated", {"group_id": group_id}, user_id=user_id)
|
||||
return row
|
||||
|
||||
|
||||
def get_events_by_group(group_id: int, days: int = 30, user_id: int = None) -> list:
|
||||
"""Get all events for a specific group."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
now = tz_now().strftime("%Y%m%dT000000")
|
||||
end = (tz_now() + timedelta(days=days)).strftime("%Y%m%dT235959")
|
||||
rows = db.execute("""
|
||||
SELECT e.*, s.group_id FROM calendar_events e
|
||||
JOIN calendar_sources s ON e.source_id = s.id
|
||||
WHERE e.user_id=? AND s.user_id=? AND s.group_id=? AND e.dtend >= ? AND e.dtstart <= ?
|
||||
ORDER BY e.dtstart
|
||||
""", (user_id, user_id, group_id, now, end)).fetchall()
|
||||
db.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def update_event(event_id: int, summary: str = None, dtstart: str = None,
|
||||
dtend: str = None, description: str = None, location: str = None,
|
||||
user_id: int = None) -> bool:
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
dtstart = _normalize_dt(dtstart)
|
||||
dtend = _normalize_dt(dtend)
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
fields = []
|
||||
vals = []
|
||||
for key, val in [("summary", summary), ("dtstart", dtstart), ("dtend", dtend),
|
||||
("description", description), ("location", location)]:
|
||||
if val is not None:
|
||||
fields.append(f"{key}=?")
|
||||
vals.append(val)
|
||||
if not fields:
|
||||
db.close()
|
||||
return False
|
||||
vals.append(user_id)
|
||||
vals.append(event_id)
|
||||
cur = db.execute(f"UPDATE calendar_events SET {', '.join(fields)} WHERE user_id=? AND id=?", vals)
|
||||
db.commit()
|
||||
ok = cur.rowcount > 0
|
||||
# Get group_id for event broadcast
|
||||
row = db.execute("""
|
||||
SELECT s.group_id FROM calendar_events e
|
||||
JOIN calendar_sources s ON e.source_id = s.id
|
||||
WHERE e.user_id=? AND e.id=?
|
||||
""", (user_id, event_id)).fetchone()
|
||||
db.close()
|
||||
if ok and row:
|
||||
current_app.event_bus.publish("calendar_updated", {"group_id": row["group_id"]}, user_id=user_id)
|
||||
return ok
|
||||
|
||||
|
||||
def delete_event(event_id: int, user_id: int = None) -> bool:
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
row = db.execute("""
|
||||
SELECT s.group_id FROM calendar_events e
|
||||
JOIN calendar_sources s ON e.source_id = s.id
|
||||
WHERE e.user_id=? AND e.id=?
|
||||
""", (user_id, event_id)).fetchone()
|
||||
cur = db.execute("DELETE FROM calendar_events WHERE user_id=? AND id=?", (user_id, event_id))
|
||||
db.commit()
|
||||
db.close()
|
||||
if cur.rowcount > 0 and row:
|
||||
current_app.event_bus.publish("calendar_updated", {"group_id": row["group_id"]}, user_id=user_id)
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def _parse_and_store_events(db, user_id: int, source_id: int, ics_data: str):
|
||||
"""Parse ICS content and store events in DB, expanding recurring events."""
|
||||
from icalendar import Calendar
|
||||
import recurring_ical_events
|
||||
try:
|
||||
cal = Calendar.from_ical(ics_data)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
# Determine the expansion window: from start of today up to 365 days ahead
|
||||
today = tz_now().date()
|
||||
window_start = datetime(today.year, today.month, today.day)
|
||||
window_end = window_start + timedelta(days=365)
|
||||
|
||||
try:
|
||||
expanded = recurring_ical_events.of(cal).between(window_start, window_end)
|
||||
except Exception:
|
||||
expanded = []
|
||||
|
||||
for comp in expanded:
|
||||
if comp.name != "VEVENT":
|
||||
continue
|
||||
uid = str(comp.get("uid", ""))
|
||||
summary = str(comp.get("summary", ""))
|
||||
description = str(comp.get("description", ""))
|
||||
location = str(comp.get("location", ""))
|
||||
|
||||
dtstart = comp.get("dtstart")
|
||||
dtend = comp.get("dtend")
|
||||
|
||||
if dtstart is None:
|
||||
continue
|
||||
|
||||
dtstart_str = _format_dt(dtstart.dt)
|
||||
if dtend:
|
||||
dtend_str = _format_dt(dtend.dt)
|
||||
else:
|
||||
dtend_str = dtstart_str
|
||||
|
||||
is_all_day = 1 if (hasattr(dtstart.dt, 'date') and not hasattr(dtstart.dt, 'time')) else 0
|
||||
|
||||
db.execute("""
|
||||
INSERT INTO calendar_events (user_id, source_id, uid, summary, description, location, dtstart, dtend, is_all_day)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (user_id, source_id, uid, summary, description, location, dtstart_str, dtend_str, is_all_day))
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
def _format_dt(dt_val) -> str:
|
||||
"""Format a datetime/date to the 'YYYYMMDDTHHMMSS' string."""
|
||||
from datetime import date, datetime as dt_cls
|
||||
if isinstance(dt_val, dt_cls):
|
||||
return dt_val.strftime("%Y%m%dT%H%M%S")
|
||||
elif isinstance(dt_val, date):
|
||||
return dt_val.strftime("%Y%m%dT000000")
|
||||
return str(dt_val)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Countdown timer management — duration-based, once-only."""
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from backend.time_util import isoformat as tz_isoformat
|
||||
from flask import current_app
|
||||
from backend.models import get_db
|
||||
|
||||
|
||||
def _uid() -> int:
|
||||
"""Resolve the current user (request session or scheduler thread)."""
|
||||
try:
|
||||
from backend.auth import current_user_id
|
||||
return current_user_id() or 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def get_countdowns(user_id: int = None) -> list:
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
rows = [dict(r) for r in db.execute(
|
||||
"SELECT * FROM countdowns WHERE user_id=? ORDER BY id DESC", (user_id,)
|
||||
).fetchall()]
|
||||
db.close()
|
||||
return rows
|
||||
|
||||
|
||||
def add_countdown(label: str, duration_seconds: int,
|
||||
enabled: bool = True, sound: str = "default", user_id: int = None) -> dict:
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
end_ts = str(time.time() + duration_seconds)
|
||||
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
cur = db.execute(
|
||||
"INSERT INTO countdowns (user_id, label, duration_seconds, end_time, enabled, sound) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(user_id, label, duration_seconds, end_ts, 1 if enabled else 0, sound)
|
||||
)
|
||||
db.commit()
|
||||
row = dict(db.execute("SELECT * FROM countdowns WHERE id=?", (cur.lastrowid,)).fetchone())
|
||||
db.close()
|
||||
return row
|
||||
|
||||
|
||||
def update_countdown(cd_id: int, label: str = None, duration_seconds: int = None,
|
||||
enabled: bool = None, sound: str = None, user_id: int = None):
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
fields = []
|
||||
vals = []
|
||||
|
||||
if duration_seconds is not None:
|
||||
fields.append("duration_seconds=?")
|
||||
vals.append(duration_seconds)
|
||||
fields.append("end_time=?")
|
||||
vals.append(str(time.time() + duration_seconds))
|
||||
|
||||
for key, val in [("label", label),
|
||||
("enabled", 1 if enabled else 0 if enabled is not None else None),
|
||||
("sound", sound)]:
|
||||
if val is not None:
|
||||
fields.append(f"{key}=?")
|
||||
vals.append(val)
|
||||
|
||||
ok = False
|
||||
if fields:
|
||||
vals.append(user_id)
|
||||
vals.append(cd_id)
|
||||
cur = db.execute(f"UPDATE countdowns SET {', '.join(fields)} WHERE user_id=? AND id=?", vals)
|
||||
db.commit()
|
||||
ok = cur.rowcount > 0
|
||||
db.close()
|
||||
return ok
|
||||
|
||||
|
||||
def delete_countdown(cd_id: int, user_id: int = None) -> bool:
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
cur = db.execute("DELETE FROM countdowns WHERE user_id=? AND id=?", (user_id, cd_id))
|
||||
db.commit()
|
||||
db.close()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def check_countdowns(user_id: int = None) -> list:
|
||||
"""Check for countdowns whose end_time has passed. Auto-disable after trigger."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
now_ts = str(time.time())
|
||||
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
rows = db.execute(
|
||||
"SELECT * FROM countdowns WHERE user_id=? AND enabled=1 AND end_time <= ? AND end_time != ''",
|
||||
(user_id, now_ts)
|
||||
).fetchall()
|
||||
|
||||
triggered = []
|
||||
for r in rows:
|
||||
cd_id = r["id"]
|
||||
triggered.append(dict(r))
|
||||
db.execute("UPDATE countdowns SET enabled=0 WHERE user_id=? AND id=?", (user_id, cd_id))
|
||||
db.commit()
|
||||
|
||||
db.close()
|
||||
|
||||
if triggered:
|
||||
current_app.logger.info(
|
||||
f"Countdowns triggered: {[c['label'] for c in triggered]}"
|
||||
)
|
||||
current_app.event_bus.publish("countdown_triggered", {
|
||||
"countdowns": triggered,
|
||||
"ts": tz_isoformat(),
|
||||
}, user_id=user_id)
|
||||
|
||||
return triggered
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Server-Sent Event bus for pushing updates to displays.
|
||||
|
||||
Events are scoped by user_id so that each user's display only receives
|
||||
updates about their own data (alarms, calendar, pomodoro, ...).
|
||||
"""
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""Simple pub/sub event system with SSE support.
|
||||
|
||||
publish(event_type, data, user_id=None): when user_id is given, only
|
||||
subscribers registered for that user receive the event.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._subscribers: list[tuple[int | None, queue.Queue]] = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def subscribe(self, user_id: int | None = None) -> queue.Queue:
|
||||
"""Subscribe to events. user_id=None receives ALL events (broadcast)."""
|
||||
q: queue.Queue = queue.Queue()
|
||||
with self._lock:
|
||||
self._subscribers.append((user_id, q))
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: queue.Queue):
|
||||
with self._lock:
|
||||
self._subscribers = [(uid, sq) for uid, sq in self._subscribers if sq is not q]
|
||||
|
||||
def publish(self, event_type: str, data: dict, user_id: int | None = None):
|
||||
msg = json.dumps({"type": event_type, "data": data})
|
||||
with self._lock:
|
||||
dead = []
|
||||
for sub_uid, q in self._subscribers:
|
||||
if sub_uid is not None and user_id is not None and sub_uid != user_id:
|
||||
continue
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except queue.Full:
|
||||
dead.append((sub_uid, q))
|
||||
for item in dead:
|
||||
self._subscribers.remove(item)
|
||||
|
||||
def broadcast(self):
|
||||
"""Send heartbeat to all subscribers."""
|
||||
self.publish("heartbeat", {"ts": __import__("time").time()})
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Ed25519 key pair generation and JWT signing for QWeather API."""
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
import jwt
|
||||
import time
|
||||
|
||||
|
||||
def generate_key_pair() -> tuple:
|
||||
"""Generate an Ed25519 key pair.
|
||||
|
||||
Returns:
|
||||
(private_key_pem: str, public_key_pem: str)
|
||||
"""
|
||||
private_key = ed25519.Ed25519PrivateKey.generate()
|
||||
public_key = private_key.public_key()
|
||||
|
||||
private_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode("utf-8")
|
||||
|
||||
public_pem = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode("utf-8")
|
||||
|
||||
return private_pem, public_pem
|
||||
|
||||
|
||||
def sign_jwt(private_key_pem: str, credential_id: str, project_id: str,
|
||||
expiry_seconds: int = 3600) -> str:
|
||||
"""Sign a JWT for QWeather API authentication.
|
||||
|
||||
Args:
|
||||
private_key_pem: Ed25519 private key in PEM format.
|
||||
credential_id: QWeather credential ID (kid).
|
||||
project_id: QWeather project ID (sub).
|
||||
expiry_seconds: Token validity in seconds (max 86400 per QWeather).
|
||||
|
||||
Returns:
|
||||
Signed JWT string.
|
||||
"""
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"sub": project_id,
|
||||
"iat": now,
|
||||
"exp": now + min(expiry_seconds, 86400),
|
||||
}
|
||||
headers = {
|
||||
"alg": "EdDSA",
|
||||
"kid": credential_id,
|
||||
}
|
||||
token = jwt.encode(payload, private_key_pem, algorithm="EdDSA", headers=headers)
|
||||
return token
|
||||
@@ -0,0 +1,774 @@
|
||||
"""SQLite database models, initialization, and user-scoped helpers.
|
||||
|
||||
Multi-user design:
|
||||
- The `users` table stores every account. Root (ID 0) is the administrator and
|
||||
is created automatically on first boot (fresh DB) or when migrating an
|
||||
existing single-user database (all pre-existing data becomes Root's data).
|
||||
- Every per-user table carries a `user_id INTEGER NOT NULL DEFAULT 0` column,
|
||||
so legacy single-user rows are automatically attributed to Root on migration
|
||||
and any NEW column added in the future gets a DEFAULT (the migration fills
|
||||
missing columns with their SCHEMA defaults).
|
||||
- `auth_config` is keyed by `user_id` (legacy `id=1` row maps to user 0).
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
remark TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','user')),
|
||||
is_temp INTEGER NOT NULL DEFAULT 0,
|
||||
temp_totp_secret TEXT DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','pending')),
|
||||
session_version INTEGER NOT NULL DEFAULT 0,
|
||||
theme TEXT NOT NULL DEFAULT 'dark' CHECK(theme IN ('dark','light','auto')),
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_config (
|
||||
user_id INTEGER PRIMARY KEY DEFAULT 0,
|
||||
passkey_credential_id TEXT DEFAULT '',
|
||||
passkey_public_key TEXT DEFAULT '',
|
||||
totp_secret TEXT DEFAULT '',
|
||||
totp_enabled INTEGER DEFAULT 0,
|
||||
recovery_codes TEXT DEFAULT '',
|
||||
temp_password TEXT DEFAULT '',
|
||||
must_configure_mfa INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT DEFAULT '#4A90D9',
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
group_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('upload', 'subscription', 'manual')),
|
||||
name TEXT NOT NULL,
|
||||
url TEXT,
|
||||
ics_data TEXT,
|
||||
last_sync TEXT,
|
||||
FOREIGN KEY (group_id) REFERENCES calendar_groups(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
source_id INTEGER NOT NULL,
|
||||
uid TEXT,
|
||||
summary TEXT,
|
||||
description TEXT,
|
||||
location TEXT,
|
||||
dtstart TEXT,
|
||||
dtend TEXT,
|
||||
is_all_day INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (source_id) REFERENCES calendar_sources(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reminders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
calendar_group_id INTEGER NOT NULL,
|
||||
minutes_before INTEGER DEFAULT 15,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
FOREIGN KEY (calendar_group_id) REFERENCES calendar_groups(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS weather_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
api_key TEXT DEFAULT '',
|
||||
city TEXT DEFAULT 'Beijing',
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
units TEXT DEFAULT 'metric',
|
||||
update_interval INTEGER DEFAULT 1800,
|
||||
jwt_private_key TEXT DEFAULT '',
|
||||
jwt_public_key TEXT DEFAULT '',
|
||||
credential_id TEXT DEFAULT '',
|
||||
project_id TEXT DEFAULT '',
|
||||
qweather_host TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS weather_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
data TEXT,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS weather_hourly_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
data TEXT,
|
||||
fetched_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS weather_minutely_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
data TEXT,
|
||||
fetched_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS weather_warning_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
data TEXT,
|
||||
fetched_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clock_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
city TEXT DEFAULT 'Beijing',
|
||||
timezone TEXT DEFAULT 'Asia/Shanghai',
|
||||
is_primary INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alarms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
label TEXT DEFAULT '',
|
||||
time TEXT NOT NULL,
|
||||
days TEXT DEFAULT '1,2,3,4,5,6,7',
|
||||
enabled INTEGER DEFAULT 1,
|
||||
sound TEXT DEFAULT 'default',
|
||||
once INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS countdowns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
label TEXT DEFAULT '',
|
||||
duration_seconds INTEGER DEFAULT 300,
|
||||
end_time TEXT DEFAULT '',
|
||||
enabled INTEGER DEFAULT 1,
|
||||
sound TEXT DEFAULT 'default'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS display_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
mode TEXT NOT NULL DEFAULT 'default' CHECK(mode IN ('default', 'custom')),
|
||||
custom_html TEXT DEFAULT '',
|
||||
custom_html_1 TEXT DEFAULT '',
|
||||
custom_html_2 TEXT DEFAULT '',
|
||||
custom_html_3 TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pomodoro_state (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
running INTEGER DEFAULT 0,
|
||||
phase TEXT DEFAULT 'work' CHECK(phase IN ('work', 'break')),
|
||||
work_minutes INTEGER DEFAULT 25,
|
||||
break_minutes INTEGER DEFAULT 5,
|
||||
long_break_minutes INTEGER DEFAULT 15,
|
||||
cycles_before_long_break INTEGER DEFAULT 4,
|
||||
end_time TEXT,
|
||||
remaining_seconds INTEGER DEFAULT 1500,
|
||||
cycles INTEGER DEFAULT 0,
|
||||
enabled INTEGER DEFAULT 0,
|
||||
display_controls_enabled INTEGER DEFAULT 1,
|
||||
cycles_date TEXT DEFAULT '',
|
||||
work_label TEXT DEFAULT '工作中',
|
||||
break_label TEXT DEFAULT '休息中'
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_pomodoro_state_user ON pomodoro_state(user_id);
|
||||
"""
|
||||
|
||||
|
||||
# Tables whose rows are completely per-user (used for cascade delete).
|
||||
USER_DATA_TABLES = [
|
||||
"auth_config", "calendar_groups", "calendar_sources", "calendar_events",
|
||||
"reminders", "weather_config", "weather_cache", "weather_hourly_cache",
|
||||
"weather_minutely_cache", "weather_warning_cache", "clock_config",
|
||||
"alarms", "countdowns", "display_config", "pomodoro_state",
|
||||
]
|
||||
|
||||
|
||||
def get_db(db_path: str = "data.db") -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
|
||||
def _db_path(preferred: str = None) -> str:
|
||||
"""Get the actual database path, preferring current_app config if available."""
|
||||
if preferred:
|
||||
return preferred
|
||||
try:
|
||||
from flask import current_app
|
||||
return current_app.config.get("DB_PATH", "data.db")
|
||||
except Exception:
|
||||
return "data.db"
|
||||
|
||||
|
||||
# ─── Users ───────────────────────────────────
|
||||
|
||||
def get_users(db_path: str = None) -> list:
|
||||
"""Return all users ordered by id."""
|
||||
conn = get_db(_db_path(db_path))
|
||||
rows = [dict(r) for r in conn.execute("SELECT * FROM users ORDER BY id").fetchall()]
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
|
||||
def get_active_user_ids(db_path: str = None) -> list:
|
||||
"""Return ids of all active (non-pending) users. Used by the scheduler."""
|
||||
conn = get_db(_db_path(db_path))
|
||||
rows = conn.execute("SELECT id FROM users WHERE status='active' ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
return [r["id"] for r in rows]
|
||||
|
||||
|
||||
def get_user(user_id: int, db_path: str = None) -> dict | None:
|
||||
conn = get_db(_db_path(db_path))
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def user_exists(user_id: int, db_path: str = None) -> bool:
|
||||
conn = get_db(_db_path(db_path))
|
||||
row = conn.execute("SELECT 1 FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
conn.close()
|
||||
return row is not None
|
||||
|
||||
|
||||
def is_admin_user(user_id: int, db_path: str = None) -> bool:
|
||||
"""Root is always admin; otherwise check the role column."""
|
||||
if user_id == 0:
|
||||
return True
|
||||
conn = get_db(_db_path(db_path))
|
||||
row = conn.execute("SELECT role FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
conn.close()
|
||||
return bool(row and row["role"] == "admin")
|
||||
|
||||
|
||||
def create_user(remark: str = "", role: str = "user", is_temp: int = 0,
|
||||
temp_totp_secret: str = "", status: str = "active",
|
||||
db_path: str = None) -> int:
|
||||
"""Create a user. Returns the new user id.
|
||||
The remark defaults to the user's own id (the only place an id is shown).
|
||||
"""
|
||||
conn = get_db(_db_path(db_path))
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (remark, role, is_temp, temp_totp_secret, status) VALUES (?, ?, ?, ?, ?)",
|
||||
(remark, role, 1 if is_temp else 0, temp_totp_secret, status)
|
||||
)
|
||||
uid = cur.lastrowid
|
||||
if not remark:
|
||||
conn.execute("UPDATE users SET remark=? WHERE id=?", (str(uid), uid))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return uid
|
||||
|
||||
|
||||
def update_user(user_id: int, remark: str = None, role: str = None,
|
||||
is_temp: int = None, temp_totp_secret: str = None,
|
||||
status: str = None, theme: str = None, db_path: str = None):
|
||||
conn = get_db(_db_path(db_path))
|
||||
fields = []
|
||||
vals = []
|
||||
for key, val in [("remark", remark), ("role", role), ("is_temp", is_temp),
|
||||
("temp_totp_secret", temp_totp_secret), ("status", status),
|
||||
("theme", theme)]:
|
||||
if val is not None:
|
||||
fields.append(f"{key}=?")
|
||||
vals.append(val)
|
||||
if fields:
|
||||
vals.append(user_id)
|
||||
conn.execute(f"UPDATE users SET {', '.join(fields)} WHERE id=?", vals)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_user(user_id: int, db_path: str = None):
|
||||
"""Delete a user and ALL of their data. Root (id 0) cannot be deleted.
|
||||
|
||||
Deletion order respects foreign keys (children before parents).
|
||||
"""
|
||||
if user_id == 0:
|
||||
return
|
||||
conn = get_db(_db_path(db_path))
|
||||
# Children first so FK constraints never reject a delete.
|
||||
ordered = [
|
||||
"calendar_events", "calendar_sources", "reminders", "calendar_groups",
|
||||
"weather_cache", "weather_hourly_cache", "weather_minutely_cache",
|
||||
"weather_warning_cache", "weather_config", "clock_config",
|
||||
"alarms", "countdowns", "display_config", "pomodoro_state", "auth_config",
|
||||
]
|
||||
try:
|
||||
for table in ordered:
|
||||
conn.execute(f"DELETE FROM {table} WHERE user_id=?", (user_id,))
|
||||
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_user_session_version(user_id: int, db_path: str = None) -> int:
|
||||
conn = get_db(_db_path(db_path))
|
||||
row = conn.execute("SELECT session_version FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
conn.close()
|
||||
return row["session_version"] if row else -1
|
||||
|
||||
|
||||
def bump_user_session_version(user_id: int, db_path: str = None) -> int:
|
||||
"""Invalidate all sessions of one user by incrementing their version."""
|
||||
conn = get_db(_db_path(db_path))
|
||||
conn.execute("UPDATE users SET session_version = session_version + 1 WHERE id=?", (user_id,))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT session_version FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
conn.close()
|
||||
return row["session_version"] if row else -1
|
||||
|
||||
|
||||
def bump_all_session_versions(db_path: str = None) -> int:
|
||||
"""Invalidate every session (admin 'kick all' action)."""
|
||||
conn = get_db(_db_path(db_path))
|
||||
conn.execute("UPDATE users SET session_version = session_version + 1")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return 0
|
||||
|
||||
|
||||
def is_temp_account(user_id: int, db_path: str = None) -> bool:
|
||||
"""True if the account is a temp account (awaiting first login-method setup)."""
|
||||
conn = get_db(_db_path(db_path))
|
||||
row = conn.execute("SELECT is_temp FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
conn.close()
|
||||
return bool(row and row["is_temp"])
|
||||
|
||||
|
||||
def get_temp_totp_secret(user_id: int, db_path: str = None) -> str:
|
||||
conn = get_db(_db_path(db_path))
|
||||
row = conn.execute("SELECT temp_totp_secret FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
conn.close()
|
||||
return (row["temp_totp_secret"] or "") if row else ""
|
||||
|
||||
|
||||
def finalize_temp_account(user_id: int, db_path: str = None):
|
||||
"""Convert a temp account into a formal account:
|
||||
invalidates the temp TOTP and clears the temp flag.
|
||||
"""
|
||||
conn = get_db(_db_path(db_path))
|
||||
conn.execute(
|
||||
"UPDATE users SET is_temp=0, temp_totp_secret='' WHERE id=?",
|
||||
(user_id,)
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE auth_config SET must_configure_mfa=0 WHERE user_id=?",
|
||||
(user_id,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Display config (per user) ──────────────
|
||||
|
||||
def _ensure_display_row(conn, user_id: int):
|
||||
row = conn.execute("SELECT id FROM display_config WHERE user_id=?", (user_id,)).fetchone()
|
||||
if row is None:
|
||||
conn.execute(
|
||||
"INSERT INTO display_config (user_id, mode) VALUES (?, 'default')",
|
||||
(user_id,)
|
||||
)
|
||||
|
||||
|
||||
def get_display_config(db_path: str = None, user_id: int = 0) -> dict:
|
||||
db = get_db(_db_path(db_path))
|
||||
row = db.execute(
|
||||
"SELECT mode, custom_html, custom_html_1, custom_html_2, custom_html_3 "
|
||||
"FROM display_config WHERE user_id=?",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
db.execute(
|
||||
"INSERT INTO display_config (user_id, mode, custom_html, custom_html_1, custom_html_2, custom_html_3) "
|
||||
"VALUES (?, 'default', '', '', '', '')",
|
||||
(user_id,)
|
||||
)
|
||||
db.commit()
|
||||
cfg = {"mode": "default", "custom_html": "", "custom_html_1": "", "custom_html_2": "", "custom_html_3": ""}
|
||||
else:
|
||||
cfg = {
|
||||
"mode": row["mode"],
|
||||
"custom_html": row["custom_html"] or "",
|
||||
"custom_html_1": row["custom_html_1"] or "",
|
||||
"custom_html_2": row["custom_html_2"] or "",
|
||||
"custom_html_3": row["custom_html_3"] or "",
|
||||
}
|
||||
db.close()
|
||||
return cfg
|
||||
|
||||
|
||||
def set_display_mode(mode: str, db_path: str = None, user_id: int = 0) -> bool:
|
||||
if mode not in ("default", "custom"):
|
||||
return False
|
||||
db = get_db(_db_path(db_path))
|
||||
_ensure_display_row(db, user_id)
|
||||
db.execute("UPDATE display_config SET mode=? WHERE user_id=?", (mode, user_id))
|
||||
db.commit()
|
||||
db.close()
|
||||
return True
|
||||
|
||||
|
||||
def set_custom_html(html: str, db_path: str = None, user_id: int = 0) -> bool:
|
||||
db = get_db(_db_path(db_path))
|
||||
_ensure_display_row(db, user_id)
|
||||
db.execute("UPDATE display_config SET custom_html=? WHERE user_id=?", (html, user_id))
|
||||
db.commit()
|
||||
db.close()
|
||||
return True
|
||||
|
||||
|
||||
def set_custom_html_template(index: int, html: str, db_path: str = None, user_id: int = 0) -> bool:
|
||||
if index not in (1, 2, 3):
|
||||
return False
|
||||
db = get_db(_db_path(db_path))
|
||||
_ensure_display_row(db, user_id)
|
||||
db.execute(
|
||||
f"UPDATE display_config SET custom_html_{index}=? WHERE user_id=?",
|
||||
(html, user_id)
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
return True
|
||||
|
||||
|
||||
def apply_custom_html(index: int, db_path: str = None, user_id: int = 0) -> bool:
|
||||
"""Copy template N (0=main custom_html, 1-3=custom_html_1-3) into custom_html and set mode to custom."""
|
||||
db = get_db(_db_path(db_path))
|
||||
row = db.execute(
|
||||
"SELECT custom_html, custom_html_1, custom_html_2, custom_html_3 "
|
||||
"FROM display_config WHERE user_id=?",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
db.close()
|
||||
return False
|
||||
if index == 0:
|
||||
html = row["custom_html"] or ""
|
||||
elif index == 1:
|
||||
html = row["custom_html_1"] or ""
|
||||
elif index == 2:
|
||||
html = row["custom_html_2"] or ""
|
||||
elif index == 3:
|
||||
html = row["custom_html_3"] or ""
|
||||
else:
|
||||
db.close()
|
||||
return False
|
||||
db.execute("UPDATE display_config SET mode='custom', custom_html=? WHERE user_id=?", (html, user_id))
|
||||
db.commit()
|
||||
db.close()
|
||||
return True
|
||||
|
||||
|
||||
# ─── Auth / MFA helpers (per user) ──────────
|
||||
|
||||
def get_auth_config(db_path: str = None, user_id: int = 0) -> dict:
|
||||
"""Return the full auth_config row for a user, with defaults for missing fields."""
|
||||
conn = get_db(_db_path(db_path))
|
||||
row = conn.execute("SELECT * FROM auth_config WHERE user_id = ?", (user_id,)).fetchone()
|
||||
if row is None:
|
||||
conn.execute(
|
||||
"""INSERT INTO auth_config (user_id, passkey_credential_id, passkey_public_key,
|
||||
totp_secret, totp_enabled, recovery_codes, temp_password, must_configure_mfa)
|
||||
VALUES (?, '', '', '', 0, '', '', 1)""",
|
||||
(user_id,)
|
||||
)
|
||||
conn.commit()
|
||||
cfg = {
|
||||
"user_id": user_id,
|
||||
"passkey_credential_id": "",
|
||||
"passkey_public_key": "",
|
||||
"totp_secret": "",
|
||||
"totp_enabled": 0,
|
||||
"recovery_codes": "",
|
||||
"temp_password": "",
|
||||
"must_configure_mfa": 1,
|
||||
}
|
||||
else:
|
||||
cfg = {
|
||||
"user_id": row["user_id"],
|
||||
"passkey_credential_id": row["passkey_credential_id"] or "",
|
||||
"passkey_public_key": row["passkey_public_key"] or "",
|
||||
"totp_secret": row["totp_secret"] or "",
|
||||
"totp_enabled": bool(row["totp_enabled"]),
|
||||
"recovery_codes": row["recovery_codes"] or "",
|
||||
"temp_password": row["temp_password"] or "",
|
||||
"must_configure_mfa": bool(row["must_configure_mfa"]),
|
||||
}
|
||||
conn.close()
|
||||
return cfg
|
||||
|
||||
|
||||
def update_auth_config(user_id: int = 0, **kwargs):
|
||||
"""Update auth_config fields. Keys match DB column names."""
|
||||
db_path = _db_path(kwargs.pop("_db_path", None))
|
||||
conn = get_db(db_path)
|
||||
row = conn.execute("SELECT user_id FROM auth_config WHERE user_id = ?", (user_id,)).fetchone()
|
||||
fields = []
|
||||
vals = []
|
||||
for key, val in kwargs.items():
|
||||
if key in ("passkey_credential_id", "passkey_public_key", "totp_secret",
|
||||
"recovery_codes", "temp_password", "must_configure_mfa", "totp_enabled"):
|
||||
fields.append(f"{key}=?")
|
||||
vals.append(val)
|
||||
if not fields:
|
||||
conn.close()
|
||||
return
|
||||
if row is None:
|
||||
cols = ["user_id"] + [f.split("=")[0] for f in fields]
|
||||
col_str = ", ".join(cols)
|
||||
ph = ", ".join("?" * len(cols))
|
||||
conn.execute(f"INSERT INTO auth_config ({col_str}) VALUES ({ph})", (user_id, *vals))
|
||||
else:
|
||||
vals.append(user_id)
|
||||
conn.execute(f"UPDATE auth_config SET {', '.join(fields)} WHERE user_id=?", vals)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_temp_password(db_path: str = None, user_id: int = 0) -> str:
|
||||
cfg = get_auth_config(db_path, user_id)
|
||||
return cfg["temp_password"]
|
||||
|
||||
|
||||
def set_temp_password(password: str, db_path: str = None, user_id: int = 0):
|
||||
update_auth_config(user_id, temp_password=password, _db_path=db_path)
|
||||
|
||||
|
||||
def clear_temp_password(db_path: str = None, user_id: int = 0):
|
||||
update_auth_config(user_id, temp_password="", must_configure_mfa=0, _db_path=db_path)
|
||||
|
||||
|
||||
def get_recovery_codes(db_path: str = None, user_id: int = 0) -> list:
|
||||
cfg = get_auth_config(db_path, user_id)
|
||||
raw = cfg["recovery_codes"]
|
||||
if not raw:
|
||||
return []
|
||||
return raw.split(",")
|
||||
|
||||
|
||||
def set_recovery_codes(codes: list, db_path: str = None, user_id: int = 0):
|
||||
update_auth_config(user_id, recovery_codes=",".join(codes), _db_path=db_path)
|
||||
|
||||
|
||||
def has_mfa_configured(db_path: str = None, user_id: int = 0) -> bool:
|
||||
cfg = get_auth_config(db_path, user_id)
|
||||
return bool(cfg["passkey_credential_id"]) or bool(cfg["totp_enabled"])
|
||||
|
||||
|
||||
def find_user_by_credential_id(credential_id: str, db_path: str = None) -> int | None:
|
||||
"""Find the user who owns a given WebAuthn credential id (for login)."""
|
||||
conn = get_db(_db_path(db_path))
|
||||
row = conn.execute(
|
||||
"SELECT a.user_id FROM auth_config a "
|
||||
"JOIN users u ON u.id = a.user_id "
|
||||
"WHERE a.passkey_credential_id = ? AND u.status = 'active'",
|
||||
(credential_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return row["user_id"] if row else None
|
||||
|
||||
|
||||
# ─── DB init / migration ─────────────────────
|
||||
|
||||
def _ensure_defaults(conn):
|
||||
"""Insert default rows for config tables and the Root user when missing.
|
||||
Generates a random 6-digit temp password when Root's auth_config is created
|
||||
(or when a migrated DB has no MFA configured yet).
|
||||
"""
|
||||
import secrets
|
||||
import string
|
||||
|
||||
# Root user (id 0)
|
||||
root = conn.execute("SELECT id FROM users WHERE id=0").fetchone()
|
||||
if root is None:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (id, remark, role, status) VALUES (0, 'Root', 'admin', 'active')"
|
||||
)
|
||||
|
||||
# weather_config for Root
|
||||
count = conn.execute("SELECT COUNT(*) FROM weather_config WHERE user_id=0").fetchone()[0]
|
||||
if count == 0:
|
||||
conn.execute("INSERT INTO weather_config (user_id, city, lat, lon) "
|
||||
"VALUES (0, 'Beijing', 39.9042, 116.4074)")
|
||||
|
||||
# clock_config for Root
|
||||
count = conn.execute("SELECT COUNT(*) FROM clock_config WHERE user_id=0").fetchone()[0]
|
||||
if count == 0:
|
||||
conn.execute("INSERT INTO clock_config (user_id, city, timezone, is_primary) "
|
||||
"VALUES (0, 'Beijing', 'Asia/Shanghai', 1)")
|
||||
|
||||
# auth_config for Root
|
||||
row = conn.execute("SELECT * FROM auth_config WHERE user_id=0").fetchone()
|
||||
mfa_configured = bool(row and (row["passkey_credential_id"] or row["totp_enabled"]))
|
||||
if row is None:
|
||||
temp_pw = "".join(secrets.choice(string.digits) for _ in range(6))
|
||||
conn.execute(
|
||||
"INSERT INTO auth_config (user_id, passkey_credential_id, passkey_public_key, "
|
||||
"totp_secret, totp_enabled, recovery_codes, temp_password, must_configure_mfa) "
|
||||
"VALUES (0, '', '', '', 0, '', ?, 1)",
|
||||
(temp_pw,)
|
||||
)
|
||||
_EMITTED_PASSWORDS.append(temp_pw)
|
||||
print(f"\n{'='*50}", file=sys.stderr)
|
||||
print(f" 🔐 Root 初始临时密码: {temp_pw}", file=sys.stderr)
|
||||
print(f" ⚠️ 请使用此密码登录 Root 并配置双因素认证", file=sys.stderr)
|
||||
print(f"{'='*50}\n", file=sys.stderr)
|
||||
else:
|
||||
# Migrated DB. If Root already has MFA, no temp password is needed.
|
||||
if mfa_configured:
|
||||
if row["temp_password"] or row["must_configure_mfa"]:
|
||||
conn.execute(
|
||||
"UPDATE auth_config SET temp_password='', must_configure_mfa=0 WHERE user_id=0"
|
||||
)
|
||||
elif (not row["temp_password"]) and row["must_configure_mfa"]:
|
||||
# Migrated DB without MFA — generate a fresh temp password.
|
||||
temp_pw = "".join(secrets.choice(string.digits) for _ in range(6))
|
||||
conn.execute(
|
||||
"UPDATE auth_config SET temp_password=? WHERE user_id=0",
|
||||
(temp_pw,)
|
||||
)
|
||||
_EMITTED_PASSWORDS.append(temp_pw)
|
||||
print(f"\n{'='*50}", file=sys.stderr)
|
||||
print(f" 🔐 迁移已存在数据库 — Root 临时密码: {temp_pw}", file=sys.stderr)
|
||||
print(f" ⚠️ 请使用此密码登录 Root 并配置双因素认证", file=sys.stderr)
|
||||
print(f"{'='*50}\n", file=sys.stderr)
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
# Stores passwords that were just generated, for the startup script to print.
|
||||
_EMITTED_PASSWORDS: list = []
|
||||
|
||||
|
||||
def _table_columns(conn, table_name):
|
||||
return [row["name"] for row in conn.execute(f"PRAGMA table_info({table_name})")]
|
||||
|
||||
|
||||
def _migrate_auth_config(conn_new, conn_old):
|
||||
"""Special-case migration for auth_config: the legacy table is keyed by
|
||||
`id` (single user, id=1), the new table is keyed by `user_id`.
|
||||
The legacy single-user row belongs to Root (user 0).
|
||||
"""
|
||||
old_cols = _table_columns(conn_old, "auth_config")
|
||||
new_cols = _table_columns(conn_new, "auth_config")
|
||||
if not old_cols:
|
||||
return
|
||||
rows = conn_old.execute("SELECT * FROM auth_config").fetchall()
|
||||
for row in rows:
|
||||
d = dict(row)
|
||||
if "user_id" in old_cols:
|
||||
uid = d.get("user_id") or 0
|
||||
else:
|
||||
# Legacy single-user DB: the id=1 row belongs to Root (user 0).
|
||||
old_id = d.get("id", 1)
|
||||
uid = 0 if old_id == 1 else (old_id or 0)
|
||||
common = [c for c in new_cols if c in old_cols and c not in ("id", "user_id")]
|
||||
if not common:
|
||||
continue
|
||||
col_str = ", ".join(common)
|
||||
placeholders = ", ".join("?" * len(common))
|
||||
conn_new.execute(
|
||||
f"INSERT OR IGNORE INTO auth_config (user_id, {col_str}) VALUES (?, {placeholders})",
|
||||
(uid, *[d[c] for c in common])
|
||||
)
|
||||
|
||||
|
||||
def init_db(db_path: str = "data.db"):
|
||||
"""Initialize the database.
|
||||
|
||||
- No database file: create it from the latest SCHEMA and create Root.
|
||||
- Existing database: rebuild it from the latest SCHEMA, migrate compatible
|
||||
data (legacy single-user data becomes Root's), fill missing columns with
|
||||
SCHEMA defaults, then replace the old file (keeping a backup).
|
||||
"""
|
||||
if not os.path.exists(db_path):
|
||||
conn = get_db(db_path)
|
||||
conn.executescript(SCHEMA)
|
||||
_ensure_defaults(conn)
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# Migrate by rebuilding: create a fresh DB with the latest schema,
|
||||
# copy compatible data from the old DB, then replace the old DB.
|
||||
new_db_path = db_path + ".new"
|
||||
backup_path = db_path + ".backup"
|
||||
|
||||
# Clean up any stale temporary database from a previous failed run.
|
||||
if os.path.exists(new_db_path):
|
||||
os.remove(new_db_path)
|
||||
|
||||
conn_new = get_db(new_db_path)
|
||||
conn_new.executescript(SCHEMA)
|
||||
|
||||
conn_old = get_db(db_path)
|
||||
# Disable FK enforcement while copying; rows are copied table by table.
|
||||
conn_old.execute("PRAGMA foreign_keys=OFF")
|
||||
conn_new.execute("PRAGMA foreign_keys=OFF")
|
||||
|
||||
tables = conn_old.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
).fetchall()
|
||||
|
||||
for (table_name,) in tables:
|
||||
# Tables that no longer exist in the new schema are dropped (e.g. camera_config).
|
||||
exists_new = conn_new.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
||||
(table_name,)
|
||||
).fetchone()
|
||||
if not exists_new:
|
||||
continue
|
||||
|
||||
if table_name == "auth_config":
|
||||
_migrate_auth_config(conn_new, conn_old)
|
||||
continue
|
||||
|
||||
old_cols = _table_columns(conn_old, table_name)
|
||||
new_cols = _table_columns(conn_new, table_name)
|
||||
common_cols = [c for c in new_cols if c in old_cols]
|
||||
if not common_cols:
|
||||
continue
|
||||
|
||||
col_str = ", ".join(common_cols)
|
||||
placeholders = ", ".join("?" * len(common_cols))
|
||||
sql = f"INSERT OR IGNORE INTO {table_name} ({col_str}) VALUES ({placeholders})"
|
||||
|
||||
for row in conn_old.execute(f"SELECT {col_str} FROM {table_name}"):
|
||||
try:
|
||||
conn_new.execute(sql, tuple(row))
|
||||
except Exception:
|
||||
# Ignore rows that cannot be migrated (e.g. constraint violations).
|
||||
pass
|
||||
|
||||
conn_new.commit()
|
||||
|
||||
# Now fill defaults for any tables that are still empty + Root bootstrap.
|
||||
_ensure_defaults(conn_new)
|
||||
|
||||
conn_old.close()
|
||||
conn_new.close()
|
||||
|
||||
# Replace old DB with the new one, keeping a backup.
|
||||
if os.path.exists(backup_path):
|
||||
os.remove(backup_path)
|
||||
os.rename(db_path, backup_path)
|
||||
os.rename(new_db_path, db_path)
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Pomodoro timer management: state persistence, countdown, cycle control.
|
||||
|
||||
State is stored per user (one row per user_id).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from backend.time_util import now as tz_now
|
||||
from flask import current_app
|
||||
from backend.models import get_db
|
||||
|
||||
|
||||
DEFAULT_WORK_MINUTES = 25
|
||||
DEFAULT_BREAK_MINUTES = 5
|
||||
DEFAULT_LONG_BREAK_MINUTES = 15
|
||||
DEFAULT_CYCLES_BEFORE_LONG_BREAK = 4
|
||||
|
||||
|
||||
def _uid() -> int:
|
||||
"""Resolve the current user (request session or scheduler thread)."""
|
||||
try:
|
||||
from backend.auth import current_user_id
|
||||
return current_user_id() or 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _get_db():
|
||||
return get_db(current_app.config["DB_PATH"])
|
||||
|
||||
|
||||
def get_state(user_id: int = None) -> dict:
|
||||
"""Return current pomodoro state from DB.
|
||||
Cycles reset at the start of each new day (daily auto-clear).
|
||||
"""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = _get_db()
|
||||
row = db.execute("SELECT * FROM pomodoro_state WHERE user_id = ?", (user_id,)).fetchone()
|
||||
if row is None:
|
||||
db.close()
|
||||
return _default_state()
|
||||
today = tz_now().strftime("%Y%m%d")
|
||||
cycles_date = row["cycles_date"] or ""
|
||||
cycles = row["cycles"]
|
||||
# Daily reset: if no date recorded or it's a new day, clear cycles
|
||||
if cycles_date != today:
|
||||
cycles = 0
|
||||
cycles_date = today
|
||||
db.execute(
|
||||
"UPDATE pomodoro_state SET cycles=0, cycles_date=? WHERE user_id=?",
|
||||
(today, user_id),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
long_break_minutes = row["long_break_minutes"] or DEFAULT_LONG_BREAK_MINUTES
|
||||
cycles_before_long_break = row["cycles_before_long_break"] or DEFAULT_CYCLES_BEFORE_LONG_BREAK
|
||||
|
||||
# Compute next break type for display
|
||||
if row["phase"] == "work":
|
||||
next_break_long = ((cycles + 1) % cycles_before_long_break) == 0
|
||||
else:
|
||||
next_break_long = ((cycles + 2) % cycles_before_long_break) == 0
|
||||
|
||||
return {
|
||||
"running": bool(row["running"]),
|
||||
"phase": row["phase"], # 'work' or 'break'
|
||||
"work_minutes": row["work_minutes"],
|
||||
"break_minutes": row["break_minutes"],
|
||||
"long_break_minutes": long_break_minutes,
|
||||
"cycles_before_long_break": cycles_before_long_break,
|
||||
"next_break_type": "long" if next_break_long else "short",
|
||||
"end_time": row["end_time"],
|
||||
"remaining_seconds": row["remaining_seconds"],
|
||||
"cycles": cycles,
|
||||
"cycles_date": cycles_date,
|
||||
"enabled": bool(row["enabled"]),
|
||||
"display_controls_enabled": bool(row["display_controls_enabled"]),
|
||||
"work_label": row["work_label"] or "工作中",
|
||||
"break_label": row["break_label"] or "休息中",
|
||||
}
|
||||
|
||||
|
||||
def _default_state() -> dict:
|
||||
return {
|
||||
"running": False,
|
||||
"phase": "work",
|
||||
"work_minutes": DEFAULT_WORK_MINUTES,
|
||||
"break_minutes": DEFAULT_BREAK_MINUTES,
|
||||
"long_break_minutes": DEFAULT_LONG_BREAK_MINUTES,
|
||||
"cycles_before_long_break": DEFAULT_CYCLES_BEFORE_LONG_BREAK,
|
||||
"next_break_type": "short",
|
||||
"end_time": None,
|
||||
"remaining_seconds": DEFAULT_WORK_MINUTES * 60,
|
||||
"cycles": 0,
|
||||
"enabled": False,
|
||||
"display_controls_enabled": True,
|
||||
"cycles_date": "",
|
||||
"work_label": "工作中",
|
||||
"break_label": "休息中",
|
||||
}
|
||||
|
||||
|
||||
def _ensure_row(db, user_id: int):
|
||||
row = db.execute("SELECT id FROM pomodoro_state WHERE user_id = ?", (user_id,)).fetchone()
|
||||
if row is None:
|
||||
db.execute(
|
||||
"""INSERT INTO pomodoro_state
|
||||
(user_id, running, phase, work_minutes, break_minutes, long_break_minutes, cycles_before_long_break, end_time, remaining_seconds, cycles, enabled, display_controls_enabled, cycles_date, work_label, break_label)
|
||||
VALUES (?, 0, 'work', ?, ?, ?, ?, NULL, ?, 0, 0, 1, '', '工作中', '休息中')""",
|
||||
(user_id, DEFAULT_WORK_MINUTES, DEFAULT_BREAK_MINUTES, DEFAULT_LONG_BREAK_MINUTES,
|
||||
DEFAULT_CYCLES_BEFORE_LONG_BREAK, DEFAULT_WORK_MINUTES * 60),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def create_or_update(work_minutes: int = None, break_minutes: int = None,
|
||||
long_break_minutes: int = None, cycles_before_long_break: int = None,
|
||||
enabled: bool = None, display_controls_enabled: bool = None,
|
||||
work_label: str = None, break_label: str = None,
|
||||
user_id: int = None) -> dict:
|
||||
"""Create or update pomodoro settings. Stops the timer and does not auto-start."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = _get_db()
|
||||
_ensure_row(db, user_id)
|
||||
|
||||
# Apply new settings first so the reset below uses the updated durations.
|
||||
fields = []
|
||||
vals = []
|
||||
if work_minutes is not None:
|
||||
fields.append("work_minutes=?")
|
||||
vals.append(work_minutes)
|
||||
if break_minutes is not None:
|
||||
fields.append("break_minutes=?")
|
||||
vals.append(break_minutes)
|
||||
if long_break_minutes is not None:
|
||||
fields.append("long_break_minutes=?")
|
||||
vals.append(long_break_minutes)
|
||||
if cycles_before_long_break is not None:
|
||||
fields.append("cycles_before_long_break=?")
|
||||
vals.append(cycles_before_long_break)
|
||||
if enabled is not None:
|
||||
fields.append("enabled=?")
|
||||
vals.append(1 if enabled else 0)
|
||||
if display_controls_enabled is not None:
|
||||
fields.append("display_controls_enabled=?")
|
||||
vals.append(1 if display_controls_enabled else 0)
|
||||
if work_label is not None:
|
||||
fields.append("work_label=?")
|
||||
vals.append(work_label)
|
||||
if break_label is not None:
|
||||
fields.append("break_label=?")
|
||||
vals.append(break_label)
|
||||
if fields:
|
||||
vals.append(user_id)
|
||||
db.execute(f"UPDATE pomodoro_state SET {', '.join(fields)} WHERE user_id=?", vals)
|
||||
|
||||
# Stop the timer and reset to the work phase. Do not auto-start after saving.
|
||||
db.execute(
|
||||
"""UPDATE pomodoro_state
|
||||
SET running=0, phase='work', end_time=NULL, remaining_seconds=work_minutes*60, enabled=0
|
||||
WHERE user_id=?""",
|
||||
(user_id,)
|
||||
)
|
||||
db.commit()
|
||||
state = get_state(user_id)
|
||||
db.close()
|
||||
current_app.event_bus.publish("pomodoro_updated", state, user_id=user_id)
|
||||
return state
|
||||
|
||||
|
||||
def start(user_id: int = None) -> dict:
|
||||
"""Start or resume the pomodoro timer."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = _get_db()
|
||||
_ensure_row(db, user_id)
|
||||
state = get_state(user_id)
|
||||
remaining = state.get("remaining_seconds") or (state["work_minutes"] * 60)
|
||||
end_time = str(time.time() + remaining)
|
||||
db.execute(
|
||||
"UPDATE pomodoro_state SET running=1, end_time=?, remaining_seconds=?, enabled=1 WHERE user_id=?",
|
||||
(end_time, remaining, user_id),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
updated = get_state(user_id)
|
||||
current_app.event_bus.publish("pomodoro_updated", updated, user_id=user_id)
|
||||
return updated
|
||||
|
||||
|
||||
def pause(user_id: int = None) -> dict:
|
||||
"""Pause the pomodoro timer, saving remaining time."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = _get_db()
|
||||
_ensure_row(db, user_id)
|
||||
state = get_state(user_id)
|
||||
remaining = _calc_remaining(state)
|
||||
db.execute(
|
||||
"UPDATE pomodoro_state SET running=0, end_time=NULL, remaining_seconds=? WHERE user_id=?",
|
||||
(remaining, user_id),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
updated = get_state(user_id)
|
||||
current_app.event_bus.publish("pomodoro_updated", updated, user_id=user_id)
|
||||
return updated
|
||||
|
||||
|
||||
def stop(user_id: int = None) -> dict:
|
||||
"""Stop and reset the pomodoro timer."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = _get_db()
|
||||
_ensure_row(db, user_id)
|
||||
db.execute(
|
||||
"UPDATE pomodoro_state SET running=0, phase='work', end_time=NULL, remaining_seconds=work_minutes*60, enabled=0 WHERE user_id=?",
|
||||
(user_id,)
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
updated = get_state(user_id)
|
||||
current_app.event_bus.publish("pomodoro_updated", updated, user_id=user_id)
|
||||
return updated
|
||||
|
||||
|
||||
def _advance_phase(user_id: int = None) -> dict:
|
||||
"""Advance to the next phase (work ↔ break), start the timer, persist, notify.
|
||||
Determines short vs long break based on cycles_before_long_break setting."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = _get_db()
|
||||
_ensure_row(db, user_id)
|
||||
state = get_state(user_id)
|
||||
|
||||
if state["phase"] == "work":
|
||||
# Work → break: decide short or long
|
||||
cycles_before_long = state.get("cycles_before_long_break", DEFAULT_CYCLES_BEFORE_LONG_BREAK)
|
||||
is_long_break = ((state["cycles"] + 1) % cycles_before_long) == 0
|
||||
if is_long_break:
|
||||
duration = state.get("long_break_minutes", DEFAULT_LONG_BREAK_MINUTES) * 60
|
||||
else:
|
||||
duration = state["break_minutes"] * 60
|
||||
next_phase = "break"
|
||||
cycles = state["cycles"] # cycles unchanged on work→break
|
||||
else:
|
||||
# Break → work: always use work duration
|
||||
duration = state["work_minutes"] * 60
|
||||
next_phase = "work"
|
||||
cycles = state["cycles"] + 1
|
||||
|
||||
end_time = str(time.time() + duration)
|
||||
today = tz_now().strftime("%Y%m%d")
|
||||
db.execute(
|
||||
"UPDATE pomodoro_state SET running=1, phase=?, end_time=?, remaining_seconds=?, cycles=?, cycles_date=? WHERE user_id=?",
|
||||
(next_phase, end_time, duration, cycles, today, user_id),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
updated = get_state(user_id)
|
||||
current_app.event_bus.publish("pomodoro_updated", updated, user_id=user_id)
|
||||
return updated
|
||||
|
||||
|
||||
def skip(user_id: int = None) -> dict:
|
||||
"""Skip the current phase and start the next phase immediately (work ↔ break)."""
|
||||
return _advance_phase(user_id)
|
||||
|
||||
|
||||
def dismiss(user_id: int = None) -> dict:
|
||||
"""Dismiss ringing and auto-advance to next phase."""
|
||||
return _advance_phase(user_id)
|
||||
|
||||
|
||||
def _calc_remaining(state: dict) -> int:
|
||||
"""Calculate remaining seconds from state."""
|
||||
if not state.get("running"):
|
||||
return state.get("remaining_seconds", state["work_minutes"] * 60)
|
||||
end_time_str = state.get("end_time")
|
||||
if not end_time_str:
|
||||
return state.get("remaining_seconds", state["work_minutes"] * 60)
|
||||
try:
|
||||
remaining = int(float(end_time_str) - time.time())
|
||||
return max(0, remaining)
|
||||
except (ValueError, TypeError):
|
||||
return state.get("remaining_seconds", state["work_minutes"] * 60)
|
||||
|
||||
|
||||
def check_and_trigger(user_id: int = None) -> bool:
|
||||
"""Called by scheduler to check if pomodoro timer has expired.
|
||||
Returns True if triggered (ringing should start)."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = _get_db()
|
||||
_ensure_row(db, user_id)
|
||||
state = get_state(user_id)
|
||||
db.close()
|
||||
|
||||
if not state.get("running") or not state.get("enabled"):
|
||||
return False
|
||||
|
||||
remaining = _calc_remaining(state)
|
||||
if remaining > 0:
|
||||
return False
|
||||
|
||||
# Timer expired; auto-advance phase on the backend once, then notify displays.
|
||||
updated = dismiss(user_id)
|
||||
current_app.event_bus.publish("pomodoro_due", updated, user_id=user_id)
|
||||
return True
|
||||
+1672
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
"""Background scheduler for periodic tasks.
|
||||
|
||||
All user-scoped jobs iterate over every active user (with that user's
|
||||
timezone/context active) so each user's alarms, reminders, calendars,
|
||||
pomodoro, countdowns and weather are serviced independently.
|
||||
"""
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from backend.calendar_mgr import sync_all_subscriptions, get_upcoming_reminders
|
||||
from backend.weather_mgr import fetch_weather, fetch_hourly
|
||||
from backend.alarm_mgr import check_alarms
|
||||
from backend.countdown_mgr import check_countdowns
|
||||
from backend.pomodoro_mgr import check_and_trigger
|
||||
from backend.models import get_db
|
||||
from backend.time_util import set_thread_user, clear_thread_user
|
||||
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
|
||||
|
||||
def _each_user(app, fn):
|
||||
"""Run fn(user_id) for every active user inside the app context."""
|
||||
with app.app_context():
|
||||
db = get_db(app.config.get("DB_PATH", "data.db"))
|
||||
rows = db.execute("SELECT id FROM users WHERE status='active' ORDER BY id").fetchall()
|
||||
db.close()
|
||||
for u in rows:
|
||||
uid = u["id"]
|
||||
try:
|
||||
set_thread_user(uid)
|
||||
fn(uid)
|
||||
except Exception as e:
|
||||
app.logger.error(f"Scheduler error for user {uid}: {e}")
|
||||
finally:
|
||||
clear_thread_user()
|
||||
|
||||
|
||||
def start_scheduler(app):
|
||||
global _scheduler
|
||||
_scheduler = BackgroundScheduler()
|
||||
|
||||
@_scheduler.scheduled_job("interval", seconds=10, id="check_alarms")
|
||||
def _check_alarms():
|
||||
_each_user(app, check_alarms)
|
||||
|
||||
@_scheduler.scheduled_job("interval", minutes=1, id="check_reminders")
|
||||
def _check_reminders():
|
||||
def _run(uid):
|
||||
due = get_upcoming_reminders(uid)
|
||||
if due:
|
||||
app.event_bus.publish("reminder_due", {"events": due}, user_id=uid)
|
||||
app.logger.info(f"Reminders due for user {uid}: {len(due)} events")
|
||||
_each_user(app, _run)
|
||||
|
||||
@_scheduler.scheduled_job("interval", minutes=30, id="sync_calendars")
|
||||
def _sync_calendars():
|
||||
with app.app_context():
|
||||
sync_all_subscriptions()
|
||||
|
||||
@_scheduler.scheduled_job("cron", minute="0,30", id="fetch_weather")
|
||||
def _fetch_weather():
|
||||
_each_user(app, fetch_weather)
|
||||
|
||||
@_scheduler.scheduled_job("interval", seconds=1, id="check_pomodoro")
|
||||
def _check_pomodoro():
|
||||
_each_user(app, check_and_trigger)
|
||||
|
||||
@_scheduler.scheduled_job("cron", minute="1", id="fetch_hourly")
|
||||
def _fetch_hourly():
|
||||
_each_user(app, fetch_hourly)
|
||||
|
||||
@_scheduler.scheduled_job("interval", seconds=1, id="check_countdowns")
|
||||
def _check_countdowns():
|
||||
def _run(uid):
|
||||
triggered = check_countdowns(uid)
|
||||
if triggered:
|
||||
app.logger.info(f"Countdowns triggered for user {uid}: {[c['label'] for c in triggered]}")
|
||||
_each_user(app, _run)
|
||||
|
||||
_scheduler.start()
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
global _scheduler
|
||||
if _scheduler:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
@@ -0,0 +1,114 @@
|
||||
"""日出日落时间计算(NOAA 简化算法)与白天/夜晚判定。
|
||||
|
||||
用于"自动"主题模式:根据用户天气配置的经纬度 + 本地时区,判断当前
|
||||
时刻处于白天(浅色主题)还是夜晚(深色主题)。
|
||||
|
||||
算法来源:Ed Williams《Sunrise/Sunset Algorithm》
|
||||
(https://edwilliams.org/sunrise_sunset_algorithm.htm)
|
||||
"""
|
||||
import math
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
# 太阳天顶角:日出日落定义(含大气折射)
|
||||
ZENITH = 90.833
|
||||
|
||||
|
||||
def _day_of_year(dt: datetime) -> int:
|
||||
"""计算一年中的第几天(NOAA 公式)。"""
|
||||
y, m, d = dt.year, dt.month, dt.day
|
||||
n1 = math.floor(275 * m / 9)
|
||||
n2 = math.floor((m + 9) / 12)
|
||||
n3 = 1 + math.floor((y - 4 * math.floor(y / 4) + 2) / 3)
|
||||
return n1 - (n2 * n3) + d - 30
|
||||
|
||||
|
||||
def _hour_angle(lat: float, lon: float, day_of_year: int, is_sunrise: bool) -> float | None:
|
||||
"""返回日出/日落的 UTC 小时(0~24),None 表示极昼/极夜(当天无日出日落)。"""
|
||||
lng_hour = lon / 15.0
|
||||
if is_sunrise:
|
||||
t = day_of_year + ((6 - lng_hour) / 24.0)
|
||||
else:
|
||||
t = day_of_year + ((18 - lng_hour) / 24.0)
|
||||
|
||||
m = (0.9856 * t) - 3.289
|
||||
l = (m + 1.916 * math.sin(math.radians(m))
|
||||
+ 0.020 * math.sin(math.radians(2 * m)) + 282.634) % 360
|
||||
|
||||
ra = math.degrees(math.atan(0.91764 * math.tan(math.radians(l)))) % 360
|
||||
# 校正 RA 象限
|
||||
l_quad = math.floor(l / 90) * 90
|
||||
ra_quad = math.floor(ra / 90) * 90
|
||||
ra = ra + (l_quad - ra_quad)
|
||||
ra /= 15.0 # 转为小时
|
||||
|
||||
sin_dec = 0.39782 * math.sin(math.radians(l))
|
||||
cos_dec = math.cos(math.asin(sin_dec))
|
||||
cos_h = ((math.cos(math.radians(ZENITH)) - sin_dec * math.sin(math.radians(lat)))
|
||||
/ (cos_dec * math.cos(math.radians(lat))))
|
||||
|
||||
if cos_h > 1 or cos_h < -1:
|
||||
return None # 极昼/极夜
|
||||
|
||||
if is_sunrise:
|
||||
h = 360 - math.degrees(math.acos(cos_h))
|
||||
else:
|
||||
h = math.degrees(math.acos(cos_h))
|
||||
h /= 15.0
|
||||
|
||||
# NOAA:T = H + RA - 0.06571*t - 6.622(小时),UT = T - lngHour(mod 24)
|
||||
local_t = h + ra - 0.06571 * t - 6.622
|
||||
return (local_t - lng_hour) % 24.0
|
||||
|
||||
|
||||
def sun_times_utc(lat: float, lon: float, date: datetime) -> tuple[datetime | None, datetime | None]:
|
||||
"""返回 (日出UTC, 日落UTC),极昼/极夜时对应项为 None。
|
||||
date 为任意带时区 datetime(取其日期)。"""
|
||||
lat = float(lat)
|
||||
lon = float(lon)
|
||||
day = _day_of_year(date.astimezone(timezone.utc))
|
||||
|
||||
sunrise_h = _hour_angle(lat, lon, day, True)
|
||||
sunset_h = _hour_angle(lat, lon, day, False)
|
||||
if sunrise_h is None and sunset_h is None:
|
||||
return None, None # 极昼或极夜,调用方按本地小时粗判
|
||||
|
||||
def to_utc(h: float) -> datetime:
|
||||
base = date.astimezone(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return base + timedelta(hours=h)
|
||||
|
||||
sunrise = to_utc(sunrise_h) if sunrise_h is not None else None
|
||||
sunset = to_utc(sunset_h) if sunset_h is not None else None
|
||||
return sunrise, sunset
|
||||
|
||||
|
||||
def is_daytime(lat: float, lon: float, now_local: datetime) -> bool:
|
||||
"""判断本地时刻是否为白天。
|
||||
now_local 为带时区的本地 datetime(使用用户配置时区)。
|
||||
|
||||
用 UTC 小时比较(忽略日期归属),自动处理日出/日落跨 UTC 午夜的情况。
|
||||
"""
|
||||
now_utc = now_local.astimezone(timezone.utc)
|
||||
sunrise, sunset = sun_times_utc(lat, lon, now_local)
|
||||
|
||||
if sunrise is None or sunset is None:
|
||||
# 极昼/极夜:根据本地小时粗判(6-18 视为白天)
|
||||
return 6 <= now_local.hour < 18
|
||||
|
||||
sr_h = sunrise.hour + sunrise.minute / 60.0
|
||||
ss_h = sunset.hour + sunset.minute / 60.0
|
||||
now_h = now_utc.hour + now_utc.minute / 60.0
|
||||
|
||||
if ss_h < sr_h:
|
||||
ss_h += 24.0 # 跨 UTC 午夜(东半球)
|
||||
if now_h < sr_h:
|
||||
now_h += 24.0
|
||||
return sr_h <= now_h <= ss_h
|
||||
|
||||
|
||||
def day_fraction_today(lat: float, lon: float, now_local: datetime) -> float:
|
||||
"""当天白昼占全天比例(0~1),用于自动模式的平滑切换(预留)。"""
|
||||
sunrise, sunset = sun_times_utc(lat, lon, now_local)
|
||||
if sunrise is None or sunset is None:
|
||||
return 0.5
|
||||
total = (sunset - sunrise).total_seconds()
|
||||
return max(0.0, min(1.0, total / 86400.0))
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Timezone-aware datetime utilities.
|
||||
|
||||
Central source for all local-time operations in the project.
|
||||
Reads the configured timezone from clock_config table (default: Asia/Shanghai).
|
||||
All backend modules should use these functions instead of bare datetime.now().
|
||||
|
||||
Two time domains:
|
||||
- **Local time** (configurable offset) — for date boundaries, display timestamps,
|
||||
user-facing schedule logic. Use now(), today(), strftime(), strptime().
|
||||
- **Unix timestamps** (UTC, always) — for interval math, expiry, cooldowns.
|
||||
Use ts_now(), ts_from_local(), ts_to_local().
|
||||
|
||||
The timezone is cached per-request via Flask app config to avoid DB lookups.
|
||||
"""
|
||||
import time as _time
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone, date
|
||||
from typing import Union
|
||||
|
||||
import pytz
|
||||
|
||||
# Thread-local "current user" context (set by the scheduler while it processes
|
||||
# a specific user's job; request threads resolve the user from the session).
|
||||
_TLS = threading.local()
|
||||
|
||||
|
||||
def set_thread_user(user_id):
|
||||
"""Set the current user for this thread (scheduler per-user iteration)."""
|
||||
_TLS.user_id = user_id
|
||||
|
||||
|
||||
def clear_thread_user():
|
||||
try:
|
||||
del _TLS.user_id
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
def get_thread_user():
|
||||
return getattr(_TLS, "user_id", None)
|
||||
|
||||
|
||||
def _ctx_user_id():
|
||||
"""Resolve the current user id: request session first, then thread-local."""
|
||||
try:
|
||||
from flask import has_request_context, session
|
||||
if has_request_context():
|
||||
uid = session.get("user_id")
|
||||
if uid is not None:
|
||||
return int(uid)
|
||||
except Exception:
|
||||
pass
|
||||
return getattr(_TLS, "user_id", None) or 0
|
||||
|
||||
# ─── Timezone loading ───────────────────────
|
||||
|
||||
_DEFAULT_TZ_STR = "Asia/Shanghai"
|
||||
_DEFAULT_UTC_OFFSET = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _load_tz() -> "pytz.BaseTzInfo":
|
||||
"""Load timezone from the current user's clock_config. Falls back to Asia/Shanghai."""
|
||||
try:
|
||||
from flask import current_app
|
||||
db_path = current_app.config.get("DB_PATH", "data.db")
|
||||
from backend.models import get_db
|
||||
db = get_db(db_path)
|
||||
row = db.execute(
|
||||
"SELECT timezone FROM clock_config WHERE is_primary=1 AND user_id=?",
|
||||
(_ctx_user_id(),)
|
||||
).fetchone()
|
||||
db.close()
|
||||
if row and row["timezone"]:
|
||||
tz_str = row["timezone"].strip()
|
||||
return pytz.timezone(tz_str)
|
||||
except Exception:
|
||||
pass
|
||||
return pytz.timezone(_DEFAULT_TZ_STR)
|
||||
|
||||
|
||||
def get_tz() -> "pytz.BaseTzInfo":
|
||||
"""Return the configured timezone object. Cached per user in app config."""
|
||||
try:
|
||||
from flask import current_app
|
||||
uid = _ctx_user_id()
|
||||
if hasattr(current_app, "config") and "_timezone_cache" in current_app.config:
|
||||
cache = current_app.config["_timezone_cache"]
|
||||
if uid in cache:
|
||||
return cache[uid]
|
||||
tz = _load_tz()
|
||||
if hasattr(current_app, "config"):
|
||||
cache = current_app.config.setdefault("_timezone_cache", {})
|
||||
cache[uid] = tz
|
||||
return tz
|
||||
except Exception:
|
||||
return pytz.timezone(_DEFAULT_TZ_STR)
|
||||
|
||||
|
||||
def clear_tz_cache():
|
||||
"""Force reload timezone on next call. Call after timezone config changes."""
|
||||
try:
|
||||
from flask import current_app
|
||||
current_app.config.pop("_timezone_cache", None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def set_tz(tz_str: str) -> bool:
|
||||
"""Set the timezone in clock_config DB and clear cache.
|
||||
|
||||
Args:
|
||||
tz_str: IANA timezone name (e.g. "Asia/Shanghai", "America/New_York")
|
||||
or UTC offset string (e.g. "UTC+8", "UTC-5")
|
||||
|
||||
Returns:
|
||||
True on success, False on invalid timezone.
|
||||
"""
|
||||
# Normalize "UTC+X" / "UTC-X" to pytz-compatible
|
||||
normalized = tz_str.strip()
|
||||
if normalized.upper().startswith("UTC") or normalized.upper().startswith("UT"):
|
||||
try:
|
||||
cleaned = normalized.upper().replace("UTC", "").replace("UT", "").strip()
|
||||
sign = 1
|
||||
if cleaned.startswith("+"):
|
||||
cleaned = cleaned[1:]
|
||||
elif cleaned.startswith("-"):
|
||||
sign = -1
|
||||
cleaned = cleaned[1:]
|
||||
hours = int(cleaned)
|
||||
# Convert to an IANA-like name: pytz has FixedOffset but easiest is
|
||||
# just use a fixed-offset timezone
|
||||
import pytz
|
||||
tz = pytz.FixedOffset(sign * hours * 60) # FixedOffset expects minutes
|
||||
except (ValueError, IndexError):
|
||||
return False
|
||||
else:
|
||||
# IANA name — validate by trying to load
|
||||
try:
|
||||
import pytz
|
||||
tz = pytz.timezone(normalized)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# Save to DB
|
||||
try:
|
||||
from flask import current_app
|
||||
db_path = current_app.config.get("DB_PATH", "data.db")
|
||||
from backend.models import get_db
|
||||
db = get_db(db_path)
|
||||
uid = _ctx_user_id()
|
||||
db.execute(
|
||||
"UPDATE clock_config SET timezone=? WHERE is_primary=1 AND user_id=?",
|
||||
(normalized, uid)
|
||||
)
|
||||
if db.total_changes == 0:
|
||||
db.execute(
|
||||
"INSERT INTO clock_config (user_id, city, timezone, is_primary) VALUES (?, '', ?, 1)",
|
||||
(uid, normalized)
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
clear_tz_cache()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_tz_str() -> str:
|
||||
"""Return the configured timezone as a string."""
|
||||
try:
|
||||
from flask import current_app
|
||||
db_path = current_app.config.get("DB_PATH", "data.db")
|
||||
from backend.models import get_db
|
||||
db = get_db(db_path)
|
||||
row = db.execute(
|
||||
"SELECT timezone FROM clock_config WHERE is_primary=1 AND user_id=?",
|
||||
(_ctx_user_id(),)
|
||||
).fetchone()
|
||||
db.close()
|
||||
if row and row["timezone"]:
|
||||
return row["timezone"]
|
||||
except Exception:
|
||||
pass
|
||||
return _DEFAULT_TZ_STR
|
||||
|
||||
|
||||
# ─── Local time functions ───────────────────
|
||||
# Use these for date boundaries, display timestamps, schedule logic.
|
||||
|
||||
def now() -> datetime:
|
||||
"""Return current datetime in the configured timezone.
|
||||
|
||||
This is the primary replacement for bare `datetime.now()`.
|
||||
"""
|
||||
return datetime.now(get_tz())
|
||||
|
||||
|
||||
def today() -> date:
|
||||
"""Return current date in the configured timezone."""
|
||||
return now().date()
|
||||
|
||||
|
||||
def today_str(fmt: str = "%Y%m%d") -> str:
|
||||
"""Return today's date formatted as string.
|
||||
|
||||
Common format: "%Y%m%d" for DB comparisons, "%Y-%m-%d" for displays.
|
||||
"""
|
||||
return now().strftime(fmt)
|
||||
|
||||
|
||||
def strftime(fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
||||
"""Return current datetime formatted as string in local timezone."""
|
||||
return now().strftime(fmt)
|
||||
|
||||
|
||||
def strptime(date_string: str, fmt: str) -> datetime:
|
||||
"""Parse a string into a timezone-aware datetime.
|
||||
|
||||
The result is in the configured timezone. If the input string
|
||||
has no timezone info, it's assumed to be in local time.
|
||||
"""
|
||||
dt = datetime.strptime(date_string, fmt)
|
||||
return get_tz().localize(dt)
|
||||
|
||||
|
||||
def isoformat() -> str:
|
||||
"""Return current time in ISO 8601 format with timezone offset."""
|
||||
return now().isoformat()
|
||||
|
||||
|
||||
def localize(dt: datetime) -> datetime:
|
||||
"""Convert a naive datetime to timezone-aware in configured timezone.
|
||||
|
||||
If dt is already timezone-aware, it's converted to local timezone.
|
||||
"""
|
||||
tz = get_tz()
|
||||
if dt.tzinfo is None:
|
||||
return tz.localize(dt)
|
||||
return dt.astimezone(tz)
|
||||
|
||||
|
||||
# ─── Unix timestamp utilities ───────────────
|
||||
# These are timezone-independent — always UTC for epoch math.
|
||||
|
||||
def ts_now() -> float:
|
||||
"""Return current Unix timestamp (seconds since epoch, UTC).
|
||||
|
||||
Equivalent to time.time(). Use for expiry, cooldowns, interval math.
|
||||
"""
|
||||
return _time.time()
|
||||
|
||||
|
||||
def ts_from_local(dt: datetime = None) -> float:
|
||||
"""Convert a local-timezone datetime to Unix timestamp.
|
||||
|
||||
If dt is None, returns current timestamp (same as ts_now()).
|
||||
"""
|
||||
if dt is None:
|
||||
return _time.time()
|
||||
if dt.tzinfo is None:
|
||||
dt = localize(dt)
|
||||
return dt.timestamp()
|
||||
|
||||
|
||||
def ts_to_local(ts: float) -> datetime:
|
||||
"""Convert a Unix timestamp to local timezone datetime."""
|
||||
return datetime.fromtimestamp(ts, get_tz())
|
||||
@@ -0,0 +1,86 @@
|
||||
"""URL safety helpers — prevent SSRF from user-supplied fetch URLs
|
||||
(calendar subscriptions, weather API hosts)."""
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
ALLOWED_SCHEMES = ("http", "https")
|
||||
|
||||
# Hosts that are always fine to reach (QWeather SaaS / common cloud APIs).
|
||||
# Everything else is resolved and must be a public address.
|
||||
_ALWAYS_ALLOWED = {
|
||||
"devapi.qweather.com",
|
||||
"geoapi.qweather.com",
|
||||
"qweather.com",
|
||||
}
|
||||
|
||||
|
||||
def _host_is_private(hostname: str) -> bool:
|
||||
"""Resolve a hostname and return True if ANY address is non-public."""
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None)
|
||||
except Exception:
|
||||
# Unresolvable — treat as unsafe (fail closed for fetches).
|
||||
return True
|
||||
for info in infos:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
||||
or ip.is_multicast or ip.is_reserved or ip.is_unspecified):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_safe_public_url(url: str) -> bool:
|
||||
"""True if the URL is http(s) and resolves to public addresses only."""
|
||||
if not url or not isinstance(url, str):
|
||||
return False
|
||||
try:
|
||||
p = urlparse(url.strip())
|
||||
except Exception:
|
||||
return False
|
||||
if p.scheme not in ALLOWED_SCHEMES or not p.hostname:
|
||||
return False
|
||||
host = p.hostname.lower().rstrip(".")
|
||||
if host in _ALWAYS_ALLOWED or host.endswith(".qweather.com"):
|
||||
return True
|
||||
if p.port and p.port not in (80, 443):
|
||||
return False
|
||||
return not _host_is_private(host)
|
||||
|
||||
|
||||
def is_safe_https_host(host: str) -> bool:
|
||||
"""True if the value is a bare https hostname (no scheme/path) and public."""
|
||||
if not host or not isinstance(host, str):
|
||||
return False
|
||||
host = host.strip().lower().rstrip(".")
|
||||
if not host or "://" in host or "/" in host or " " in host:
|
||||
return False
|
||||
if host in _ALWAYS_ALLOWED or host.endswith(".qweather.com"):
|
||||
return True
|
||||
return not _host_is_private(host)
|
||||
|
||||
|
||||
def safe_http_get(url: str, timeout: float = 15, max_redirects: int = 3):
|
||||
"""GET a URL, re-validating safety on every redirect hop.
|
||||
|
||||
Returns the final `requests.Response` (status 200) or None if any hop is
|
||||
unsafe/unresolvable or the redirect chain exceeds max_redirects.
|
||||
Raises requests.HTTPError for non-2xx final responses.
|
||||
"""
|
||||
import requests
|
||||
from urllib.parse import urljoin
|
||||
|
||||
current = (url or "").strip()
|
||||
for _ in range(max_redirects + 1):
|
||||
if not is_safe_public_url(current):
|
||||
return None
|
||||
resp = requests.get(current, timeout=timeout, allow_redirects=False)
|
||||
if resp.status_code in (301, 302, 303, 307, 308):
|
||||
location = resp.headers.get("Location", "")
|
||||
if not location:
|
||||
return None
|
||||
current = urljoin(current, location)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
return None
|
||||
@@ -0,0 +1,680 @@
|
||||
"""QWeather client: JWT auth, real-time weather, city search, intelligent caching."""
|
||||
import json
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from backend.time_util import now as tz_now, strftime as tz_strftime, today_str as tz_today_str, strptime as tz_strptime, localize as tz_localize
|
||||
from flask import current_app
|
||||
from backend.models import get_db
|
||||
from backend.jwt_util import sign_jwt, generate_key_pair
|
||||
|
||||
# QWeather API host (shared dev host, or user's dedicated host)
|
||||
QWEATHER_API = "https://devapi.qweather.com"
|
||||
QWEATHER_GEO_API = "https://geoapi.qweather.com"
|
||||
|
||||
# Cache TTL in seconds (default 30 min)
|
||||
CACHE_TTL = 1800
|
||||
|
||||
|
||||
# ─── Config ────────────────────────────────
|
||||
|
||||
def _uid() -> int:
|
||||
"""Resolve the current user (request session or scheduler thread)."""
|
||||
try:
|
||||
from backend.auth import current_user_id
|
||||
return current_user_id() or 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def get_config(user_id: int = None) -> dict:
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
row = db.execute("SELECT * FROM weather_config WHERE user_id=?", (user_id,)).fetchone()
|
||||
db.close()
|
||||
return dict(row) if row else {"user_id": user_id}
|
||||
|
||||
|
||||
def update_config(api_key: str = None,
|
||||
city: str = None, lat: float = None, lon: float = None,
|
||||
units: str = None, update_interval: int = None,
|
||||
credential_id: str = None, project_id: str = None,
|
||||
qweather_host: str = None, user_id: int = None):
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
if qweather_host is not None and qweather_host.strip():
|
||||
from backend.url_util import is_safe_https_host
|
||||
if not is_safe_https_host(qweather_host):
|
||||
raise ValueError("API Host 无效或不安全:仅允许公网域名(如 abc123.def.qweatherapi.com)")
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
fields = []
|
||||
vals = []
|
||||
for key, val in [
|
||||
("api_key", api_key),
|
||||
("city", city), ("lat", lat), ("lon", lon),
|
||||
("units", units), ("update_interval", update_interval),
|
||||
("credential_id", credential_id), ("project_id", project_id),
|
||||
("qweather_host", qweather_host),
|
||||
]:
|
||||
if val is not None:
|
||||
fields.append(f"{key}=?")
|
||||
vals.append(val)
|
||||
row = db.execute("SELECT id FROM weather_config WHERE user_id=?", (user_id,)).fetchone()
|
||||
if row is None:
|
||||
db.execute("INSERT INTO weather_config (user_id) VALUES (?)", (user_id,))
|
||||
if fields:
|
||||
vals.append(user_id)
|
||||
db.execute(f"UPDATE weather_config SET {', '.join(fields)} WHERE user_id=?", vals)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def generate_weather_keys() -> dict:
|
||||
"""Generate new Ed25519 key pair for QWeather JWT auth.
|
||||
|
||||
Returns:
|
||||
dict with public_key (private key stored in DB)
|
||||
"""
|
||||
priv, pub = generate_key_pair()
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
row = db.execute("SELECT id FROM weather_config WHERE user_id=?", (user_id,)).fetchone()
|
||||
if row is None:
|
||||
db.execute("INSERT INTO weather_config (user_id) VALUES (?)", (user_id,))
|
||||
db.execute(
|
||||
"UPDATE weather_config SET jwt_private_key=?, jwt_public_key=? WHERE user_id=?",
|
||||
(priv, pub, user_id)
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"public_key": pub}
|
||||
|
||||
|
||||
# ─── JWT Token ─────────────────────────────
|
||||
|
||||
def _get_auth_header(cfg: dict) -> dict:
|
||||
"""Build the Authorization header for QWeather API.
|
||||
|
||||
仅和风天气。优先 JWT(credential_id + project_id + jwt_private_key),
|
||||
否则回退到和风旧版 API Key(X-QW-Api-Key)。
|
||||
"""
|
||||
priv_key = cfg.get("jwt_private_key", "")
|
||||
kid = cfg.get("credential_id", "")
|
||||
sub = cfg.get("project_id", "")
|
||||
|
||||
if priv_key and kid and sub:
|
||||
token = sign_jwt(priv_key, kid, sub)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
api_key = cfg.get("api_key", "")
|
||||
if api_key:
|
||||
return {"X-QW-Api-Key": api_key}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
# ─── API Host ──────────────────────────────
|
||||
|
||||
def _api_host(cfg: dict) -> str:
|
||||
host = cfg.get("qweather_host", "").strip()
|
||||
from backend.url_util import is_safe_https_host
|
||||
if host and is_safe_https_host(host):
|
||||
return f"https://{host}"
|
||||
return QWEATHER_API
|
||||
|
||||
|
||||
def _geo_host(cfg: dict) -> str:
|
||||
# If user has a dedicated host, GeoAPI is under the same host
|
||||
host = cfg.get("qweather_host", "").strip()
|
||||
from backend.url_util import is_safe_https_host
|
||||
if host and is_safe_https_host(host):
|
||||
return f"https://{host}"
|
||||
return QWEATHER_GEO_API
|
||||
|
||||
|
||||
# ─── City Lookup ───────────────────────────
|
||||
|
||||
def city_lookup(query: str, number: int = 10) -> list:
|
||||
"""Search cities via QWeather GeoAPI.
|
||||
|
||||
Returns list of {name, id, lat, lon, adm1, adm2, country}
|
||||
"""
|
||||
cfg = get_config()
|
||||
headers = _get_auth_header(cfg)
|
||||
if not headers:
|
||||
return []
|
||||
|
||||
host = _geo_host(cfg)
|
||||
url = f"{host}/v2/city/lookup"
|
||||
params = {
|
||||
"location": query,
|
||||
"number": min(number, 20),
|
||||
"lang": "zh",
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.get(url, params=params, headers=headers, timeout=8)
|
||||
if resp.status_code != 200:
|
||||
current_app.logger.error(f"City lookup failed: {resp.status_code} {resp.text}")
|
||||
return []
|
||||
data = resp.json()
|
||||
if data.get("code") != "200":
|
||||
return []
|
||||
results = []
|
||||
for loc in data.get("location", []):
|
||||
results.append({
|
||||
"name": loc.get("name", ""),
|
||||
"id": loc.get("id", ""),
|
||||
"lat": loc.get("lat", ""),
|
||||
"lon": loc.get("lon", ""),
|
||||
"adm1": loc.get("adm1", ""),
|
||||
"adm2": loc.get("adm2", ""),
|
||||
"country": loc.get("country", ""),
|
||||
})
|
||||
return results
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"City lookup error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# ─── Weather Fetch ─────────────────────────
|
||||
|
||||
def fetch_weather(force: bool = False, user_id: int = None) -> dict:
|
||||
"""Fetch real-time weather from QWeather and cache it.
|
||||
|
||||
Args:
|
||||
force: If True, skip fallback cache on error — return error directly.
|
||||
"""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
return _fetch_qweather(get_config(user_id), force=force, user_id=user_id)
|
||||
|
||||
|
||||
def _fetch_qweather(cfg: dict, force: bool = False, user_id: int = None) -> dict:
|
||||
"""Fetch weather from QWeather v7/weather/now."""
|
||||
headers = _get_auth_header(cfg)
|
||||
if not headers:
|
||||
return {"error": "未配置 JWT 密钥或 API Key", "city": cfg.get("city", "")}
|
||||
|
||||
lat = cfg.get("lat") or 39.9042
|
||||
lon = cfg.get("lon") or 116.4074
|
||||
host = _api_host(cfg)
|
||||
|
||||
url = f"{host}/v7/weather/now"
|
||||
params = {
|
||||
"location": f"{lon:.2f},{lat:.2f}",
|
||||
"lang": "zh",
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
if resp.status_code == 401:
|
||||
current_app.logger.error("QWeather: JWT auth failed (401)")
|
||||
return {"error": "JWT 认证失败,请检查密钥配置", "city": cfg.get("city", "")}
|
||||
if resp.status_code == 403:
|
||||
current_app.logger.error(f"QWeather: forbidden (403) body={resp.text[:200]}")
|
||||
# Detect Invalid Host (shared dev domain deprecated)
|
||||
if "invalid host" in resp.text.lower() or "unauthorized api host" in resp.text.lower():
|
||||
return {"error": "API Host 无效:共享开发域名已弃用,请在控制端配置专属 API Host(和风控制台 → 产品 → API Host)", "city": cfg.get("city", "")}
|
||||
return {"error": "请求被拒绝(403),请检查额度、权限或 API Host 配置", "city": cfg.get("city", "")}
|
||||
if resp.status_code == 429:
|
||||
msg = "请求太频繁,请稍后再试"
|
||||
current_app.logger.warning(f"QWeather: rate limited (429)")
|
||||
return {"error": msg, "city": cfg.get("city", "")} if force else _fallback_cache(cfg, user_id)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("code") != "200":
|
||||
err_msg = data.get("code", "unknown")
|
||||
current_app.logger.error(f"QWeather API error code: {err_msg}")
|
||||
return {"error": f"API 错误: {err_msg}", "city": cfg.get("city", "")}
|
||||
except requests.exceptions.Timeout:
|
||||
msg = "API 请求超时"
|
||||
current_app.logger.error(f"QWeather: timeout")
|
||||
return {"error": msg, "city": cfg.get("city", "")} if force else _fallback_cache(cfg, user_id)
|
||||
except requests.exceptions.ConnectionError:
|
||||
msg = "无法连接 API"
|
||||
current_app.logger.error(f"QWeather: connection failed")
|
||||
return {"error": msg, "city": cfg.get("city", "")} if force else _fallback_cache(cfg, user_id)
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
current_app.logger.error(f"QWeather error: {e}")
|
||||
return {"error": msg, "city": cfg.get("city", "")} if force else _fallback_cache(cfg, user_id)
|
||||
|
||||
now_data = data.get("now", {})
|
||||
|
||||
# Map QWeather icon codes to something displayable
|
||||
icon_code = now_data.get("icon", "999")
|
||||
|
||||
result = {
|
||||
"city": cfg.get("city", ""),
|
||||
"temp": float(now_data.get("temp", 0)),
|
||||
"feels_like": float(now_data.get("feelsLike", 0)),
|
||||
"humidity": int(now_data.get("humidity", 0)),
|
||||
"pressure": int(now_data.get("pressure", 0)),
|
||||
"description": now_data.get("text", ""),
|
||||
"icon": icon_code,
|
||||
"wind_speed": float(now_data.get("windSpeed", 0)),
|
||||
"wind_dir": now_data.get("windDir", ""),
|
||||
"wind_scale": int(now_data.get("windScale", 0)),
|
||||
"visibility": now_data.get("vis", ""),
|
||||
"precip": now_data.get("precip", "0"),
|
||||
"cloud": now_data.get("cloud", ""),
|
||||
"dew": now_data.get("dew", ""),
|
||||
"obs_time": now_data.get("obsTime", ""),
|
||||
"updated_at": tz_now().isoformat(),
|
||||
"provider": "qweather",
|
||||
}
|
||||
|
||||
_save_cache(result, user_id)
|
||||
current_app.event_bus.publish("weather_updated", result, user_id=user_id)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
# ─── Cache ─────────────────────────────────
|
||||
|
||||
def _save_cache(data: dict, user_id: int = None):
|
||||
"""Write weather data to the cache table, replacing any existing row."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
now_str = tz_strftime()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
db.execute("DELETE FROM weather_cache WHERE user_id=?", (user_id,))
|
||||
db.execute(
|
||||
"INSERT INTO weather_cache (user_id, data, updated_at) VALUES (?, ?, ?)",
|
||||
(user_id, json.dumps(data), now_str)
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def _fallback_cache(cfg: dict, user_id: int = None) -> dict:
|
||||
"""Return cached data if available, else error."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
cache = db.execute(
|
||||
"SELECT data FROM weather_cache WHERE user_id=?", (user_id,)
|
||||
).fetchone()
|
||||
db.close()
|
||||
if cache:
|
||||
cached = json.loads(cache["data"])
|
||||
cached["cached"] = True
|
||||
return cached
|
||||
return {"error": "无法获取天气数据", "city": cfg.get("city", "")}
|
||||
|
||||
|
||||
def get_cached_weather(user_id: int = None) -> dict:
|
||||
"""Return cached weather or empty error."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
row = db.execute("SELECT * FROM weather_cache WHERE user_id=?", (user_id,)).fetchone()
|
||||
db.close()
|
||||
if row:
|
||||
return json.loads(row["data"])
|
||||
return {"error": "暂无天气数据"}
|
||||
|
||||
|
||||
# ─── Next Update Schedule ─────────────────
|
||||
|
||||
def get_next_update_times(user_id: int = None) -> dict:
|
||||
"""Compute next scheduled update times based on cron rules.
|
||||
|
||||
- Weather (current): every :00 and :30
|
||||
- Hourly forecast: every :01 past the hour
|
||||
"""
|
||||
from datetime import timedelta
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
now = tz_now()
|
||||
|
||||
# Next weather update: next :00 or :30
|
||||
if now.minute < 30:
|
||||
next_w = now.replace(minute=30, second=0, microsecond=0)
|
||||
else:
|
||||
next_w = (now + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)
|
||||
|
||||
# Next hourly update: next :01
|
||||
if now.minute < 1:
|
||||
next_h = now.replace(minute=1, second=0, microsecond=0)
|
||||
else:
|
||||
next_h = (now + timedelta(hours=1)).replace(minute=1, second=0, microsecond=0)
|
||||
|
||||
# Read last update times from cached JSON (local time) not SQLite datetime('now') which is UTC
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
|
||||
w_updated = None
|
||||
w_row = db.execute("SELECT data FROM weather_cache WHERE user_id=?", (user_id,)).fetchone()
|
||||
if w_row and w_row["data"]:
|
||||
w_data = json.loads(w_row["data"])
|
||||
w_updated = w_data.get("updated_at", None)
|
||||
|
||||
h_updated = None
|
||||
h_row = db.execute("SELECT data FROM weather_hourly_cache WHERE user_id=?", (user_id,)).fetchone()
|
||||
if h_row and h_row["data"]:
|
||||
h_data = json.loads(h_row["data"])
|
||||
# Hourly data is a list; we store fetched_at separately, but for display
|
||||
# use the SQLite fetched_at (converted) or just show the schedule
|
||||
# Let's use the local-computed schedule as last-update signal
|
||||
h_row2 = db.execute("SELECT fetched_at FROM weather_hourly_cache WHERE user_id=?", (user_id,)).fetchone()
|
||||
if h_row2 and h_row2[0]:
|
||||
# Keep the UTC string but note it's UTC — we'll just pass it
|
||||
h_updated = h_row2[0]
|
||||
|
||||
db.close()
|
||||
|
||||
return {
|
||||
"weather_updated_at": w_updated,
|
||||
"weather_next_update": next_w.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"hourly_updated_at": h_updated,
|
||||
"hourly_next_update": next_h.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
|
||||
|
||||
# ─── Hourly Forecast ──────────────────────
|
||||
|
||||
def fetch_hourly(user_id: int = None) -> list:
|
||||
"""Fetch 24-hour forecast from QWeather, filter past hours, cache.
|
||||
|
||||
Returns filtered list of {time, temp, icon, text, pop, windSpeed, windDir}.
|
||||
Handles cross-hour/cross-day: filters out fxTime < now, so stale cache
|
||||
never shows expired hours. If filtered result is short, caller should
|
||||
re-fetch (handled by scheduler every 60 min).
|
||||
"""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
cfg = get_config(user_id)
|
||||
|
||||
headers = _get_auth_header(cfg)
|
||||
if not headers:
|
||||
return []
|
||||
|
||||
lat = cfg.get("lat") or 39.9042
|
||||
lon = cfg.get("lon") or 116.4074
|
||||
host = _api_host(cfg)
|
||||
url = f"{host}/v7/weather/24h"
|
||||
params = {"location": f"{lon:.2f},{lat:.2f}", "lang": "zh"}
|
||||
|
||||
try:
|
||||
resp = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
current_app.logger.error(f"Hourly fetch failed: {resp.status_code}")
|
||||
return _get_cached_hourly(user_id) or []
|
||||
data = resp.json()
|
||||
if data.get("code") != "200":
|
||||
return _get_cached_hourly(user_id) or []
|
||||
raw_list = data.get("hourly", [])
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Hourly fetch error: {e}")
|
||||
return _get_cached_hourly(user_id) or []
|
||||
|
||||
now = tz_now()
|
||||
today_date = now.strftime("%Y-%m-%d")
|
||||
filtered = []
|
||||
for h in raw_list:
|
||||
fx_str = h.get("fxTime", "")
|
||||
fx_time = _parse_qw_time(fx_str)
|
||||
if not fx_time:
|
||||
continue
|
||||
# Unify to a local timezone-aware datetime via the time middleware.
|
||||
# fxTime from QWeather may carry an offset (+08:00) or be naive;
|
||||
# never strip tzinfo — that breaks comparison with tz_now().
|
||||
fx_local = tz_localize(fx_time)
|
||||
if fx_local >= now:
|
||||
# Determine if this hour is today or tomorrow
|
||||
fx_date = fx_local.strftime("%Y-%m-%d")
|
||||
day_label = "明日" if fx_date > today_date else "今日"
|
||||
filtered.append({
|
||||
"time": fx_local.strftime("%H:%M"),
|
||||
"day": day_label,
|
||||
"temp": h.get("temp", ""),
|
||||
"icon": h.get("icon", "999"),
|
||||
"text": h.get("text", ""),
|
||||
"pop": h.get("pop", "0"),
|
||||
"wind_speed": h.get("windSpeed", "0"),
|
||||
"wind_dir": h.get("windDir", ""),
|
||||
})
|
||||
|
||||
_save_hourly_cache(filtered, user_id)
|
||||
current_app.logger.info(f"Hourly: fetched {len(raw_list)} → {len(filtered)} future hours")
|
||||
return filtered
|
||||
|
||||
|
||||
def _parse_qw_time(s: str):
|
||||
"""Parse QWeather ISO time string to datetime, or None."""
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
# Format: "2026-07-14T15:00+08:00" or "2026-07-14T15:00+08:00"
|
||||
return datetime.fromisoformat(s)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
# Fallback: "2026-07-14T15:00"
|
||||
return datetime.strptime(s[:16], "%Y-%m-%dT%H:%M")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_cached_hourly(user_id: int = None) -> list:
|
||||
"""Return cached hourly forecast with cross-time filtering.
|
||||
|
||||
Filters out past hours using proper time comparison (handles
|
||||
cross-midnight correctly). If fewer than 12 remain, logs a warning
|
||||
so the scheduler knows to refresh early.
|
||||
"""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
data = _get_cached_hourly(user_id)
|
||||
if not data:
|
||||
return []
|
||||
|
||||
# Detect old cache format (missing "day" field) — force re-fetch
|
||||
if not data[0].get("day"):
|
||||
current_app.logger.info("Hourly: old cache format detected, force re-fetch")
|
||||
return []
|
||||
|
||||
now = tz_now()
|
||||
now_minutes = now.hour * 60 + now.minute
|
||||
|
||||
valid = []
|
||||
for h in data:
|
||||
parts = h.get("time", "00:00").split(":")
|
||||
h_minutes = int(parts[0]) * 60 + int(parts[1])
|
||||
day = h.get("day", "今日")
|
||||
# Keep if: it's "今日" and time hasn't passed, OR it's "明日" (always future)
|
||||
if (day == "今日" and h_minutes >= now_minutes) or day == "明日":
|
||||
valid.append(h)
|
||||
|
||||
if len(valid) < 12:
|
||||
current_app.logger.info(
|
||||
f"Hourly cache: only {len(valid)} future hours left, will refresh soon"
|
||||
)
|
||||
return valid
|
||||
|
||||
|
||||
def _save_hourly_cache(data: list, user_id: int = None):
|
||||
"""Persist hourly data, replacing old cache."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
now_str = tz_strftime()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
db.execute(
|
||||
"DELETE FROM weather_hourly_cache WHERE user_id=?", (user_id,)
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO weather_hourly_cache (user_id, data, fetched_at) VALUES (?, ?, ?)",
|
||||
(user_id, json.dumps(data), now_str)
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def _get_cached_hourly(user_id: int = None) -> list | None:
|
||||
"""Read cached hourly data from DB."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
row = db.execute(
|
||||
"SELECT data FROM weather_hourly_cache WHERE user_id=?", (user_id,)
|
||||
).fetchone()
|
||||
db.close()
|
||||
if row and row["data"]:
|
||||
return json.loads(row["data"])
|
||||
return None
|
||||
|
||||
|
||||
# ─── Minutely Forecast (manual, rate-limited) ─
|
||||
|
||||
_MINUTELY_COOLDOWN = 180 # 3 minutes in seconds
|
||||
|
||||
def fetch_minutely(user_id: int = None) -> dict:
|
||||
"""Fetch minute-level precipitation forecast from QWeather v7/minutely/5m.
|
||||
|
||||
Rate-limited: once per 3 minutes. Returns cooldown info on rejection.
|
||||
"""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
# Check cooldown
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
row = db.execute(
|
||||
"SELECT fetched_at FROM weather_minutely_cache WHERE user_id=?", (user_id,)
|
||||
).fetchone()
|
||||
if row and row["fetched_at"]:
|
||||
try:
|
||||
last = tz_strptime(row["fetched_at"], "%Y-%m-%d %H:%M:%S") if 'T' not in row["fetched_at"] else tz_strptime(row["fetched_at"][:19].replace('T', ' '), "%Y-%m-%d %H:%M:%S")
|
||||
elapsed = (tz_now() - last).total_seconds()
|
||||
if elapsed < _MINUTELY_COOLDOWN:
|
||||
remaining = int(_MINUTELY_COOLDOWN - elapsed)
|
||||
db.close()
|
||||
return {"error": f"冷却中,剩余 {remaining} 秒", "cooldown": remaining}
|
||||
except Exception:
|
||||
pass
|
||||
db.close()
|
||||
|
||||
cfg = get_config(user_id)
|
||||
headers = _get_auth_header(cfg)
|
||||
if not headers:
|
||||
return {"error": "未配置认证"}
|
||||
|
||||
lat = cfg.get("lat") or 39.9042
|
||||
lon = cfg.get("lon") or 116.4074
|
||||
host = _api_host(cfg)
|
||||
url = f"{host}/v7/minutely/5m"
|
||||
params = {"location": f"{lon:.2f},{lat:.2f}", "lang": "zh"}
|
||||
|
||||
try:
|
||||
resp = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return {"error": f"API 返回 {resp.status_code}"}
|
||||
data = resp.json()
|
||||
if data.get("code") != "200":
|
||||
return {"error": f"API 错误: {data.get('code')}"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
now_str = tz_strftime()
|
||||
result = {
|
||||
"summary": data.get("summary", ""),
|
||||
"minutely": data.get("minutely", []),
|
||||
"fetched_at": now_str,
|
||||
}
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
db.execute("DELETE FROM weather_minutely_cache WHERE user_id=?", (user_id,))
|
||||
db.execute(
|
||||
"INSERT INTO weather_minutely_cache (user_id, data, fetched_at) VALUES (?, ?, ?)",
|
||||
(user_id, json.dumps(result), now_str)
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
return result
|
||||
|
||||
|
||||
def get_cached_minutely(user_id: int = None) -> dict | None:
|
||||
"""Return cached minutely forecast if available."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
row = db.execute("SELECT data FROM weather_minutely_cache WHERE user_id=?", (user_id,)).fetchone()
|
||||
db.close()
|
||||
if row and row["data"]:
|
||||
return json.loads(row["data"])
|
||||
return None
|
||||
|
||||
|
||||
# ─── Weather Warnings (manual, rate-limited) ─
|
||||
|
||||
def fetch_warnings(user_id: int = None) -> dict:
|
||||
"""Fetch weather warnings from QWeather v7/warning/now.
|
||||
|
||||
Rate-limited: once per 3 minutes. Returns cooldown info on rejection.
|
||||
"""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
# Check cooldown
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
row = db.execute(
|
||||
"SELECT fetched_at FROM weather_warning_cache WHERE user_id=?", (user_id,)
|
||||
).fetchone()
|
||||
if row and row["fetched_at"]:
|
||||
try:
|
||||
last = tz_strptime(row["fetched_at"], "%Y-%m-%d %H:%M:%S") if 'T' not in row["fetched_at"] else tz_strptime(row["fetched_at"][:19].replace('T', ' '), "%Y-%m-%d %H:%M:%S")
|
||||
elapsed = (tz_now() - last).total_seconds()
|
||||
if elapsed < _MINUTELY_COOLDOWN:
|
||||
remaining = int(_MINUTELY_COOLDOWN - elapsed)
|
||||
db.close()
|
||||
return {"error": f"冷却中,剩余 {remaining} 秒", "cooldown": remaining}
|
||||
except Exception:
|
||||
pass
|
||||
db.close()
|
||||
|
||||
cfg = get_config(user_id)
|
||||
headers = _get_auth_header(cfg)
|
||||
if not headers:
|
||||
return {"error": "未配置认证"}
|
||||
|
||||
lat = round(cfg.get("lat") or 39.9042, 2)
|
||||
lon = round(cfg.get("lon") or 116.4074, 2)
|
||||
host = _api_host(cfg)
|
||||
url = f"{host}/weatheralert/v1/current/{lat}/{lon}"
|
||||
params = {"lang": "zh"}
|
||||
|
||||
try:
|
||||
resp = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
if resp.status_code == 403:
|
||||
return {"error": "天气预警为付费功能,当前订阅不可用", "unavailable": True}
|
||||
if resp.status_code != 200:
|
||||
return {"error": f"API 返回 {resp.status_code}"}
|
||||
data = resp.json()
|
||||
alert_list = data.get("alerts", [])
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
now_str = tz_strftime()
|
||||
result = {
|
||||
"warnings": alert_list,
|
||||
"fetched_at": now_str,
|
||||
"zero_result": data.get("metadata", {}).get("zeroResult", False),
|
||||
}
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
db.execute("DELETE FROM weather_warning_cache WHERE user_id=?", (user_id,))
|
||||
db.execute(
|
||||
"INSERT INTO weather_warning_cache (user_id, data, fetched_at) VALUES (?, ?, ?)",
|
||||
(user_id, json.dumps(result), now_str)
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
return result
|
||||
|
||||
|
||||
def get_cached_warnings(user_id: int = None) -> dict | None:
|
||||
"""Return cached warnings if available."""
|
||||
if user_id is None:
|
||||
user_id = _uid()
|
||||
db = get_db(current_app.config["DB_PATH"])
|
||||
row = db.execute("SELECT data FROM weather_warning_cache WHERE user_id=?", (user_id,)).fetchone()
|
||||
db.close()
|
||||
if row and row["data"]:
|
||||
return json.loads(row["data"])
|
||||
return None
|
||||
@@ -0,0 +1,255 @@
|
||||
"""WebAuthn (Passkey) manager — registration and authentication ceremonies.
|
||||
|
||||
Multi-user support:
|
||||
- Registration is bound to a concrete user id (challenge keyed by user id,
|
||||
user handle = "user_<id>").
|
||||
- Login ceremonies are credential-driven: after `complete_authentication`,
|
||||
the caller maps the returned credential id to its owner via
|
||||
`models.find_user_by_credential_id`.
|
||||
- `begin_authentication` can be restricted to a single credential for
|
||||
re-authentication ceremonies.
|
||||
|
||||
Uses the `webauthn` library (v2.x/v3.x).
|
||||
Requires HTTPS (or localhost) to function.
|
||||
"""
|
||||
from dataclasses import asdict
|
||||
from webauthn.helpers import bytes_to_base64url
|
||||
from webauthn import (
|
||||
generate_registration_options,
|
||||
verify_registration_response,
|
||||
generate_authentication_options,
|
||||
verify_authentication_response,
|
||||
)
|
||||
from webauthn.helpers.structs import (
|
||||
AuthenticatorSelectionCriteria,
|
||||
ResidentKeyRequirement,
|
||||
UserVerificationRequirement,
|
||||
PublicKeyCredentialDescriptor,
|
||||
)
|
||||
from webauthn.helpers.exceptions import InvalidRegistrationResponse, InvalidAuthenticationResponse
|
||||
|
||||
|
||||
# snake_case → camelCase mapping for browser WebAuthn API
|
||||
_SNAKE_TO_CAMEL = {
|
||||
"pub_key_cred_params": "pubKeyCredParams",
|
||||
"exclude_credentials": "excludeCredentials",
|
||||
"authenticator_selection": "authenticatorSelection",
|
||||
"allow_credentials": "allowCredentials",
|
||||
"rp_id": "rpId",
|
||||
"user_verification": "userVerification",
|
||||
"resident_key": "residentKey",
|
||||
"authenticator_attachment": "authenticatorAttachment",
|
||||
"require_resident_key": "requireResidentKey",
|
||||
"user_handle": "userHandle",
|
||||
"credential_id": "credentialId",
|
||||
"credential_public_key": "credentialPublicKey",
|
||||
"sign_count": "signCount",
|
||||
"cred_props": "credProps",
|
||||
"public_key": "publicKey",
|
||||
"private_key": "privateKey",
|
||||
"display_name": "displayName",
|
||||
}
|
||||
|
||||
|
||||
def _snake_to_camel(name: str) -> str:
|
||||
"""Convert snake_case key to camelCase, with known overrides."""
|
||||
if name in _SNAKE_TO_CAMEL:
|
||||
return _SNAKE_TO_CAMEL[name]
|
||||
parts = name.split("_")
|
||||
return parts[0] + "".join(p.capitalize() for p in parts[1:])
|
||||
|
||||
|
||||
# Relying Party info
|
||||
RP_NAME = "Cal Display"
|
||||
RP_ID = None # set dynamically from request.host
|
||||
ORIGIN = None # set dynamically
|
||||
|
||||
# In-memory challenge stores
|
||||
_CHALLENGES: dict = {} # "login" → challenge
|
||||
_REGISTRATION_CHALLENGES: dict = {} # "reg_<user_id>" → challenge
|
||||
|
||||
|
||||
def _get_rp_id(request) -> str:
|
||||
"""Extract RP ID from the request host (e.g. 'example.com' or '192.168.1.100')."""
|
||||
host = request.host.split(":")[0] # strip port
|
||||
return host
|
||||
|
||||
|
||||
def _get_origin(request) -> str:
|
||||
"""Extract origin from the request (scheme + host)."""
|
||||
scheme = request.scheme
|
||||
return f"{scheme}://{request.host}"
|
||||
|
||||
|
||||
def _serialize_options(options) -> dict:
|
||||
"""Serialize a webauthn options dataclass to a browser-ready dict.
|
||||
Converts bytes to base64url strings AND snake_case keys to camelCase.
|
||||
"""
|
||||
d = asdict(options)
|
||||
return _bytes_to_json(d)
|
||||
|
||||
|
||||
def _bytes_to_json(obj):
|
||||
"""Recursively convert bytes→base64url and snake_case→camelCase."""
|
||||
if isinstance(obj, bytes):
|
||||
return bytes_to_base64url(obj)
|
||||
elif isinstance(obj, dict):
|
||||
return {_snake_to_camel(k): _bytes_to_json(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [_bytes_to_json(v) for v in obj]
|
||||
elif isinstance(obj, tuple):
|
||||
return tuple(_bytes_to_json(v) for v in obj)
|
||||
return obj
|
||||
|
||||
|
||||
# ─── Registration ────────────────────────────
|
||||
|
||||
def begin_registration(request, user_id: int, display_name: str = "",
|
||||
existing_credential_id: str = None) -> dict:
|
||||
"""Start WebAuthn registration for a specific user. Returns options dict."""
|
||||
rp_id = _get_rp_id(request)
|
||||
origin = _get_origin(request)
|
||||
|
||||
user_name = f"user_{user_id}"
|
||||
user_handle = user_name.encode("utf-8")
|
||||
|
||||
# If the user already has a passkey, exclude it so they can't re-register same credential
|
||||
exclude_credentials = []
|
||||
if existing_credential_id:
|
||||
exclude_credentials.append(
|
||||
PublicKeyCredentialDescriptor(id=bytes.fromhex(existing_credential_id))
|
||||
)
|
||||
|
||||
options = generate_registration_options(
|
||||
rp_id=rp_id,
|
||||
rp_name=RP_NAME,
|
||||
user_id=user_handle,
|
||||
user_name=user_name,
|
||||
user_display_name=display_name or user_name,
|
||||
attestation="none",
|
||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||
resident_key=ResidentKeyRequirement.PREFERRED,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
),
|
||||
exclude_credentials=exclude_credentials,
|
||||
)
|
||||
|
||||
# Store challenge for verification
|
||||
_REGISTRATION_CHALLENGES[f"reg_{user_id}"] = options.challenge
|
||||
|
||||
return _serialize_options(options)
|
||||
|
||||
|
||||
def complete_registration(request, credential: dict) -> dict:
|
||||
"""Complete WebAuthn registration.
|
||||
|
||||
Returns {'ok': True, 'user_id': int, 'credential_id': str, 'public_key': str}
|
||||
or raises an exception on failure.
|
||||
"""
|
||||
rp_id = _get_rp_id(request)
|
||||
origin = _get_origin(request)
|
||||
|
||||
user_name = credential.get("user_name", "")
|
||||
# Derive the target user id from the registration challenge key.
|
||||
challenge_key = f"reg_{user_name}"
|
||||
expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None)
|
||||
if not expected_challenge:
|
||||
# Self-registration fallback: the browser may not echo the user handle,
|
||||
# so resolve the target from the session user.
|
||||
try:
|
||||
from flask import session
|
||||
uid = session.get("user_id")
|
||||
if uid is not None:
|
||||
challenge_key = f"reg_{uid}"
|
||||
expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None)
|
||||
user_name = f"user_{uid}"
|
||||
except Exception:
|
||||
pass
|
||||
if not expected_challenge:
|
||||
raise ValueError("No registration challenge found. Please restart registration.")
|
||||
user_id = int(user_name.split("_")[-1])
|
||||
|
||||
try:
|
||||
# Library expects rawId field (browser-native camelCase, base64url string)
|
||||
if "rawId" not in credential:
|
||||
credential["rawId"] = credential.get("id", "")
|
||||
|
||||
verification = verify_registration_response(
|
||||
credential=credential,
|
||||
expected_challenge=expected_challenge,
|
||||
expected_rp_id=rp_id,
|
||||
expected_origin=origin,
|
||||
)
|
||||
except (InvalidRegistrationResponse, Exception) as e:
|
||||
raise ValueError(f"Registration verification failed: {e}")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"user_id": user_id,
|
||||
"credential_id": verification.credential_id.hex(),
|
||||
"public_key": verification.credential_public_key.hex(),
|
||||
}
|
||||
|
||||
|
||||
# ─── Authentication (Login / Re-auth) ────────
|
||||
|
||||
def begin_authentication(request, allow_credential_ids: list = None) -> dict:
|
||||
"""Start WebAuthn authentication. Returns options dict for the frontend.
|
||||
|
||||
allow_credential_ids: optional list of hex credential ids to restrict the
|
||||
ceremony to (used for re-authentication of the current user).
|
||||
"""
|
||||
rp_id = _get_rp_id(request)
|
||||
origin = _get_origin(request)
|
||||
|
||||
allow_credentials = []
|
||||
if allow_credential_ids:
|
||||
allow_credentials = [
|
||||
PublicKeyCredentialDescriptor(id=bytes.fromhex(cid))
|
||||
for cid in allow_credential_ids
|
||||
]
|
||||
|
||||
options = generate_authentication_options(
|
||||
rp_id=rp_id,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
allow_credentials=allow_credentials,
|
||||
)
|
||||
|
||||
# Store challenge for verification
|
||||
_CHALLENGES["login"] = options.challenge
|
||||
|
||||
return _serialize_options(options)
|
||||
|
||||
|
||||
def complete_authentication(request, credential: dict, stored_credential_id: str, stored_public_key: str) -> dict:
|
||||
"""Complete WebAuthn authentication.
|
||||
Returns {'ok': True, 'credential_id': str} or raises on failure.
|
||||
"""
|
||||
rp_id = _get_rp_id(request)
|
||||
origin = _get_origin(request)
|
||||
|
||||
expected_challenge = _CHALLENGES.pop("login", None)
|
||||
if not expected_challenge:
|
||||
raise ValueError("No authentication challenge found. Please start again.")
|
||||
|
||||
try:
|
||||
# Library expects rawId field (browser-native camelCase, base64url string)
|
||||
if "rawId" not in credential:
|
||||
credential["rawId"] = credential.get("id", "")
|
||||
|
||||
verification = verify_authentication_response(
|
||||
credential=credential,
|
||||
expected_challenge=expected_challenge,
|
||||
expected_rp_id=rp_id,
|
||||
expected_origin=origin,
|
||||
credential_public_key=bytes.fromhex(stored_public_key),
|
||||
credential_current_sign_count=0, # we don't track sign count
|
||||
require_user_verification=False,
|
||||
)
|
||||
except (InvalidAuthenticationResponse, Exception) as e:
|
||||
raise ValueError(f"Authentication verification failed: {e}")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"credential_id": verification.credential_id.hex(),
|
||||
}
|
||||
Reference in New Issue
Block a user