feat: 完成 Passkey 能力与前后台加载优化
更新说明:\n1. 新增用户端与管理员端 Passkey 登录/注册/设备管理(最多3台,支持设备备注、删除设备)。\n2. 修复 Passkey 注册与登录流程中的浏览器/证书/CSRF相关问题,增强错误提示。\n3. 前台登录页改为独立入口,首屏仅加载必要资源,其他页面按需加载。\n4. 系统配置页改为静默获取金山文档状态,避免首屏阻塞,并优化状态展示为“检测中/已登录/未登录/异常”。\n5. 补充后端接口与页面渲染适配,修复多入口下样式依赖注入问题。\n6. 同步更新前后台构建产物与相关静态资源。
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
@@ -16,6 +17,19 @@ from routes.admin_api import admin_api_bp
|
||||
from routes.decorators import admin_required
|
||||
from services.accounts_service import load_user_accounts
|
||||
from services.checkpoints import get_checkpoint_mgr
|
||||
from services.passkeys import (
|
||||
MAX_PASSKEYS_PER_OWNER,
|
||||
encode_credential_id,
|
||||
get_credential_transports,
|
||||
get_expected_origins,
|
||||
get_rp_id,
|
||||
is_challenge_valid,
|
||||
make_authentication_options,
|
||||
make_registration_options,
|
||||
normalize_device_name,
|
||||
verify_authentication,
|
||||
verify_registration,
|
||||
)
|
||||
from services.state import (
|
||||
safe_get_user_accounts_snapshot,
|
||||
safe_verify_and_consume_captcha,
|
||||
@@ -32,6 +46,8 @@ from services.tasks import submit_account_task
|
||||
|
||||
logger = get_logger("app")
|
||||
config = get_config()
|
||||
_ADMIN_PASSKEY_LOGIN_SESSION_KEY = "admin_passkey_login_state"
|
||||
_ADMIN_PASSKEY_REGISTER_SESSION_KEY = "admin_passkey_register_state"
|
||||
|
||||
|
||||
def _admin_reauth_required() -> bool:
|
||||
@@ -46,6 +62,27 @@ def _require_admin_reauth():
|
||||
return jsonify({"error": "需要二次确认", "code": "reauth_required"}), 401
|
||||
return None
|
||||
|
||||
|
||||
def _parse_credential_payload(data: dict) -> dict | None:
|
||||
credential = data.get("credential")
|
||||
if isinstance(credential, dict):
|
||||
return credential
|
||||
if isinstance(credential, str):
|
||||
try:
|
||||
parsed = json.loads(credential)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _truncate_text(value, max_len: int = 300) -> str:
|
||||
text = str(value or "").strip()
|
||||
if len(text) > max_len:
|
||||
return f"{text[:max_len]}..."
|
||||
return text
|
||||
|
||||
|
||||
@admin_api_bp.route("/debug-config", methods=["GET"])
|
||||
@admin_required
|
||||
def debug_config():
|
||||
@@ -70,6 +107,169 @@ def debug_config():
|
||||
)
|
||||
|
||||
|
||||
@admin_api_bp.route("/passkeys/login/options", methods=["POST"])
|
||||
@require_ip_not_locked
|
||||
def admin_passkey_login_options():
|
||||
"""管理员 Passkey 登录:获取 assertion challenge。"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = str(data.get("username", "") or "").strip()
|
||||
|
||||
client_ip = get_rate_limit_ip()
|
||||
mode = "named" if username else "discoverable"
|
||||
username_key = f"admin-passkey:{username}" if username else "admin-passkey:discoverable"
|
||||
|
||||
is_locked, remaining = check_login_ip_user_locked(client_ip, username_key)
|
||||
if is_locked:
|
||||
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}秒"
|
||||
return jsonify({"error": f"账号短时锁定,请{wait_hint}后再试"}), 429
|
||||
|
||||
allowed, error_msg = check_ip_request_rate(client_ip, "login")
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
|
||||
allowed, error_msg = check_login_rate_limits(client_ip, username_key)
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
|
||||
admin_id = 0
|
||||
allow_credential_ids = []
|
||||
if mode == "named":
|
||||
admin_row = database.get_admin_by_username(username)
|
||||
if not admin_row:
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "账号或Passkey不可用"}), 400
|
||||
|
||||
admin_id = int(admin_row["id"])
|
||||
passkeys = database.list_passkeys("admin", admin_id)
|
||||
if not passkeys:
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "该管理员尚未绑定Passkey"}), 400
|
||||
allow_credential_ids = [str(item.get("credential_id") or "").strip() for item in passkeys if item.get("credential_id")]
|
||||
|
||||
try:
|
||||
rp_id = get_rp_id(request)
|
||||
expected_origins = get_expected_origins(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"[passkey] 管理员登录 options 失败(mode={mode}, username={username or '-'}) : {e}")
|
||||
return jsonify({"error": "Passkey配置异常,请联系管理员"}), 500
|
||||
|
||||
options = make_authentication_options(rp_id=rp_id, allow_credential_ids=allow_credential_ids)
|
||||
challenge = str(options.get("challenge") or "").strip()
|
||||
if not challenge:
|
||||
return jsonify({"error": "生成Passkey挑战失败"}), 500
|
||||
|
||||
session[_ADMIN_PASSKEY_LOGIN_SESSION_KEY] = {
|
||||
"mode": mode,
|
||||
"username": username,
|
||||
"admin_id": int(admin_id),
|
||||
"challenge": challenge,
|
||||
"rp_id": rp_id,
|
||||
"expected_origins": expected_origins,
|
||||
"username_key": username_key,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
session.modified = True
|
||||
return jsonify({"publicKey": options})
|
||||
|
||||
|
||||
@admin_api_bp.route("/passkeys/login/verify", methods=["POST"])
|
||||
@require_ip_not_locked
|
||||
def admin_passkey_login_verify():
|
||||
"""管理员 Passkey 登录:校验 assertion 并登录。"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
request_username = str(data.get("username", "") or "").strip()
|
||||
credential = _parse_credential_payload(data)
|
||||
if not credential:
|
||||
return jsonify({"error": "Passkey参数缺失"}), 400
|
||||
|
||||
state = session.get(_ADMIN_PASSKEY_LOGIN_SESSION_KEY) or {}
|
||||
if not state:
|
||||
return jsonify({"error": "Passkey挑战不存在或已过期,请重试"}), 400
|
||||
if not is_challenge_valid(state.get("created_at")):
|
||||
session.pop(_ADMIN_PASSKEY_LOGIN_SESSION_KEY, None)
|
||||
return jsonify({"error": "Passkey挑战已过期,请重试"}), 400
|
||||
|
||||
mode = str(state.get("mode") or "named")
|
||||
if mode not in {"named", "discoverable"}:
|
||||
session.pop(_ADMIN_PASSKEY_LOGIN_SESSION_KEY, None)
|
||||
return jsonify({"error": "Passkey状态异常,请重试"}), 400
|
||||
|
||||
expected_username = str(state.get("username") or "").strip()
|
||||
username = expected_username
|
||||
if mode == "named":
|
||||
if not expected_username:
|
||||
session.pop(_ADMIN_PASSKEY_LOGIN_SESSION_KEY, None)
|
||||
return jsonify({"error": "Passkey状态异常,请重试"}), 400
|
||||
if request_username and request_username != expected_username:
|
||||
return jsonify({"error": "用户名与挑战不匹配,请重试"}), 400
|
||||
else:
|
||||
username = request_username
|
||||
|
||||
client_ip = get_rate_limit_ip()
|
||||
username_key = str(state.get("username_key") or "").strip() or (
|
||||
f"admin-passkey:{expected_username}" if mode == "named" else "admin-passkey:discoverable"
|
||||
)
|
||||
|
||||
is_locked, remaining = check_login_ip_user_locked(client_ip, username_key)
|
||||
if is_locked:
|
||||
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}秒"
|
||||
return jsonify({"error": f"账号短时锁定,请{wait_hint}后再试"}), 429
|
||||
|
||||
credential_id = str(credential.get("id") or credential.get("rawId") or "").strip()
|
||||
if not credential_id:
|
||||
return jsonify({"error": "Passkey参数无效"}), 400
|
||||
|
||||
passkey = database.get_passkey_by_credential_id(credential_id)
|
||||
if not passkey:
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "Passkey不存在或已删除"}), 401
|
||||
if str(passkey.get("owner_type") or "") != "admin":
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "Passkey不属于管理员账号"}), 401
|
||||
if mode == "named" and int(passkey.get("owner_id") or 0) != int(state.get("admin_id") or 0):
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "Passkey与管理员账号不匹配"}), 401
|
||||
|
||||
try:
|
||||
_, verified = verify_authentication(
|
||||
credential=credential,
|
||||
expected_challenge=str(state.get("challenge") or ""),
|
||||
expected_rp_id=str(state.get("rp_id") or ""),
|
||||
expected_origins=list(state.get("expected_origins") or []),
|
||||
credential_public_key=str(passkey.get("public_key") or ""),
|
||||
credential_current_sign_count=int(passkey.get("sign_count") or 0),
|
||||
)
|
||||
verified_credential_id = encode_credential_id(verified.credential_id)
|
||||
if verified_credential_id != str(passkey.get("credential_id") or ""):
|
||||
raise ValueError("credential_id mismatch")
|
||||
except Exception as e:
|
||||
logger.warning(f"[passkey] 管理员登录验签失败(mode={mode}, username={expected_username or request_username or '-'}) : {e}")
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "Passkey验证失败"}), 401
|
||||
|
||||
admin_id = int(passkey.get("owner_id") or 0)
|
||||
admin_row = database.get_admin_by_id(admin_id)
|
||||
if not admin_row:
|
||||
return jsonify({"error": "管理员账号不存在"}), 401
|
||||
admin_username = str(admin_row.get("username") or "").strip() or username or f"admin-{admin_id}"
|
||||
|
||||
database.update_passkey_usage(int(passkey["id"]), int(verified.new_sign_count))
|
||||
clear_login_failures(client_ip, username_key)
|
||||
admin_login_key = f"admin-passkey:{admin_username}"
|
||||
if admin_login_key and admin_login_key != username_key:
|
||||
clear_login_failures(client_ip, admin_login_key)
|
||||
|
||||
session.pop(_ADMIN_PASSKEY_LOGIN_SESSION_KEY, None)
|
||||
session.pop("admin_id", None)
|
||||
session.pop("admin_username", None)
|
||||
session["admin_id"] = admin_id
|
||||
session["admin_username"] = admin_username
|
||||
session["admin_reauth_until"] = time.time() + int(config.ADMIN_REAUTH_WINDOW_SECONDS)
|
||||
session.permanent = True
|
||||
session.modified = True
|
||||
return jsonify({"success": True, "redirect": "/yuyx/admin", "username": admin_username})
|
||||
|
||||
|
||||
@admin_api_bp.route("/login", methods=["POST"])
|
||||
@require_ip_not_locked
|
||||
def admin_login():
|
||||
@@ -161,6 +361,164 @@ def admin_logout():
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@admin_api_bp.route("/admin/passkeys", methods=["GET"])
|
||||
@admin_required
|
||||
def list_admin_passkeys():
|
||||
admin_id = int(session.get("admin_id") or 0)
|
||||
rows = database.list_passkeys("admin", admin_id)
|
||||
items = []
|
||||
for row in rows:
|
||||
credential_id = str(row.get("credential_id") or "")
|
||||
preview = f"{credential_id[:8]}...{credential_id[-6:]}" if len(credential_id) > 16 else credential_id
|
||||
items.append(
|
||||
{
|
||||
"id": int(row.get("id")),
|
||||
"device_name": str(row.get("device_name") or ""),
|
||||
"credential_id_preview": preview,
|
||||
"created_at": row.get("created_at"),
|
||||
"last_used_at": row.get("last_used_at"),
|
||||
"transports": str(row.get("transports") or ""),
|
||||
}
|
||||
)
|
||||
return jsonify({"items": items, "limit": MAX_PASSKEYS_PER_OWNER})
|
||||
|
||||
|
||||
@admin_api_bp.route("/admin/passkeys/register/options", methods=["POST"])
|
||||
@admin_required
|
||||
def admin_passkey_register_options():
|
||||
admin_id = int(session.get("admin_id") or 0)
|
||||
admin_username = str(session.get("admin_username") or "").strip() or f"admin-{admin_id}"
|
||||
|
||||
count = database.count_passkeys("admin", admin_id)
|
||||
if count >= MAX_PASSKEYS_PER_OWNER:
|
||||
return jsonify({"error": f"最多可绑定{MAX_PASSKEYS_PER_OWNER}台设备"}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
device_name = normalize_device_name(data.get("device_name"))
|
||||
|
||||
existing = database.list_passkeys("admin", admin_id)
|
||||
exclude_credential_ids = [str(item.get("credential_id") or "").strip() for item in existing if item.get("credential_id")]
|
||||
|
||||
try:
|
||||
rp_id = get_rp_id(request)
|
||||
expected_origins = get_expected_origins(request)
|
||||
options = make_registration_options(
|
||||
rp_id=rp_id,
|
||||
rp_name="知识管理平台",
|
||||
user_name=admin_username,
|
||||
user_display_name=admin_username,
|
||||
user_id_bytes=f"admin:{admin_id}".encode("utf-8"),
|
||||
exclude_credential_ids=exclude_credential_ids,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[passkey] 管理员注册 options 失败(admin_id={admin_id}): {e}")
|
||||
return jsonify({"error": "生成Passkey挑战失败"}), 500
|
||||
|
||||
challenge = str(options.get("challenge") or "").strip()
|
||||
if not challenge:
|
||||
return jsonify({"error": "生成Passkey挑战失败"}), 500
|
||||
|
||||
session[_ADMIN_PASSKEY_REGISTER_SESSION_KEY] = {
|
||||
"admin_id": admin_id,
|
||||
"challenge": challenge,
|
||||
"rp_id": rp_id,
|
||||
"expected_origins": expected_origins,
|
||||
"device_name": device_name,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
session.modified = True
|
||||
return jsonify({"publicKey": options, "limit": MAX_PASSKEYS_PER_OWNER})
|
||||
|
||||
|
||||
@admin_api_bp.route("/admin/passkeys/register/verify", methods=["POST"])
|
||||
@admin_required
|
||||
def admin_passkey_register_verify():
|
||||
admin_id = int(session.get("admin_id") or 0)
|
||||
state = session.get(_ADMIN_PASSKEY_REGISTER_SESSION_KEY) or {}
|
||||
if not state:
|
||||
return jsonify({"error": "Passkey挑战不存在或已过期,请重试"}), 400
|
||||
if int(state.get("admin_id") or 0) != admin_id:
|
||||
return jsonify({"error": "Passkey挑战与当前管理员不匹配"}), 400
|
||||
if not is_challenge_valid(state.get("created_at")):
|
||||
session.pop(_ADMIN_PASSKEY_REGISTER_SESSION_KEY, None)
|
||||
return jsonify({"error": "Passkey挑战已过期,请重试"}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
credential = _parse_credential_payload(data)
|
||||
if not credential:
|
||||
return jsonify({"error": "Passkey参数缺失"}), 400
|
||||
|
||||
count = database.count_passkeys("admin", admin_id)
|
||||
if count >= MAX_PASSKEYS_PER_OWNER:
|
||||
session.pop(_ADMIN_PASSKEY_REGISTER_SESSION_KEY, None)
|
||||
return jsonify({"error": f"最多可绑定{MAX_PASSKEYS_PER_OWNER}台设备"}), 400
|
||||
|
||||
try:
|
||||
verified = verify_registration(
|
||||
credential=credential,
|
||||
expected_challenge=str(state.get("challenge") or ""),
|
||||
expected_rp_id=str(state.get("rp_id") or ""),
|
||||
expected_origins=list(state.get("expected_origins") or []),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[passkey] 管理员注册验签失败(admin_id={admin_id}): {e}")
|
||||
return jsonify({"error": "Passkey验证失败,请重试"}), 400
|
||||
|
||||
device_name = normalize_device_name(data.get("device_name") if "device_name" in data else state.get("device_name"))
|
||||
|
||||
created_id = database.create_passkey(
|
||||
"admin",
|
||||
admin_id,
|
||||
credential_id=encode_credential_id(verified.credential_id),
|
||||
public_key=encode_credential_id(verified.credential_public_key),
|
||||
sign_count=int(verified.sign_count or 0),
|
||||
device_name=device_name,
|
||||
transports=get_credential_transports(credential),
|
||||
aaguid=str(verified.aaguid or ""),
|
||||
)
|
||||
if not created_id:
|
||||
return jsonify({"error": "该Passkey已绑定,或保存失败"}), 400
|
||||
|
||||
session.pop(_ADMIN_PASSKEY_REGISTER_SESSION_KEY, None)
|
||||
return jsonify({"success": True, "id": int(created_id)})
|
||||
|
||||
|
||||
@admin_api_bp.route("/admin/passkeys/<int:passkey_id>", methods=["DELETE"])
|
||||
@admin_required
|
||||
def delete_admin_passkey(passkey_id):
|
||||
admin_id = int(session.get("admin_id") or 0)
|
||||
ok = database.delete_passkey("admin", admin_id, int(passkey_id))
|
||||
if ok:
|
||||
return jsonify({"success": True})
|
||||
return jsonify({"error": "设备不存在或已删除"}), 404
|
||||
|
||||
|
||||
@admin_api_bp.route("/admin/passkeys/client-error", methods=["POST"])
|
||||
@admin_required
|
||||
def report_admin_passkey_client_error():
|
||||
"""上报管理员端浏览器 Passkey 失败详情,便于排查兼容性问题。"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
error_name = _truncate_text(data.get("name"), 120)
|
||||
error_message = _truncate_text(data.get("message"), 400)
|
||||
error_code = _truncate_text(data.get("code"), 120)
|
||||
ua = _truncate_text(data.get("user_agent") or request.headers.get("User-Agent", ""), 300)
|
||||
stage = _truncate_text(data.get("stage"), 80)
|
||||
source = _truncate_text(data.get("source"), 80)
|
||||
admin_id = int(session.get("admin_id") or 0)
|
||||
|
||||
logger.warning(
|
||||
"[passkey][client-error][admin] admin_id=%s stage=%s source=%s name=%s code=%s message=%s ua=%s",
|
||||
admin_id,
|
||||
stage or "-",
|
||||
source or "-",
|
||||
error_name or "-",
|
||||
error_code or "-",
|
||||
error_message or "-",
|
||||
ua or "-",
|
||||
)
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@admin_api_bp.route("/admin/reauth", methods=["POST"])
|
||||
@admin_required
|
||||
def admin_reauth():
|
||||
|
||||
@@ -60,8 +60,8 @@ def get_kdocs_status_api():
|
||||
status = uploader.get_status()
|
||||
live = str(request.args.get("live", "")).lower() in ("1", "true", "yes")
|
||||
|
||||
# 重启后首次查询时(last_login_ok is None)自动做一次实时状态校验
|
||||
should_live_check = live or status.get("last_login_ok") is None
|
||||
# 仅在显式 live=1 时做实时状态校验,默认返回缓存状态,避免阻塞页面加载
|
||||
should_live_check = live
|
||||
if should_live_check:
|
||||
live_status = uploader.refresh_login_status()
|
||||
if live_status.get("success"):
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import random
|
||||
import secrets
|
||||
import threading
|
||||
@@ -20,6 +21,15 @@ from flask_login import login_required, login_user, logout_user
|
||||
from routes.pages import render_app_spa_or_legacy
|
||||
from services.accounts_service import load_user_accounts
|
||||
from services.models import User
|
||||
from services.passkeys import (
|
||||
encode_credential_id,
|
||||
get_expected_origins,
|
||||
get_rp_id,
|
||||
is_challenge_valid,
|
||||
make_authentication_options,
|
||||
normalize_device_name,
|
||||
verify_authentication,
|
||||
)
|
||||
from services.state import (
|
||||
check_ip_request_rate,
|
||||
check_email_rate_limit,
|
||||
@@ -50,6 +60,7 @@ _CAPTCHA_FONT_PATHS = [
|
||||
]
|
||||
_CAPTCHA_FONT = None
|
||||
_CAPTCHA_FONT_LOCK = threading.Lock()
|
||||
_USER_PASSKEY_LOGIN_SESSION_KEY = "user_passkey_login_state"
|
||||
|
||||
|
||||
def _get_json_payload() -> dict:
|
||||
@@ -194,6 +205,19 @@ def _send_login_security_alert_if_needed(user: dict, username: str, client_ip: s
|
||||
pass
|
||||
|
||||
|
||||
def _parse_credential_payload(data: dict) -> dict | None:
|
||||
credential = data.get("credential")
|
||||
if isinstance(credential, dict):
|
||||
return credential
|
||||
if isinstance(credential, str):
|
||||
try:
|
||||
parsed = json.loads(credential)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
@api_auth_bp.route("/api/register", methods=["POST"])
|
||||
@require_ip_not_locked
|
||||
def register():
|
||||
@@ -538,6 +562,166 @@ def generate_captcha():
|
||||
return jsonify({"error": "验证码服务暂不可用,请联系管理员安装PIL库"}), 503
|
||||
|
||||
|
||||
@api_auth_bp.route("/api/passkeys/login/options", methods=["POST"])
|
||||
@require_ip_not_locked
|
||||
def user_passkey_login_options():
|
||||
"""用户 Passkey 登录:获取 assertion challenge。"""
|
||||
data = _get_json_payload()
|
||||
username = str(data.get("username", "") or "").strip()
|
||||
client_ip = get_rate_limit_ip()
|
||||
mode = "named" if username else "discoverable"
|
||||
username_key = f"passkey:{username}" if username else "passkey:discoverable"
|
||||
|
||||
is_locked, remaining = check_login_ip_user_locked(client_ip, username_key)
|
||||
if is_locked:
|
||||
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}秒"
|
||||
return jsonify({"error": f"账号短时锁定,请{wait_hint}后再试"}), 429
|
||||
|
||||
allowed, error_msg = check_ip_request_rate(client_ip, "login")
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
|
||||
allowed, error_msg = check_login_rate_limits(client_ip, username_key)
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
|
||||
user_id = 0
|
||||
allow_credential_ids = []
|
||||
if mode == "named":
|
||||
user = database.get_user_by_username(username)
|
||||
if not user or user.get("status") != "approved":
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "账号或Passkey不可用"}), 400
|
||||
|
||||
user_id = int(user["id"])
|
||||
passkeys = database.list_passkeys("user", user_id)
|
||||
if not passkeys:
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "该账号尚未绑定Passkey"}), 400
|
||||
allow_credential_ids = [str(item.get("credential_id") or "").strip() for item in passkeys if item.get("credential_id")]
|
||||
|
||||
try:
|
||||
rp_id = get_rp_id(request)
|
||||
expected_origins = get_expected_origins(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"[passkey] 生成登录 challenge 失败(mode={mode}, username={username or '-'}) : {e}")
|
||||
return jsonify({"error": "Passkey配置异常,请联系管理员"}), 500
|
||||
|
||||
options = make_authentication_options(rp_id=rp_id, allow_credential_ids=allow_credential_ids)
|
||||
challenge = str(options.get("challenge") or "").strip()
|
||||
if not challenge:
|
||||
return jsonify({"error": "生成Passkey挑战失败"}), 500
|
||||
|
||||
session[_USER_PASSKEY_LOGIN_SESSION_KEY] = {
|
||||
"mode": mode,
|
||||
"username": username,
|
||||
"user_id": int(user_id),
|
||||
"challenge": challenge,
|
||||
"rp_id": rp_id,
|
||||
"expected_origins": expected_origins,
|
||||
"username_key": username_key,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
session.modified = True
|
||||
return jsonify({"publicKey": options})
|
||||
|
||||
|
||||
@api_auth_bp.route("/api/passkeys/login/verify", methods=["POST"])
|
||||
@require_ip_not_locked
|
||||
def user_passkey_login_verify():
|
||||
"""用户 Passkey 登录:校验 assertion 并登录。"""
|
||||
data = _get_json_payload()
|
||||
request_username = str(data.get("username", "") or "").strip()
|
||||
credential = _parse_credential_payload(data)
|
||||
if not credential:
|
||||
return jsonify({"error": "Passkey参数缺失"}), 400
|
||||
|
||||
state = session.get(_USER_PASSKEY_LOGIN_SESSION_KEY) or {}
|
||||
if not state:
|
||||
return jsonify({"error": "Passkey挑战不存在或已过期,请重试"}), 400
|
||||
if not is_challenge_valid(state.get("created_at")):
|
||||
session.pop(_USER_PASSKEY_LOGIN_SESSION_KEY, None)
|
||||
return jsonify({"error": "Passkey挑战已过期,请重试"}), 400
|
||||
|
||||
mode = str(state.get("mode") or "named")
|
||||
if mode not in {"named", "discoverable"}:
|
||||
session.pop(_USER_PASSKEY_LOGIN_SESSION_KEY, None)
|
||||
return jsonify({"error": "Passkey状态异常,请重试"}), 400
|
||||
|
||||
expected_username = str(state.get("username") or "").strip()
|
||||
username = expected_username
|
||||
if mode == "named":
|
||||
if not expected_username:
|
||||
session.pop(_USER_PASSKEY_LOGIN_SESSION_KEY, None)
|
||||
return jsonify({"error": "Passkey状态异常,请重试"}), 400
|
||||
if request_username and request_username != expected_username:
|
||||
return jsonify({"error": "用户名与挑战不匹配,请重试"}), 400
|
||||
else:
|
||||
username = request_username
|
||||
|
||||
client_ip = get_rate_limit_ip()
|
||||
username_key = str(state.get("username_key") or "").strip() or (
|
||||
f"passkey:{expected_username}" if mode == "named" else "passkey:discoverable"
|
||||
)
|
||||
|
||||
is_locked, remaining = check_login_ip_user_locked(client_ip, username_key)
|
||||
if is_locked:
|
||||
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}秒"
|
||||
return jsonify({"error": f"账号短时锁定,请{wait_hint}后再试"}), 429
|
||||
|
||||
credential_id = str(credential.get("id") or credential.get("rawId") or "").strip()
|
||||
if not credential_id:
|
||||
return jsonify({"error": "Passkey参数无效"}), 400
|
||||
|
||||
passkey = database.get_passkey_by_credential_id(credential_id)
|
||||
if not passkey:
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "Passkey不存在或已删除"}), 401
|
||||
if str(passkey.get("owner_type") or "") != "user":
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "Passkey不属于用户账号"}), 401
|
||||
if mode == "named" and int(passkey.get("owner_id") or 0) != int(state.get("user_id") or 0):
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "Passkey与账号不匹配"}), 401
|
||||
|
||||
try:
|
||||
parsed_credential, verified = verify_authentication(
|
||||
credential=credential,
|
||||
expected_challenge=str(state.get("challenge") or ""),
|
||||
expected_rp_id=str(state.get("rp_id") or ""),
|
||||
expected_origins=list(state.get("expected_origins") or []),
|
||||
credential_public_key=str(passkey.get("public_key") or ""),
|
||||
credential_current_sign_count=int(passkey.get("sign_count") or 0),
|
||||
)
|
||||
verified_credential_id = encode_credential_id(verified.credential_id)
|
||||
if verified_credential_id != str(passkey.get("credential_id") or ""):
|
||||
raise ValueError("credential_id mismatch")
|
||||
except Exception as e:
|
||||
logger.warning(f"[passkey] 用户登录验签失败(mode={mode}, username={expected_username or request_username or '-'}) : {e}")
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "Passkey验证失败"}), 401
|
||||
|
||||
user_id = int(passkey.get("owner_id") or 0)
|
||||
user = database.get_user_by_id(user_id)
|
||||
if not user or user.get("status") != "approved":
|
||||
return jsonify({"error": "账号不可用"}), 401
|
||||
|
||||
database.update_passkey_usage(int(passkey["id"]), int(verified.new_sign_count))
|
||||
clear_login_failures(client_ip, username_key)
|
||||
user_login_key = f"passkey:{str(user.get('username') or '').strip()}"
|
||||
if user_login_key and user_login_key != username_key:
|
||||
clear_login_failures(client_ip, user_login_key)
|
||||
session.pop(_USER_PASSKEY_LOGIN_SESSION_KEY, None)
|
||||
|
||||
user_obj = User(user_id)
|
||||
login_user(user_obj)
|
||||
load_user_accounts(user_id)
|
||||
|
||||
resolved_username = str(user.get("username") or "").strip() or username or f"user-{user_id}"
|
||||
_send_login_security_alert_if_needed(user=user, username=resolved_username, client_ip=client_ip)
|
||||
return jsonify({"success": True, "credential_id": parsed_credential.id, "username": resolved_username})
|
||||
|
||||
|
||||
@api_auth_bp.route("/api/login", methods=["POST"])
|
||||
@require_ip_not_locked
|
||||
def login():
|
||||
|
||||
@@ -79,6 +79,27 @@ def _parse_browse_type_or_error(raw_value, *, default=BROWSE_TYPE_SHOULD_READ):
|
||||
return browse_type, None
|
||||
|
||||
|
||||
def _parse_optional_pagination(default_limit: int = 20, *, max_limit: int = 200) -> tuple[int | None, int | None, bool]:
|
||||
limit_raw = request.args.get("limit")
|
||||
offset_raw = request.args.get("offset")
|
||||
if (limit_raw is None) and (offset_raw is None):
|
||||
return None, None, False
|
||||
|
||||
try:
|
||||
limit = int(limit_raw if limit_raw is not None else default_limit)
|
||||
except (ValueError, TypeError):
|
||||
limit = default_limit
|
||||
limit = max(1, min(limit, max_limit))
|
||||
|
||||
try:
|
||||
offset = int(offset_raw if offset_raw is not None else 0)
|
||||
except (ValueError, TypeError):
|
||||
offset = 0
|
||||
offset = max(0, offset)
|
||||
|
||||
return limit, offset, True
|
||||
|
||||
|
||||
@api_schedules_bp.route("/api/schedules", methods=["GET"])
|
||||
@login_required
|
||||
def get_user_schedules_api():
|
||||
@@ -86,6 +107,13 @@ def get_user_schedules_api():
|
||||
schedules = database.get_user_schedules(current_user.id)
|
||||
for schedule in schedules:
|
||||
schedule["account_ids"] = _parse_schedule_account_ids(schedule.get("account_ids"))
|
||||
|
||||
limit, offset, paged = _parse_optional_pagination(default_limit=12, max_limit=100)
|
||||
if paged:
|
||||
total = len(schedules)
|
||||
items = schedules[offset : offset + limit]
|
||||
return jsonify({"items": items, "total": total, "limit": limit, "offset": offset})
|
||||
|
||||
return jsonify(schedules)
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Iterator
|
||||
import database
|
||||
from app_config import get_config
|
||||
from app_security import is_safe_path
|
||||
from flask import Blueprint, jsonify, send_from_directory
|
||||
from flask import Blueprint, jsonify, request, send_from_directory
|
||||
from flask_login import current_user, login_required
|
||||
from PIL import Image, ImageOps
|
||||
from services.client_log import log_to_client
|
||||
@@ -100,6 +100,27 @@ def _remove_thumbnail(filename: str) -> None:
|
||||
os.remove(thumb_path)
|
||||
|
||||
|
||||
def _parse_optional_pagination(default_limit: int = 24, *, max_limit: int = 100) -> tuple[int | None, int | None, bool]:
|
||||
limit_raw = request.args.get("limit")
|
||||
offset_raw = request.args.get("offset")
|
||||
if (limit_raw is None) and (offset_raw is None):
|
||||
return None, None, False
|
||||
|
||||
try:
|
||||
limit = int(limit_raw if limit_raw is not None else default_limit)
|
||||
except (ValueError, TypeError):
|
||||
limit = default_limit
|
||||
limit = max(1, min(limit, max_limit))
|
||||
|
||||
try:
|
||||
offset = int(offset_raw if offset_raw is not None else 0)
|
||||
except (ValueError, TypeError):
|
||||
offset = 0
|
||||
offset = max(0, offset)
|
||||
|
||||
return limit, offset, True
|
||||
|
||||
|
||||
@api_screenshots_bp.route("/api/screenshots", methods=["GET"])
|
||||
@login_required
|
||||
def get_screenshots():
|
||||
@@ -128,6 +149,12 @@ def get_screenshots():
|
||||
for item in screenshots:
|
||||
item.pop("_created_ts", None)
|
||||
|
||||
limit, offset, paged = _parse_optional_pagination(default_limit=24, max_limit=100)
|
||||
if paged:
|
||||
total = len(screenshots)
|
||||
items = screenshots[offset : offset + limit]
|
||||
return jsonify({"items": items, "total": total, "limit": limit, "offset": offset})
|
||||
|
||||
return jsonify(screenshots)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@@ -2,19 +2,34 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import database
|
||||
import email_service
|
||||
from app_logger import get_logger
|
||||
from app_security import get_rate_limit_ip, require_ip_not_locked, validate_email, validate_password
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask import Blueprint, jsonify, request, session
|
||||
from flask_login import current_user, login_required
|
||||
from routes.pages import render_app_spa_or_legacy
|
||||
from services.passkeys import (
|
||||
MAX_PASSKEYS_PER_OWNER,
|
||||
encode_credential_id,
|
||||
get_credential_transports,
|
||||
get_expected_origins,
|
||||
get_rp_id,
|
||||
is_challenge_valid,
|
||||
make_registration_options,
|
||||
normalize_device_name,
|
||||
verify_registration,
|
||||
)
|
||||
from services.state import check_email_rate_limit, check_ip_request_rate, safe_iter_task_status_items
|
||||
from services.tasks import get_task_scheduler
|
||||
|
||||
logger = get_logger("app")
|
||||
|
||||
api_user_bp = Blueprint("api_user", __name__)
|
||||
_USER_PASSKEY_REGISTER_SESSION_KEY = "user_passkey_register_state"
|
||||
|
||||
|
||||
def _get_current_user_record():
|
||||
@@ -46,6 +61,26 @@ def _coerce_binary_flag(value, *, field_label: str):
|
||||
return value, None
|
||||
|
||||
|
||||
def _parse_credential_payload(data: dict) -> dict | None:
|
||||
credential = data.get("credential")
|
||||
if isinstance(credential, dict):
|
||||
return credential
|
||||
if isinstance(credential, str):
|
||||
try:
|
||||
parsed = json.loads(credential)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _truncate_text(value, max_len: int = 300) -> str:
|
||||
text = str(value or "").strip()
|
||||
if len(text) > max_len:
|
||||
return f"{text[:max_len]}..."
|
||||
return text
|
||||
|
||||
|
||||
def _check_bind_email_rate_limits(email: str):
|
||||
client_ip = get_rate_limit_ip()
|
||||
allowed, error_msg = check_ip_request_rate(client_ip, "email")
|
||||
@@ -374,6 +409,176 @@ def update_user_email_notify():
|
||||
return jsonify({"error": "更新失败"}), 500
|
||||
|
||||
|
||||
@api_user_bp.route("/api/user/passkeys", methods=["GET"])
|
||||
@login_required
|
||||
def list_user_passkeys():
|
||||
"""获取当前用户绑定的 Passkey 设备列表。"""
|
||||
rows = database.list_passkeys("user", int(current_user.id))
|
||||
items = []
|
||||
for row in rows:
|
||||
credential_id = str(row.get("credential_id") or "")
|
||||
preview = ""
|
||||
if credential_id:
|
||||
preview = f"{credential_id[:8]}...{credential_id[-6:]}" if len(credential_id) > 16 else credential_id
|
||||
items.append(
|
||||
{
|
||||
"id": int(row.get("id")),
|
||||
"device_name": str(row.get("device_name") or ""),
|
||||
"credential_id_preview": preview,
|
||||
"created_at": row.get("created_at"),
|
||||
"last_used_at": row.get("last_used_at"),
|
||||
"transports": str(row.get("transports") or ""),
|
||||
}
|
||||
)
|
||||
return jsonify({"items": items, "limit": MAX_PASSKEYS_PER_OWNER})
|
||||
|
||||
|
||||
@api_user_bp.route("/api/user/passkeys/register/options", methods=["POST"])
|
||||
@login_required
|
||||
def user_passkey_register_options():
|
||||
"""当前登录用户创建 Passkey:下发 registration challenge。"""
|
||||
user, error_response = _get_current_user_or_404()
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
count = database.count_passkeys("user", int(current_user.id))
|
||||
if count >= MAX_PASSKEYS_PER_OWNER:
|
||||
return jsonify({"error": f"最多可绑定{MAX_PASSKEYS_PER_OWNER}台设备"}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
device_name = normalize_device_name(data.get("device_name"))
|
||||
|
||||
existing = database.list_passkeys("user", int(current_user.id))
|
||||
exclude_credential_ids = [str(item.get("credential_id") or "").strip() for item in existing if item.get("credential_id")]
|
||||
|
||||
try:
|
||||
rp_id = get_rp_id(request)
|
||||
expected_origins = get_expected_origins(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"[passkey] 用户注册 options 失败(user_id={current_user.id}): {e}")
|
||||
return jsonify({"error": "Passkey配置异常,请联系管理员"}), 500
|
||||
|
||||
try:
|
||||
options = make_registration_options(
|
||||
rp_id=rp_id,
|
||||
rp_name="知识管理平台",
|
||||
user_name=str(user.get("username") or f"user-{current_user.id}"),
|
||||
user_display_name=str(user.get("username") or f"user-{current_user.id}"),
|
||||
user_id_bytes=f"user:{int(current_user.id)}".encode("utf-8"),
|
||||
exclude_credential_ids=exclude_credential_ids,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[passkey] 用户注册 options 构建失败(user_id={current_user.id}): {e}")
|
||||
return jsonify({"error": "生成Passkey挑战失败"}), 500
|
||||
|
||||
challenge = str(options.get("challenge") or "").strip()
|
||||
if not challenge:
|
||||
return jsonify({"error": "生成Passkey挑战失败"}), 500
|
||||
|
||||
session[_USER_PASSKEY_REGISTER_SESSION_KEY] = {
|
||||
"user_id": int(current_user.id),
|
||||
"challenge": challenge,
|
||||
"rp_id": rp_id,
|
||||
"expected_origins": expected_origins,
|
||||
"device_name": device_name,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
session.modified = True
|
||||
return jsonify({"publicKey": options, "limit": MAX_PASSKEYS_PER_OWNER})
|
||||
|
||||
|
||||
@api_user_bp.route("/api/user/passkeys/register/verify", methods=["POST"])
|
||||
@login_required
|
||||
def user_passkey_register_verify():
|
||||
"""当前登录用户创建 Passkey:校验 attestation 并落库。"""
|
||||
state = session.get(_USER_PASSKEY_REGISTER_SESSION_KEY) or {}
|
||||
if not state:
|
||||
return jsonify({"error": "Passkey挑战不存在或已过期,请重试"}), 400
|
||||
if int(state.get("user_id") or 0) != int(current_user.id):
|
||||
return jsonify({"error": "Passkey挑战与当前用户不匹配"}), 400
|
||||
if not is_challenge_valid(state.get("created_at")):
|
||||
session.pop(_USER_PASSKEY_REGISTER_SESSION_KEY, None)
|
||||
return jsonify({"error": "Passkey挑战已过期,请重试"}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
credential = _parse_credential_payload(data)
|
||||
if not credential:
|
||||
return jsonify({"error": "Passkey参数缺失"}), 400
|
||||
|
||||
count = database.count_passkeys("user", int(current_user.id))
|
||||
if count >= MAX_PASSKEYS_PER_OWNER:
|
||||
session.pop(_USER_PASSKEY_REGISTER_SESSION_KEY, None)
|
||||
return jsonify({"error": f"最多可绑定{MAX_PASSKEYS_PER_OWNER}台设备"}), 400
|
||||
|
||||
try:
|
||||
verified = verify_registration(
|
||||
credential=credential,
|
||||
expected_challenge=str(state.get("challenge") or ""),
|
||||
expected_rp_id=str(state.get("rp_id") or ""),
|
||||
expected_origins=list(state.get("expected_origins") or []),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[passkey] 用户注册验签失败(user_id={current_user.id}): {e}")
|
||||
return jsonify({"error": "Passkey验证失败,请重试"}), 400
|
||||
|
||||
credential_id = encode_credential_id(verified.credential_id)
|
||||
public_key = encode_credential_id(verified.credential_public_key)
|
||||
transports = get_credential_transports(credential)
|
||||
device_name = normalize_device_name(data.get("device_name") if "device_name" in data else state.get("device_name"))
|
||||
aaguid = str(verified.aaguid or "")
|
||||
|
||||
created_id = database.create_passkey(
|
||||
"user",
|
||||
int(current_user.id),
|
||||
credential_id=credential_id,
|
||||
public_key=public_key,
|
||||
sign_count=int(verified.sign_count or 0),
|
||||
device_name=device_name,
|
||||
transports=transports,
|
||||
aaguid=aaguid,
|
||||
)
|
||||
if not created_id:
|
||||
return jsonify({"error": "该Passkey已绑定,或保存失败"}), 400
|
||||
|
||||
session.pop(_USER_PASSKEY_REGISTER_SESSION_KEY, None)
|
||||
return jsonify({"success": True, "id": int(created_id), "device_name": device_name})
|
||||
|
||||
|
||||
@api_user_bp.route("/api/user/passkeys/<int:passkey_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
def delete_user_passkey(passkey_id):
|
||||
"""删除当前用户绑定的 Passkey 设备。"""
|
||||
ok = database.delete_passkey("user", int(current_user.id), int(passkey_id))
|
||||
if ok:
|
||||
return jsonify({"success": True})
|
||||
return jsonify({"error": "设备不存在或已删除"}), 404
|
||||
|
||||
|
||||
@api_user_bp.route("/api/user/passkeys/client-error", methods=["POST"])
|
||||
@login_required
|
||||
def report_user_passkey_client_error():
|
||||
"""上报浏览器端 Passkey 失败详情,便于排查兼容性问题。"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
error_name = _truncate_text(data.get("name"), 120)
|
||||
error_message = _truncate_text(data.get("message"), 400)
|
||||
error_code = _truncate_text(data.get("code"), 120)
|
||||
ua = _truncate_text(data.get("user_agent") or request.headers.get("User-Agent", ""), 300)
|
||||
stage = _truncate_text(data.get("stage"), 80)
|
||||
source = _truncate_text(data.get("source"), 80)
|
||||
|
||||
logger.warning(
|
||||
"[passkey][client-error][user] user_id=%s stage=%s source=%s name=%s code=%s message=%s ua=%s",
|
||||
current_user.id,
|
||||
stage or "-",
|
||||
source or "-",
|
||||
error_name or "-",
|
||||
error_code or "-",
|
||||
error_message or "-",
|
||||
ua or "-",
|
||||
)
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@api_user_bp.route("/api/run_stats", methods=["GET"])
|
||||
@login_required
|
||||
def get_run_stats():
|
||||
|
||||
@@ -15,10 +15,45 @@ from services.runtime import get_logger
|
||||
pages_bp = Blueprint("pages", __name__)
|
||||
|
||||
|
||||
def _collect_entry_css_files(manifest: dict, entry_name: str) -> list[str]:
|
||||
css_files: list[str] = []
|
||||
seen_css: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
|
||||
def _append_css(entry_obj: dict) -> None:
|
||||
for css_file in entry_obj.get("css") or []:
|
||||
css_path = str(css_file or "").strip()
|
||||
if not css_path or css_path in seen_css:
|
||||
continue
|
||||
seen_css.add(css_path)
|
||||
css_files.append(css_path)
|
||||
|
||||
def _walk_manifest_key(manifest_key: str) -> None:
|
||||
key = str(manifest_key or "").strip()
|
||||
if not key or key in visited:
|
||||
return
|
||||
visited.add(key)
|
||||
entry_obj = manifest.get(key)
|
||||
if not isinstance(entry_obj, dict):
|
||||
return
|
||||
_append_css(entry_obj)
|
||||
for imported_key in entry_obj.get("imports") or []:
|
||||
_walk_manifest_key(imported_key)
|
||||
|
||||
entry = manifest.get(entry_name) or {}
|
||||
if isinstance(entry, dict):
|
||||
_append_css(entry)
|
||||
for imported_key in entry.get("imports") or []:
|
||||
_walk_manifest_key(imported_key)
|
||||
|
||||
return css_files
|
||||
|
||||
|
||||
def render_app_spa_or_legacy(
|
||||
legacy_template_name: str,
|
||||
legacy_context: Optional[dict] = None,
|
||||
spa_initial_state: Optional[dict] = None,
|
||||
spa_entry_name: str = "index.html",
|
||||
):
|
||||
"""渲染前台 Vue SPA(构建产物位于 static/app),失败则回退旧模板。"""
|
||||
logger = get_logger()
|
||||
@@ -28,9 +63,9 @@ def render_app_spa_or_legacy(
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
entry = manifest.get("index.html") or {}
|
||||
entry = manifest.get(spa_entry_name) or {}
|
||||
js_file = entry.get("file")
|
||||
css_files = entry.get("css") or []
|
||||
css_files = _collect_entry_css_files(manifest, spa_entry_name)
|
||||
|
||||
if not js_file:
|
||||
logger.warning(f"[app_spa] manifest缺少入口文件: {manifest_path}")
|
||||
@@ -83,7 +118,7 @@ def index():
|
||||
@pages_bp.route("/login")
|
||||
def login_page():
|
||||
"""登录页面"""
|
||||
return render_app_spa_or_legacy("login.html")
|
||||
return render_app_spa_or_legacy("login.html", spa_entry_name="login.html")
|
||||
|
||||
|
||||
@pages_bp.route("/register")
|
||||
|
||||
Reference in New Issue
Block a user