New Database

This commit is contained in:
喆OvO
2026-08-11 22:07:55 +08:00
parent cbbb2e6d34
commit ceb4404c6a
28 changed files with 1825 additions and 768 deletions
+118 -68
View File
@@ -8,13 +8,11 @@ Multi-user rules enforced here:
the MFA setup page until they configure (and verify) one real login method,
at which point the temp TOTP is invalidated.
"""
import json
import os
import queue
import io
import time as _time
import secrets
from datetime import datetime
from flask import (
Blueprint, request, jsonify, Response,
render_template, current_app, stream_with_context,
@@ -23,15 +21,15 @@ from flask import (
from backend import calendar_mgr, weather_mgr, alarm_mgr, pomodoro_mgr, countdown_mgr
from backend.auth import (
login_required, login_or_temp_required, display_login_required, admin_required,
is_authenticated, is_temp_pending, is_admin, current_user_id,
is_authenticated, is_temp_pending, current_user_id,
verify_login, verify_totp, verify_recovery_code,
check_rate_limit, record_failure, record_success,
require_reauth, mark_reauth, generate_recovery_codes, generate_temp_password,
require_reauth, mark_reauth, generate_recovery_codes,
load_auth_context, issue_tokens, set_refresh_cookie, clear_refresh_cookie,
rotate_refresh, logout_current_device, load_page_context, REFRESH_COOKIE,
)
from backend.models import (
get_auth_config, update_auth_config, set_temp_password, clear_temp_password,
get_auth_config, update_auth_config, clear_temp_password,
set_recovery_codes, get_recovery_codes, has_mfa_configured,
get_display_config, set_display_mode, set_custom_html,
set_custom_html_template, apply_custom_html,
@@ -39,6 +37,10 @@ from backend.models import (
is_temp_account, is_admin_user, finalize_temp_account,
find_user_by_credential_id, bump_all_session_versions,
bump_user_session_version, revoke_user_tokens, revoke_all_tokens,
get_db, _read_db_version,
)
from backend.db_admin import (
export_database_json, import_blob, detect_format,
)
from backend.webauthn_mgr import (
begin_registration, complete_registration,
@@ -64,6 +66,14 @@ def _load_token():
load_auth_context()
@api.before_request
def _adjust_upload_limit():
"""Database import accepts large legacy .db files; raise this request's own
body limit (per-request setter, no global race) instead of the global 5MB cap."""
if request.path == "/api/admin/db/import" and request.method == "POST":
request.max_content_length = 200 * 1024 * 1024
@api.before_request
def _check_origin():
"""Protect state-changing requests against CSRF by verifying Origin/Referer.
@@ -107,12 +117,6 @@ def login_page():
return render_template("login.html", next=next_url)
@api.route("/register-request")
def register_request_page():
"""Public page where a prospective user fills in their info (admin approves later)."""
return render_template("register_request.html")
@api.route("/passkey")
def passkey_page():
"""Passkey bridge page: loaded inside the ASWebAuthenticationSession sheet
@@ -492,39 +496,6 @@ def api_auth_status():
})
# ════════════════════════════════════════════
# USER SELF-SERVICE REQUEST
# ════════════════════════════════════════════
# The user fills in their info; the admin confirms it and a temp account
# (with a server-held temp TOTP) is generated automatically.
_REG_REQ_LIMIT: dict[str, list] = {} # ip -> [timestamps]
@api.route("/api/auth/register-request", methods=["POST"])
def api_register_request():
"""Public: a prospective user submits their remark for admin approval.
Rate-limited per IP (5 requests per 10 minutes)."""
ip = request.remote_addr or "unknown"
now = _time.time()
stamps = [t for t in _REG_REQ_LIMIT.get(ip, []) if now - t < 600]
if len(stamps) >= 5:
_REG_REQ_LIMIT[ip] = stamps
return jsonify({"error": "申请过于频繁,请稍后再试"}), 429
stamps.append(now)
_REG_REQ_LIMIT[ip] = stamps
data = request.get_json() or {}
remark = (data.get("remark") or "").strip()
if not remark:
return jsonify({"error": "请填写你的称呼/备注"}), 400
if len(remark) > 64:
return jsonify({"error": "备注过长"}), 400
create_user(remark=remark, role="user", is_temp=0, status="pending")
return jsonify({"ok": True, "message": "已提交申请,请等待管理员确认"}), 201
# ════════════════════════════════════════════
# MFA CONFIGURATION
# ════════════════════════════════════════════
@@ -842,30 +813,6 @@ def admin_create_user():
return jsonify({"ok": True, "user": _user_payload(uid)}), 201
@api.route("/api/admin/users/confirm", methods=["POST"])
@admin_required
def admin_confirm_request():
"""Confirm a pending self-service request → temp account with temp TOTP."""
if not require_reauth():
return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403
data = request.get_json() or {}
request_id = data.get("request_id")
if request_id is None:
return jsonify({"error": "request_id required"}), 400
u = get_user(request_id)
if not u:
return jsonify({"error": "申请不存在"}), 404
if u.get("status") != "pending":
return jsonify({"error": "该申请已处理"}), 400
import pyotp
secret = pyotp.random_base32()
update_user(request_id, is_temp=1, temp_totp_secret=secret, status="active")
return jsonify({"ok": True, "user": _user_payload(request_id)})
@api.route("/api/admin/users/<int:user_id>", methods=["PUT"])
@admin_required
def admin_update_user(user_id):
@@ -1052,6 +999,109 @@ def admin_kick_all_users():
return jsonify({"ok": True})
# ════════════════════════════════════════════
# DATABASE ADMINISTRATION (Root)
# ════════════════════════════════════════════
# Importing an old SQLite database, exporting a backup, or importing a backup
# replaces ALL current data — Root-only and re-auth required.
def _require_root():
"""Database administration is Root-only (id 0)."""
if _cur() != 0:
return jsonify({"error": "仅 Root 用户可执行数据库管理操作"}), 403
return None
@api.route("/api/admin/db/status", methods=["GET"])
@admin_required
def admin_db_status():
denied = _require_root()
if denied:
return denied
db_path = current_app.config.get("DB_PATH", "data.db")
conn = get_db(db_path)
try:
version = _read_db_version(conn) or 0
server = conn.execute("SELECT version()").fetchone()[0]
rows = conn.execute(
"SELECT table_name FROM information_schema.tables "
"WHERE table_schema = current_schema() ORDER BY table_name"
).fetchall()
tables = [r["table_name"] for r in rows]
finally:
conn.close()
return jsonify({
"database": "postgresql",
"db_version": version,
"server": server,
"tables": tables,
"backup_format": "iot-dboard-backup",
})
@api.route("/api/admin/db/export", methods=["GET"])
@admin_required
def admin_db_export():
denied = _require_root()
if denied:
return denied
if not require_reauth():
return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403
db_path = current_app.config.get("DB_PATH", "data.db")
try:
payload = export_database_json(db_path)
except Exception as e:
return jsonify({"error": f"导出失败: {e}"}), 500
from datetime import datetime
fname = f"iot-dboard-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
return Response(
payload,
mimetype="application/json",
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
@api.route("/api/admin/db/import", methods=["POST"])
@admin_required
def admin_db_import():
denied = _require_root()
if denied:
return denied
if not require_reauth():
return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403
file = request.files.get("file")
if not file or not file.filename:
return jsonify({"error": "请选择要导入的数据库文件"}), 400
data = file.read()
if not data:
return jsonify({"error": "文件为空"}), 400
if len(data) > 200 * 1024 * 1024:
return jsonify({"error": "文件超过 200MB 限制"}), 413
db_path = current_app.config.get("DB_PATH", "data.db")
fmt = detect_format(data)
try:
stats = import_blob(db_path, data)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": f"导入失败(已回滚,当前数据未受影响): {e}"}), 500
total = sum(v for k, v in (stats or {}).items() if isinstance(v, int))
skipped = (stats or {}).get("_skipped_tables") or []
msg = f"导入成功:{total} 行数据已恢复。所有设备已下线,请重新登录。"
if skipped:
msg += f" 以下表因结构与当前版本不兼容被跳过:{', '.join(skipped)}。"
return jsonify({
"ok": True,
"format": fmt,
"stats": stats,
"message": msg,
"session_invalidated": True,
})
# ════════════════════════════════════════════
# DISPLAY PAGES
# ════════════════════════════════════════════