feat: 添加安全模块 + Dockerfile添加curl支持健康检查
主要更新: - 新增 security/ 安全模块 (风险评估、威胁检测、蜜罐等) - Dockerfile 添加 curl 以支持 Docker 健康检查 - 前端页面更新 (管理后台、用户端) - 数据库迁移和 schema 更新 - 新增 kdocs 上传服务 - 添加安全相关测试用例 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
348
routes/admin_api/security.py
Normal file
348
routes/admin_api/security.py
Normal 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})
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user