First Push

This commit is contained in:
喆OvO
2026-08-06 11:11:11 +08:00
commit 0fef309972
551 changed files with 15793 additions and 0 deletions
View File
+142
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+471
View File
@@ -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)
+119
View File
@@ -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
+49
View File
@@ -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()})
+55
View File
@@ -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
+774
View File
@@ -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)
+312
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+86
View File
@@ -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
+114
View File
@@ -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))
+267
View File
@@ -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())
+86
View File
@@ -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
+680
View File
@@ -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
+255
View File
@@ -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(),
}