feat: 添加安全模块 + Dockerfile添加curl支持健康检查

主要更新:
- 新增 security/ 安全模块 (风险评估、威胁检测、蜜罐等)
- Dockerfile 添加 curl 以支持 Docker 健康检查
- 前端页面更新 (管理后台、用户端)
- 数据库迁移和 schema 更新
- 新增 kdocs 上传服务
- 添加安全相关测试用例

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yu Yon
2026-01-08 17:48:33 +08:00
parent e3b0c35da6
commit 53c78e8e3c
76 changed files with 8563 additions and 4709 deletions

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
def register_blueprints(app) -> None:
from routes.admin_api import admin_api_bp
from routes.admin_api import security_bp as admin_security_bp
from routes.api_accounts import api_accounts_bp
from routes.api_auth import api_auth_bp
from routes.api_schedules import api_schedules_bp
@@ -21,3 +22,6 @@ def register_blueprints(app) -> None:
app.register_blueprint(api_screenshots_bp)
app.register_blueprint(api_schedules_bp)
app.register_blueprint(admin_api_bp)
# Security admin APIs (support both /api/admin/* and /yuyx/api/admin/*)
app.register_blueprint(admin_security_bp)
app.register_blueprint(admin_security_bp, url_prefix="/yuyx", name="admin_security_yuyx")

View File

@@ -8,4 +8,6 @@ admin_api_bp = Blueprint("admin_api", __name__, url_prefix="/yuyx/api")
# Import side effects: register routes on blueprint
from routes.admin_api import core as _core # noqa: F401
from routes.admin_api import update as _update # noqa: F401
# Export security blueprint for app registration
from routes.admin_api.security import security_bp # noqa: F401

View File

@@ -3,6 +3,8 @@
from __future__ import annotations
import os
import posixpath
import secrets
import threading
import time
from datetime import datetime
@@ -15,7 +17,9 @@ from app_logger import get_logger
from app_security import (
get_rate_limit_ip,
is_safe_outbound_url,
is_safe_path,
require_ip_not_locked,
sanitize_filename,
validate_email,
validate_password,
)
@@ -48,6 +52,36 @@ from services.time_utils import BEIJING_TZ, get_beijing_now
logger = get_logger("app")
config = get_config()
_server_cpu_percent_lock = threading.Lock()
_server_cpu_percent_last: float | None = None
_server_cpu_percent_last_ts = 0.0
def _get_server_cpu_percent() -> float:
import psutil
global _server_cpu_percent_last, _server_cpu_percent_last_ts
now = time.time()
with _server_cpu_percent_lock:
if _server_cpu_percent_last is not None and (now - _server_cpu_percent_last_ts) < 0.5:
return _server_cpu_percent_last
try:
if _server_cpu_percent_last is None:
cpu_percent = float(psutil.cpu_percent(interval=0.1))
else:
cpu_percent = float(psutil.cpu_percent(interval=None))
except Exception:
cpu_percent = float(_server_cpu_percent_last or 0.0)
if cpu_percent < 0:
cpu_percent = 0.0
_server_cpu_percent_last = cpu_percent
_server_cpu_percent_last_ts = now
return cpu_percent
def _admin_reauth_required() -> bool:
try:
@@ -61,6 +95,24 @@ def _require_admin_reauth():
return jsonify({"error": "需要二次确认", "code": "reauth_required"}), 401
return None
def _get_upload_dir():
rel_dir = getattr(config, "ANNOUNCEMENT_IMAGE_DIR", "static/announcements")
if not is_safe_path(current_app.root_path, rel_dir):
rel_dir = "static/announcements"
abs_dir = os.path.join(current_app.root_path, rel_dir)
os.makedirs(abs_dir, exist_ok=True)
return abs_dir, rel_dir
def _get_file_size(file_storage):
try:
file_storage.stream.seek(0, os.SEEK_END)
size = file_storage.stream.tell()
file_storage.stream.seek(0)
return size
except Exception:
return None
@admin_api_bp.route("/debug-config", methods=["GET"])
@admin_required
@@ -199,6 +251,42 @@ def admin_reauth():
# ==================== 公告管理API管理员 ====================
@admin_api_bp.route("/announcements/upload_image", methods=["POST"])
@admin_required
def admin_upload_announcement_image():
"""上传公告图片返回可访问URL"""
file = request.files.get("file")
if not file or not file.filename:
return jsonify({"error": "请选择图片"}), 400
filename = sanitize_filename(file.filename)
ext = os.path.splitext(filename)[1].lower()
allowed_exts = getattr(config, "ALLOWED_ANNOUNCEMENT_IMAGE_EXTENSIONS", {".png", ".jpg", ".jpeg"})
if not ext or ext not in allowed_exts:
return jsonify({"error": "不支持的图片格式"}), 400
if file.mimetype and not str(file.mimetype).startswith("image/"):
return jsonify({"error": "文件类型无效"}), 400
size = _get_file_size(file)
max_size = int(getattr(config, "MAX_ANNOUNCEMENT_IMAGE_SIZE", 5 * 1024 * 1024))
if size is not None and size > max_size:
max_mb = max_size // 1024 // 1024
return jsonify({"error": f"图片大小不能超过{max_mb}MB"}), 400
abs_dir, rel_dir = _get_upload_dir()
token = secrets.token_hex(6)
name = f"announcement_{int(time.time())}_{token}{ext}"
save_path = os.path.join(abs_dir, name)
file.save(save_path)
static_root = os.path.join(current_app.root_path, "static")
rel_to_static = os.path.relpath(abs_dir, static_root)
if rel_to_static.startswith(".."):
rel_to_static = "announcements"
url_path = posixpath.join(rel_to_static.replace(os.sep, "/"), name)
return jsonify({"success": True, "url": url_for("serve_static", filename=url_path)})
@admin_api_bp.route("/announcements", methods=["GET"])
@admin_required
def admin_get_announcements():
@@ -221,9 +309,13 @@ def admin_create_announcement():
data = request.json or {}
title = (data.get("title") or "").strip()
content = (data.get("content") or "").strip()
image_url = (data.get("image_url") or "").strip()
is_active = bool(data.get("is_active", True))
announcement_id = database.create_announcement(title, content, is_active=is_active)
if image_url and len(image_url) > 1000:
return jsonify({"error": "图片地址过长"}), 400
announcement_id = database.create_announcement(title, content, image_url=image_url, is_active=is_active)
if not announcement_id:
return jsonify({"error": "标题和内容不能为空"}), 400
@@ -317,6 +409,71 @@ def get_system_stats():
return jsonify(stats)
@admin_api_bp.route("/browser_pool/stats", methods=["GET"])
@admin_required
def get_browser_pool_stats():
"""获取截图线程池状态"""
try:
from browser_pool_worker import get_browser_worker_pool
pool = get_browser_worker_pool()
stats = pool.get_stats() or {}
worker_details = []
for w in stats.get("workers") or []:
last_ts = float(w.get("last_active_ts") or 0)
last_active_at = None
if last_ts > 0:
try:
last_active_at = datetime.fromtimestamp(last_ts, tz=BEIJING_TZ).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
last_active_at = None
created_ts = w.get("browser_created_at")
created_at = None
if created_ts:
try:
created_at = datetime.fromtimestamp(float(created_ts), tz=BEIJING_TZ).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
created_at = None
worker_details.append(
{
"worker_id": w.get("worker_id"),
"idle": bool(w.get("idle")),
"has_browser": bool(w.get("has_browser")),
"total_tasks": int(w.get("total_tasks") or 0),
"failed_tasks": int(w.get("failed_tasks") or 0),
"browser_use_count": int(w.get("browser_use_count") or 0),
"browser_created_at": created_at,
"browser_created_ts": created_ts,
"last_active_at": last_active_at,
"last_active_ts": last_ts,
"thread_alive": bool(w.get("thread_alive")),
}
)
total_workers = len(worker_details) if worker_details else int(stats.get("pool_size") or 0)
return jsonify(
{
"total_workers": total_workers,
"active_workers": int(stats.get("busy_workers") or 0),
"idle_workers": int(stats.get("idle_workers") or 0),
"queue_size": int(stats.get("queue_size") or 0),
"workers": worker_details,
"summary": {
"total_tasks": int(stats.get("total_tasks") or 0),
"failed_tasks": int(stats.get("failed_tasks") or 0),
"success_rate": stats.get("success_rate"),
},
"server_time_cst": get_beijing_now().strftime("%Y-%m-%d %H:%M:%S"),
}
)
except Exception as e:
logger.exception(f"[AdminAPI] 获取截图线程池状态失败: {e}")
return jsonify({"error": "获取截图线程池状态失败"}), 500
@admin_api_bp.route("/docker_stats", methods=["GET"])
@admin_required
def get_docker_stats():
@@ -510,9 +667,21 @@ def update_system_config_api():
schedule_weekdays = data.get("schedule_weekdays")
new_max_concurrent_per_account = data.get("max_concurrent_per_account")
new_max_screenshot_concurrent = data.get("max_screenshot_concurrent")
enable_screenshot = data.get("enable_screenshot")
auto_approve_enabled = data.get("auto_approve_enabled")
auto_approve_hourly_limit = data.get("auto_approve_hourly_limit")
auto_approve_vip_days = data.get("auto_approve_vip_days")
kdocs_enabled = data.get("kdocs_enabled")
kdocs_doc_url = data.get("kdocs_doc_url")
kdocs_default_unit = data.get("kdocs_default_unit")
kdocs_sheet_name = data.get("kdocs_sheet_name")
kdocs_sheet_index = data.get("kdocs_sheet_index")
kdocs_unit_column = data.get("kdocs_unit_column")
kdocs_image_column = data.get("kdocs_image_column")
kdocs_admin_notify_enabled = data.get("kdocs_admin_notify_enabled")
kdocs_admin_notify_email = data.get("kdocs_admin_notify_email")
kdocs_row_start = data.get("kdocs_row_start")
kdocs_row_end = data.get("kdocs_row_end")
if max_concurrent is not None:
if not isinstance(max_concurrent, int) or max_concurrent < 1:
@@ -524,7 +693,13 @@ def update_system_config_api():
if new_max_screenshot_concurrent is not None:
if not isinstance(new_max_screenshot_concurrent, int) or new_max_screenshot_concurrent < 1:
return jsonify({"error": "截图并发数必须大于0建议根据服务器配置设置每个浏览器约占用200MB内存"}), 400
return jsonify({"error": "截图并发数必须大于0建议根据服务器配置设置wkhtmltoimage 资源占用较低"}), 400
if enable_screenshot is not None:
if isinstance(enable_screenshot, bool):
enable_screenshot = 1 if enable_screenshot else 0
if enable_screenshot not in (0, 1):
return jsonify({"error": "截图开关必须是0或1"}), 400
if schedule_time is not None:
import re
@@ -554,6 +729,82 @@ def update_system_config_api():
if not isinstance(auto_approve_vip_days, int) or auto_approve_vip_days < 0:
return jsonify({"error": "注册赠送VIP天数不能为负数"}), 400
if kdocs_enabled is not None:
if isinstance(kdocs_enabled, bool):
kdocs_enabled = 1 if kdocs_enabled else 0
if kdocs_enabled not in (0, 1):
return jsonify({"error": "表格上传开关必须是0或1"}), 400
if kdocs_doc_url is not None:
kdocs_doc_url = str(kdocs_doc_url or "").strip()
if kdocs_doc_url and not is_safe_outbound_url(kdocs_doc_url):
return jsonify({"error": "文档链接格式不正确"}), 400
if kdocs_default_unit is not None:
kdocs_default_unit = str(kdocs_default_unit or "").strip()
if len(kdocs_default_unit) > 50:
return jsonify({"error": "默认县区长度不能超过50"}), 400
if kdocs_sheet_name is not None:
kdocs_sheet_name = str(kdocs_sheet_name or "").strip()
if len(kdocs_sheet_name) > 50:
return jsonify({"error": "Sheet名称长度不能超过50"}), 400
if kdocs_sheet_index is not None:
try:
kdocs_sheet_index = int(kdocs_sheet_index)
except Exception:
return jsonify({"error": "Sheet序号必须是数字"}), 400
if kdocs_sheet_index < 0:
return jsonify({"error": "Sheet序号不能为负数"}), 400
if kdocs_unit_column is not None:
kdocs_unit_column = str(kdocs_unit_column or "").strip().upper()
if not kdocs_unit_column:
return jsonify({"error": "县区列不能为空"}), 400
import re
if not re.match(r"^[A-Z]{1,3}$", kdocs_unit_column):
return jsonify({"error": "县区列格式错误"}), 400
if kdocs_image_column is not None:
kdocs_image_column = str(kdocs_image_column or "").strip().upper()
if not kdocs_image_column:
return jsonify({"error": "图片列不能为空"}), 400
import re
if not re.match(r"^[A-Z]{1,3}$", kdocs_image_column):
return jsonify({"error": "图片列格式错误"}), 400
if kdocs_admin_notify_enabled is not None:
if isinstance(kdocs_admin_notify_enabled, bool):
kdocs_admin_notify_enabled = 1 if kdocs_admin_notify_enabled else 0
if kdocs_admin_notify_enabled not in (0, 1):
return jsonify({"error": "管理员通知开关必须是0或1"}), 400
if kdocs_admin_notify_email is not None:
kdocs_admin_notify_email = str(kdocs_admin_notify_email or "").strip()
if kdocs_admin_notify_email:
is_valid, error_msg = validate_email(kdocs_admin_notify_email)
if not is_valid:
return jsonify({"error": error_msg}), 400
if kdocs_row_start is not None:
try:
kdocs_row_start = int(kdocs_row_start)
except (ValueError, TypeError):
return jsonify({"error": "起始行必须是数字"}), 400
if kdocs_row_start < 0:
return jsonify({"error": "起始行不能为负数"}), 400
if kdocs_row_end is not None:
try:
kdocs_row_end = int(kdocs_row_end)
except (ValueError, TypeError):
return jsonify({"error": "结束行必须是数字"}), 400
if kdocs_row_end < 0:
return jsonify({"error": "结束行不能为负数"}), 400
old_config = database.get_system_config() or {}
if not database.update_system_config(
@@ -564,9 +815,21 @@ def update_system_config_api():
schedule_weekdays=schedule_weekdays,
max_concurrent_per_account=new_max_concurrent_per_account,
max_screenshot_concurrent=new_max_screenshot_concurrent,
enable_screenshot=enable_screenshot,
auto_approve_enabled=auto_approve_enabled,
auto_approve_hourly_limit=auto_approve_hourly_limit,
auto_approve_vip_days=auto_approve_vip_days,
kdocs_enabled=kdocs_enabled,
kdocs_doc_url=kdocs_doc_url,
kdocs_default_unit=kdocs_default_unit,
kdocs_sheet_name=kdocs_sheet_name,
kdocs_sheet_index=kdocs_sheet_index,
kdocs_unit_column=kdocs_unit_column,
kdocs_image_column=kdocs_image_column,
kdocs_admin_notify_enabled=kdocs_admin_notify_enabled,
kdocs_admin_notify_email=kdocs_admin_notify_email,
kdocs_row_start=kdocs_row_start,
kdocs_row_end=kdocs_row_end,
):
return jsonify({"error": "更新失败"}), 400
@@ -577,6 +840,14 @@ def update_system_config_api():
max_global=int(new_config.get("max_concurrent_global", old_config.get("max_concurrent_global", 2))),
max_per_user=int(new_config.get("max_concurrent_per_account", old_config.get("max_concurrent_per_account", 1))),
)
if new_max_screenshot_concurrent is not None:
try:
from browser_pool_worker import resize_browser_worker_pool
if resize_browser_worker_pool(int(new_config.get("max_screenshot_concurrent", new_max_screenshot_concurrent))):
logger.info(f"截图线程池并发已更新为: {new_config.get('max_screenshot_concurrent')}")
except Exception as pool_error:
logger.warning(f"截图线程池并发更新失败: {pool_error}")
except Exception:
pass
@@ -590,6 +861,70 @@ def update_system_config_api():
return jsonify({"message": "系统配置已更新"})
@admin_api_bp.route("/kdocs/status", methods=["GET"])
@admin_required
def get_kdocs_status_api():
"""获取金山文档上传状态"""
try:
from services.kdocs_uploader import get_kdocs_uploader
uploader = get_kdocs_uploader()
status = uploader.get_status()
live = str(request.args.get("live", "")).lower() in ("1", "true", "yes")
if live:
live_status = uploader.refresh_login_status()
if live_status.get("success"):
logged_in = bool(live_status.get("logged_in"))
status["logged_in"] = logged_in
status["last_login_ok"] = logged_in
status["login_required"] = not logged_in
if live_status.get("error"):
status["last_error"] = live_status.get("error")
else:
status["logged_in"] = True if status.get("last_login_ok") else False if status.get("last_login_ok") is False else None
if status.get("last_login_ok") is True and status.get("last_error") == "操作超时":
status["last_error"] = None
return jsonify(status)
except Exception as e:
return jsonify({"error": f"获取状态失败: {e}"}), 500
@admin_api_bp.route("/kdocs/qr", methods=["POST"])
@admin_required
def get_kdocs_qr_api():
"""获取金山文档登录二维码"""
try:
from services.kdocs_uploader import get_kdocs_uploader
uploader = get_kdocs_uploader()
data = request.get_json(silent=True) or {}
force = bool(data.get("force"))
if not force:
force = str(request.args.get("force", "")).lower() in ("1", "true", "yes")
result = uploader.request_qr(force=force)
if not result.get("success"):
return jsonify({"error": result.get("error", "获取二维码失败")}), 400
return jsonify(result)
except Exception as e:
return jsonify({"error": f"获取二维码失败: {e}"}), 500
@admin_api_bp.route("/kdocs/clear-login", methods=["POST"])
@admin_required
def clear_kdocs_login_api():
"""清除金山文档登录态"""
try:
from services.kdocs_uploader import get_kdocs_uploader
uploader = get_kdocs_uploader()
result = uploader.clear_login()
if not result.get("success"):
return jsonify({"error": result.get("error", "清除失败")}), 400
return jsonify({"success": True})
except Exception as e:
return jsonify({"error": f"清除失败: {e}"}), 500
@admin_api_bp.route("/schedule/execute", methods=["POST"])
@admin_required
def execute_schedule_now():
@@ -673,7 +1008,7 @@ def get_server_info_api():
"""获取服务器信息"""
import psutil
cpu_percent = psutil.cpu_percent(interval=1)
cpu_percent = _get_server_cpu_percent()
memory = psutil.virtual_memory()
memory_total = f"{memory.total / (1024**3):.1f}GB"
@@ -776,30 +1111,44 @@ def get_running_tasks_api():
@admin_required
def get_task_logs_api():
"""获取任务日志列表(支持分页和多种筛选)"""
limit = int(request.args.get("limit", 20))
offset = int(request.args.get("offset", 0))
try:
limit = int(request.args.get("limit", 20))
limit = max(1, min(limit, 200)) # 限制 1-200 条
except (ValueError, TypeError):
limit = 20
try:
offset = int(request.args.get("offset", 0))
offset = max(0, offset)
except (ValueError, TypeError):
offset = 0
date_filter = request.args.get("date")
status_filter = request.args.get("status")
source_filter = request.args.get("source")
user_id_filter = request.args.get("user_id")
account_filter = request.args.get("account")
account_filter = (request.args.get("account") or "").strip()
if user_id_filter:
try:
user_id_filter = int(user_id_filter)
except ValueError:
except (ValueError, TypeError):
user_id_filter = None
result = database.get_task_logs(
limit=limit,
offset=offset,
date_filter=date_filter,
status_filter=status_filter,
source_filter=source_filter,
user_id_filter=user_id_filter,
account_filter=account_filter,
)
return jsonify(result)
try:
result = database.get_task_logs(
limit=limit,
offset=offset,
date_filter=date_filter,
status_filter=status_filter,
source_filter=source_filter,
user_id_filter=user_id_filter,
account_filter=account_filter if account_filter else None,
)
return jsonify(result)
except Exception as e:
logger.error(f"获取任务日志失败: {e}")
return jsonify({"logs": [], "total": 0, "error": "查询失败"})
@admin_api_bp.route("/task/logs/clear", methods=["POST"])
@@ -910,32 +1259,6 @@ def admin_reset_password_route(user_id):
return jsonify({"error": "重置失败,用户不存在"}), 400
@admin_api_bp.route("/password_resets", methods=["GET"])
@admin_required
def get_password_resets_route():
"""获取所有待审核的密码重置申请"""
resets = database.get_pending_password_resets()
return jsonify(resets)
@admin_api_bp.route("/password_resets/<int:request_id>/approve", methods=["POST"])
@admin_required
def approve_password_reset_route(request_id):
"""批准密码重置申请"""
if database.approve_password_reset(request_id):
return jsonify({"message": "密码重置申请已批准"})
return jsonify({"error": "批准失败"}), 400
@admin_api_bp.route("/password_resets/<int:request_id>/reject", methods=["POST"])
@admin_required
def reject_password_reset_route(request_id):
"""拒绝密码重置申请"""
if database.reject_password_reset(request_id):
return jsonify({"message": "密码重置申请已拒绝"})
return jsonify({"error": "拒绝失败"}), 400
@admin_api_bp.route("/feedbacks", methods=["GET"])
@admin_required
def get_all_feedbacks():
@@ -1067,6 +1390,7 @@ def update_email_settings_api():
enabled = data.get("enabled", False)
failover_enabled = data.get("failover_enabled", True)
register_verify_enabled = data.get("register_verify_enabled")
login_alert_enabled = data.get("login_alert_enabled")
base_url = data.get("base_url")
task_notify_enabled = data.get("task_notify_enabled")
@@ -1074,6 +1398,7 @@ def update_email_settings_api():
enabled=enabled,
failover_enabled=failover_enabled,
register_verify_enabled=register_verify_enabled,
login_alert_enabled=login_alert_enabled,
base_url=base_url,
task_notify_enabled=task_notify_enabled,
)

View File

@@ -0,0 +1,348 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import Any
from flask import Blueprint, jsonify, request
import db_pool
from db import security as security_db
from routes.decorators import admin_required
from security import BlacklistManager, RiskScorer
security_bp = Blueprint("admin_security", __name__)
blacklist = BlacklistManager()
scorer = RiskScorer(blacklist_manager=blacklist)
def _truncate(value: Any, max_len: int = 200) -> str:
text = str(value or "")
if max_len <= 0:
return ""
if len(text) <= max_len:
return text
return text[: max(0, max_len - 3)] + "..."
def _parse_int_arg(name: str, default: int, *, min_value: int | None = None, max_value: int | None = None) -> int:
raw = request.args.get(name, None)
if raw is None or str(raw).strip() == "":
value = int(default)
else:
try:
value = int(str(raw).strip())
except Exception:
value = int(default)
if min_value is not None:
value = max(int(min_value), value)
if max_value is not None:
value = min(int(max_value), value)
return value
def _parse_json() -> dict:
if request.is_json:
data = request.get_json(silent=True) or {}
return data if isinstance(data, dict) else {}
# 兼容 form-data
try:
return dict(request.form or {})
except Exception:
return {}
def _parse_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, int):
return value != 0
text = str(value or "").strip().lower()
return text in {"1", "true", "yes", "y", "on"}
def _sanitize_threat_event(event: dict) -> dict:
return {
"id": event.get("id"),
"threat_type": event.get("threat_type") or "unknown",
"score": int(event.get("score") or 0),
"ip": _truncate(event.get("ip"), 64),
"user_id": event.get("user_id"),
"request_method": _truncate(event.get("request_method"), 16),
"request_path": _truncate(event.get("request_path"), 256),
"field_name": _truncate(event.get("field_name"), 80),
"rule": _truncate(event.get("rule"), 120),
"matched": _truncate(event.get("matched"), 120),
"value_preview": _truncate(event.get("value_preview"), 200),
"created_at": event.get("created_at"),
}
def _sanitize_ban_entry(entry: dict, *, kind: str) -> dict:
if kind == "ip":
return {
"ip": _truncate(entry.get("ip"), 64),
"reason": _truncate(entry.get("reason"), 200),
"added_at": entry.get("added_at"),
"expires_at": entry.get("expires_at"),
"is_active": int(entry.get("is_active") or 0),
}
if kind == "user":
return {
"user_id": entry.get("user_id"),
"reason": _truncate(entry.get("reason"), 200),
"added_at": entry.get("added_at"),
"expires_at": entry.get("expires_at"),
"is_active": int(entry.get("is_active") or 0),
}
return {}
@security_bp.route("/api/admin/security/dashboard", methods=["GET"])
@admin_required
def get_security_dashboard():
"""
获取安全仪表板数据
返回:
- 最近24小时威胁事件数
- 当前封禁IP数
- 当前封禁用户数
- 最近10条威胁事件
"""
try:
threat_24h = security_db.get_threat_events_count(hours=24)
except Exception:
threat_24h = 0
try:
banned_ips = blacklist.get_banned_ips()
except Exception:
banned_ips = []
try:
banned_users = blacklist.get_banned_users()
except Exception:
banned_users = []
try:
recent = security_db.get_threat_events_list(page=1, per_page=10, filters={}).get("items", [])
recent_items = [_sanitize_threat_event(e) for e in recent if isinstance(e, dict)]
except Exception:
recent_items = []
return jsonify(
{
"threat_events_24h": int(threat_24h or 0),
"banned_ip_count": len(banned_ips),
"banned_user_count": len(banned_users),
"recent_threat_events": recent_items,
}
)
@security_bp.route("/api/admin/security/threats", methods=["GET"])
@admin_required
def get_threat_events():
"""
获取威胁事件列表(分页)
参数: page, per_page, severity, event_type
"""
page = _parse_int_arg("page", 1, min_value=1, max_value=100000)
per_page = _parse_int_arg("per_page", 20, min_value=1, max_value=200)
severity = (request.args.get("severity") or "").strip()
event_type = (request.args.get("event_type") or "").strip()
filters: dict[str, Any] = {}
if severity:
filters["severity"] = severity
if event_type:
filters["event_type"] = event_type
data = security_db.get_threat_events_list(page, per_page, filters)
items = data.get("items") or []
data["items"] = [_sanitize_threat_event(e) for e in items if isinstance(e, dict)]
return jsonify(data)
@security_bp.route("/api/admin/security/banned-ips", methods=["GET"])
@admin_required
def get_banned_ips():
"""获取封禁IP列表"""
items = blacklist.get_banned_ips()
return jsonify({"count": len(items), "items": [_sanitize_ban_entry(x, kind="ip") for x in items]})
@security_bp.route("/api/admin/security/banned-users", methods=["GET"])
@admin_required
def get_banned_users():
"""获取封禁用户列表"""
items = blacklist.get_banned_users()
return jsonify({"count": len(items), "items": [_sanitize_ban_entry(x, kind="user") for x in items]})
@security_bp.route("/api/admin/security/ban-ip", methods=["POST"])
@admin_required
def ban_ip():
"""
手动封禁IP
参数: ip, reason, duration_hours(可选), permanent(可选)
"""
data = _parse_json()
ip = str(data.get("ip") or "").strip()
reason = str(data.get("reason") or "").strip()
duration_hours_raw = data.get("duration_hours", 24)
permanent = _parse_bool(data.get("permanent", False))
if not ip:
return jsonify({"error": "ip不能为空"}), 400
if not reason:
return jsonify({"error": "reason不能为空"}), 400
try:
duration_hours = max(1, int(duration_hours_raw))
except Exception:
duration_hours = 24
ok = blacklist.ban_ip(ip, reason, duration_hours=duration_hours, permanent=permanent)
if not ok:
return jsonify({"error": "封禁失败"}), 400
return jsonify({"success": True})
@security_bp.route("/api/admin/security/unban-ip", methods=["POST"])
@admin_required
def unban_ip():
"""解除IP封禁"""
data = _parse_json()
ip = str(data.get("ip") or "").strip()
if not ip:
return jsonify({"error": "ip不能为空"}), 400
ok = blacklist.unban_ip(ip)
if not ok:
return jsonify({"error": "未找到封禁记录"}), 404
return jsonify({"success": True})
@security_bp.route("/api/admin/security/ban-user", methods=["POST"])
@admin_required
def ban_user():
"""手动封禁用户"""
data = _parse_json()
user_id_raw = data.get("user_id")
reason = str(data.get("reason") or "").strip()
duration_hours_raw = data.get("duration_hours", 24)
permanent = _parse_bool(data.get("permanent", False))
try:
user_id = int(user_id_raw)
except Exception:
user_id = None
if user_id is None:
return jsonify({"error": "user_id不能为空"}), 400
if not reason:
return jsonify({"error": "reason不能为空"}), 400
try:
duration_hours = max(1, int(duration_hours_raw))
except Exception:
duration_hours = 24
ok = blacklist._ban_user_internal(user_id, reason=reason, duration_hours=duration_hours, permanent=permanent)
if not ok:
return jsonify({"error": "封禁失败"}), 400
return jsonify({"success": True})
@security_bp.route("/api/admin/security/unban-user", methods=["POST"])
@admin_required
def unban_user():
"""解除用户封禁"""
data = _parse_json()
user_id_raw = data.get("user_id")
try:
user_id = int(user_id_raw)
except Exception:
user_id = None
if user_id is None:
return jsonify({"error": "user_id不能为空"}), 400
ok = blacklist.unban_user(user_id)
if not ok:
return jsonify({"error": "未找到封禁记录"}), 404
return jsonify({"success": True})
@security_bp.route("/api/admin/security/ip-risk/<ip>", methods=["GET"])
@admin_required
def get_ip_risk(ip):
"""获取指定IP的风险评分和历史事件"""
ip_text = str(ip or "").strip()
if not ip_text:
return jsonify({"error": "ip不能为空"}), 400
history = security_db.get_ip_threat_history(ip_text)
return jsonify(
{
"ip": _truncate(ip_text, 64),
"risk_score": int(scorer.get_ip_score(ip_text) or 0),
"is_banned": bool(blacklist.is_ip_banned(ip_text)),
"threat_history": [_sanitize_threat_event(e) for e in history if isinstance(e, dict)],
}
)
@security_bp.route("/api/admin/security/ip-risk/clear", methods=["POST"])
@admin_required
def clear_ip_risk():
"""清除指定IP的风险分"""
data = _parse_json()
ip_text = str(data.get("ip") or "").strip()
if not ip_text:
return jsonify({"error": "ip不能为空"}), 400
if not scorer.reset_ip_score(ip_text):
return jsonify({"error": "清理失败"}), 400
return jsonify({"success": True, "ip": _truncate(ip_text, 64), "risk_score": 0})
@security_bp.route("/api/admin/security/user-risk/<int:user_id>", methods=["GET"])
@admin_required
def get_user_risk(user_id):
"""获取指定用户的风险评分和历史事件"""
history = security_db.get_user_threat_history(user_id)
return jsonify(
{
"user_id": int(user_id),
"risk_score": int(scorer.get_user_score(user_id) or 0),
"is_banned": bool(blacklist.is_user_banned(user_id)),
"threat_history": [_sanitize_threat_event(e) for e in history if isinstance(e, dict)],
}
)
@security_bp.route("/api/admin/security/cleanup", methods=["POST"])
@admin_required
def cleanup_expired():
"""清理过期的封禁记录和衰减风险分"""
try:
blacklist.cleanup_expired()
except Exception:
pass
try:
scorer.decay_scores()
except Exception:
pass
# 可选:返回当前连接池统计信息,便于排查后台运行状态
pool_stats = None
try:
pool_stats = db_pool.get_pool_stats()
except Exception:
pool_stats = None
return jsonify({"success": True, "pool_stats": pool_stats})

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
import os
import time
import uuid
from flask import jsonify, request, session
@@ -66,13 +65,6 @@ def _parse_bool_field(data: dict, key: str) -> bool | None:
raise ValueError(f"{key} 必须是 0/1 或 true/false")
def _admin_reauth_required() -> bool:
try:
return time.time() > float(session.get("admin_reauth_until", 0) or 0)
except Exception:
return True
@admin_api_bp.route("/update/status", methods=["GET"])
@admin_required
def get_update_status_api():
@@ -154,8 +146,6 @@ def request_update_check_api():
def request_update_run_api():
"""请求宿主机 Update-Agent 执行一键更新并重启服务。"""
ensure_update_dirs()
if _admin_reauth_required():
return jsonify({"error": "需要二次确认", "code": "reauth_required"}), 401
if _has_pending_request():
return jsonify({"error": "已有更新请求正在处理中,请稍后再试"}), 409

View File

@@ -11,7 +11,6 @@ from crypto_utils import encrypt_password as encrypt_account_password
from flask import Blueprint, jsonify, request
from flask_login import current_user, login_required
from services.accounts_service import load_user_accounts
from services.browser_manager import init_browser_manager_async
from services.browse_types import BROWSE_TYPE_SHOULD_READ, normalize_browse_type, validate_browse_type
from services.client_log import log_to_client
from services.models import Account
@@ -230,10 +229,6 @@ def start_account(account_id):
if not browse_type:
return jsonify({"error": "浏览类型无效"}), 400
enable_screenshot = data.get("enable_screenshot", True)
if enable_screenshot:
# 异步初始化浏览器环境,避免首次下载/安装 Chromium 阻塞请求导致“网页无响应”
init_browser_manager_async()
ok, message = submit_account_task(
user_id=user_id,
account_id=account_id,
@@ -308,9 +303,6 @@ def manual_screenshot(account_id):
account.last_browse_type = browse_type
# 异步初始化浏览器环境,避免首次下载/安装 Chromium 阻塞请求
init_browser_manager_async()
threading.Thread(
target=take_screenshot_for_account,
args=(user_id, account_id, browse_type, "manual_screenshot"),
@@ -336,10 +328,6 @@ def batch_start_accounts():
if not account_ids:
return jsonify({"error": "请选择要启动的账号"}), 400
if enable_screenshot:
# 异步初始化浏览器环境,避免首次下载/安装 Chromium 阻塞请求
init_browser_manager_async()
started = []
failed = []

View File

@@ -237,23 +237,31 @@ def forgot_password():
"""发送密码重置邮件"""
data = request.json or {}
email = data.get("email", "").strip().lower()
username = data.get("username", "").strip()
captcha_session = data.get("captcha_session", "")
captcha_code = data.get("captcha", "").strip()
if not email:
return jsonify({"error": "请输入邮箱"}), 400
if not email and not username:
return jsonify({"error": "请输入邮箱或用户名"}), 400
is_valid, error_msg = validate_email(email)
if not is_valid:
return jsonify({"error": error_msg}), 400
if username:
is_valid, error_msg = validate_username(username)
if not is_valid:
return jsonify({"error": error_msg}), 400
if email:
is_valid, error_msg = validate_email(email)
if not is_valid:
return jsonify({"error": error_msg}), 400
client_ip = get_rate_limit_ip()
allowed, error_msg = check_ip_request_rate(client_ip, "email")
if not allowed:
return jsonify({"error": error_msg}), 429
allowed, error_msg = check_email_rate_limit(email, "forgot_password")
if not allowed:
return jsonify({"error": error_msg}), 429
if email:
allowed, error_msg = check_email_rate_limit(email, "forgot_password")
if not allowed:
return jsonify({"error": error_msg}), 429
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
if not success:
@@ -266,6 +274,34 @@ def forgot_password():
if not email_settings.get("enabled", False):
return jsonify({"error": "邮件功能未启用,请联系管理员"}), 400
if username:
user = database.get_user_by_username(username)
if user and user.get("status") == "approved":
bound_email = (user.get("email") or "").strip()
if not bound_email:
return (
jsonify(
{
"error": "您尚未绑定邮箱,无法通过邮箱找回密码。请联系管理员重置密码。",
"code": "email_not_bound",
}
),
400,
)
allowed, error_msg = check_email_rate_limit(bound_email, "forgot_password")
if not allowed:
return jsonify({"error": error_msg}), 429
result = email_service.send_password_reset_email(
email=bound_email,
username=user["username"],
user_id=user["id"],
)
if not result["success"]:
logger.error(f"密码重置邮件发送失败: {result['error']}")
return jsonify({"success": True, "message": "如果该账号已绑定邮箱,您将收到密码重置邮件"})
user = database.get_user_by_email(email)
if user and user.get("status") == "approved":
result = email_service.send_password_reset_email(email=email, username=user["username"], user_id=user["id"])
@@ -317,46 +353,6 @@ def reset_password_confirm():
return jsonify({"error": "密码重置失败"}), 500
@api_auth_bp.route("/api/reset_password_request", methods=["POST"])
def request_password_reset():
"""用户申请重置密码(需要审核)"""
data = request.json or {}
username = data.get("username", "").strip()
email = data.get("email", "").strip().lower()
new_password = data.get("new_password", "").strip()
if not username or not new_password:
return jsonify({"error": "用户名和新密码不能为空"}), 400
is_valid, error_msg = validate_password(new_password)
if not is_valid:
return jsonify({"error": error_msg}), 400
if email:
is_valid, error_msg = validate_email(email)
if not is_valid:
return jsonify({"error": error_msg}), 400
client_ip = get_rate_limit_ip()
allowed, error_msg = check_ip_request_rate(client_ip, "email")
if not allowed:
return jsonify({"error": error_msg}), 429
if email:
allowed, error_msg = check_email_rate_limit(email, "reset_request")
if not allowed:
return jsonify({"error": error_msg}), 429
user = database.get_user_by_username(username)
if user:
if email and user.get("email") != email:
pass
else:
database.create_password_reset_request(user["id"], new_password)
return jsonify({"message": "如果账号存在,密码重置申请已提交,请等待管理员审核"})
@api_auth_bp.route("/api/generate_captcha", methods=["POST"])
def generate_captcha():
"""生成4位数字验证码图片"""
@@ -481,15 +477,19 @@ def login():
load_user_accounts(user["id"])
try:
user_agent = request.headers.get("User-Agent", "")
context = database.record_login_context(user["id"], client_ip, user_agent)
if context and (context.get("new_ip") or context.get("new_device")):
if config.LOGIN_ALERT_ENABLED and should_send_login_alert(user["id"], client_ip):
user_info = database.get_user_by_id(user["id"]) or {}
if user_info.get("email") and user_info.get("email_verified"):
if database.get_user_email_notify(user["id"]):
email_service.send_security_alert_email(
email=user_info.get("email"),
user_agent = request.headers.get("User-Agent", "")
context = database.record_login_context(user["id"], client_ip, user_agent)
if context and (context.get("new_ip") or context.get("new_device")):
if (
config.LOGIN_ALERT_ENABLED
and should_send_login_alert(user["id"], client_ip)
and email_service.get_email_settings().get("login_alert_enabled", True)
):
user_info = database.get_user_by_id(user["id"]) or {}
if user_info.get("email") and user_info.get("email_verified"):
if database.get_user_email_notify(user["id"]):
email_service.send_security_alert_email(
email=user_info.get("email"),
username=user_info.get("username") or username,
ip_address=client_ip,
user_agent=user_agent,

View File

@@ -35,6 +35,7 @@ def get_active_announcement():
"id": announcement.get("id"),
"title": announcement.get("title", ""),
"content": announcement.get("content", ""),
"image_url": announcement.get("image_url") or "",
"created_at": announcement.get("created_at"),
}
}
@@ -147,6 +148,50 @@ def get_user_email():
return jsonify({"email": user.get("email", ""), "email_verified": user.get("email_verified", False)})
@api_user_bp.route("/api/user/kdocs", methods=["GET"])
@login_required
def get_user_kdocs_settings():
"""获取当前用户的金山文档设置"""
settings = database.get_user_kdocs_settings(current_user.id)
if not settings:
return jsonify({"kdocs_unit": "", "kdocs_auto_upload": 0})
return jsonify(settings)
@api_user_bp.route("/api/user/kdocs", methods=["POST"])
@login_required
def update_user_kdocs_settings():
"""更新当前用户的金山文档设置"""
data = request.get_json() or {}
kdocs_unit = data.get("kdocs_unit")
kdocs_auto_upload = data.get("kdocs_auto_upload")
if kdocs_unit is not None:
kdocs_unit = str(kdocs_unit or "").strip()
if len(kdocs_unit) > 50:
return jsonify({"error": "县区长度不能超过50"}), 400
if kdocs_auto_upload is not None:
if isinstance(kdocs_auto_upload, bool):
kdocs_auto_upload = 1 if kdocs_auto_upload else 0
try:
kdocs_auto_upload = int(kdocs_auto_upload)
except Exception:
return jsonify({"error": "自动上传开关必须是0或1"}), 400
if kdocs_auto_upload not in (0, 1):
return jsonify({"error": "自动上传开关必须是0或1"}), 400
if not database.update_user_kdocs_settings(
current_user.id,
kdocs_unit=kdocs_unit,
kdocs_auto_upload=kdocs_auto_upload,
):
return jsonify({"error": "更新失败"}), 400
settings = database.get_user_kdocs_settings(current_user.id) or {"kdocs_unit": "", "kdocs_auto_upload": 0}
return jsonify({"success": True, "settings": settings})
@api_user_bp.route("/api/user/bind-email", methods=["POST"])
@login_required
@require_ip_not_locked
@@ -303,3 +348,37 @@ def get_run_stats():
"today_attachments": stats.get("total_attachments", 0),
}
)
@api_user_bp.route("/api/kdocs/status", methods=["GET"])
@login_required
def get_kdocs_status_for_user():
"""获取金山文档在线状态(用户端简化版)"""
try:
# 检查系统是否启用了金山文档功能
cfg = database.get_system_config() or {}
kdocs_enabled = int(cfg.get("kdocs_enabled") or 0)
if not kdocs_enabled:
return jsonify({"enabled": False, "online": False, "message": "未启用"})
# 获取金山文档状态
from services.kdocs_uploader import get_kdocs_uploader
kdocs = get_kdocs_uploader()
status = kdocs.get_status()
login_required_flag = status.get("login_required", False)
last_login_ok = status.get("last_login_ok")
# 判断是否在线
is_online = not login_required_flag and last_login_ok is True
return jsonify({
"enabled": True,
"online": is_online,
"message": "就绪" if is_online else "离线"
})
except Exception as e:
logger.error(f"获取金山文档状态失败: {e}")
return jsonify({"enabled": False, "online": False, "message": "获取失败"})

View File

@@ -14,11 +14,20 @@ def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
logger = get_logger()
try:
logger = get_logger()
except Exception:
import logging
logger = logging.getLogger("app")
logger.debug(f"[admin_required] 检查会话admin_id存在: {'admin_id' in session}")
if "admin_id" not in session:
logger.warning(f"[admin_required] 拒绝访问 {request.path} - session中无admin_id")
is_api = request.blueprint == "admin_api" or request.path.startswith("/yuyx/api")
is_api = (
request.blueprint in {"admin_api", "admin_security", "admin_security_yuyx"}
or request.path.startswith("/yuyx/api")
or request.path.startswith("/api/admin")
)
if is_api:
return jsonify({"error": "需要管理员权限"}), 403
return redirect(url_for("pages.admin_login_page"))

View File

@@ -6,7 +6,7 @@ import json
import os
from typing import Optional
from flask import Blueprint, current_app, redirect, render_template, session, url_for
from flask import Blueprint, current_app, redirect, render_template, request, session, url_for
from flask_login import current_user, login_required
from routes.decorators import admin_required
@@ -36,10 +36,18 @@ def render_app_spa_or_legacy(
logger.warning(f"[app_spa] manifest缺少入口文件: {manifest_path}")
return render_template(legacy_template_name, **legacy_context)
app_spa_js_file = f"app/{js_file}"
app_spa_css_files = [f"app/{p}" for p in css_files]
app_spa_build_id = _get_asset_build_id(
os.path.join(current_app.root_path, "static"),
[app_spa_js_file, *app_spa_css_files],
)
return render_template(
"app.html",
app_spa_js_file=f"app/{js_file}",
app_spa_css_files=[f"app/{p}" for p in css_files],
app_spa_js_file=app_spa_js_file,
app_spa_css_files=app_spa_css_files,
app_spa_build_id=app_spa_build_id,
app_spa_initial_state=spa_initial_state,
)
except FileNotFoundError:
@@ -50,6 +58,27 @@ def render_app_spa_or_legacy(
return render_template(legacy_template_name, **legacy_context)
def _get_asset_build_id(static_root: str, rel_paths: list[str]) -> Optional[str]:
mtimes = []
for rel_path in rel_paths:
if not rel_path:
continue
try:
mtimes.append(os.path.getmtime(os.path.join(static_root, rel_path)))
except OSError:
continue
if not mtimes:
return None
return str(int(max(mtimes)))
def _is_legacy_admin_user_agent(user_agent: str) -> bool:
if not user_agent:
return False
ua = user_agent.lower()
return "msie" in ua or "trident/" in ua
@pages_bp.route("/")
def index():
"""主页 - 重定向到登录或应用"""
@@ -96,6 +125,8 @@ def admin_login_page():
@admin_required
def admin_page():
"""后台管理页面"""
if request.args.get("legacy") == "1" or _is_legacy_admin_user_agent(request.headers.get("User-Agent", "")):
return render_template("admin_legacy.html")
logger = get_logger()
manifest_path = os.path.join(current_app.root_path, "static", "admin", ".vite", "manifest.json")
try:
@@ -110,10 +141,18 @@ def admin_page():
logger.warning(f"[admin_spa] manifest缺少入口文件: {manifest_path}")
return render_template("admin_legacy.html")
admin_spa_js_file = f"admin/{js_file}"
admin_spa_css_files = [f"admin/{p}" for p in css_files]
admin_spa_build_id = _get_asset_build_id(
os.path.join(current_app.root_path, "static"),
[admin_spa_js_file, *admin_spa_css_files],
)
return render_template(
"admin.html",
admin_spa_js_file=f"admin/{js_file}",
admin_spa_css_files=[f"admin/{p}" for p in css_files],
admin_spa_js_file=admin_spa_js_file,
admin_spa_css_files=admin_spa_css_files,
admin_spa_build_id=admin_spa_build_id,
)
except FileNotFoundError:
logger.warning(f"[admin_spa] 未找到manifest: {manifest_path},回退旧版后台模板")