New Database
This commit is contained in:
+118
-68
@@ -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
|
||||
# ════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user