Files
zsglpt/routes/admin_api/core.py

273 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import os
import time
import database
from app_config import get_config
from app_logger import get_logger
from app_security import get_rate_limit_ip, require_ip_not_locked
from flask import current_app, jsonify, redirect, request, session, url_for
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.state import (
safe_get_user_accounts_snapshot,
safe_verify_and_consume_captcha,
check_login_ip_user_locked,
check_login_rate_limits,
get_login_failure_delay_seconds,
record_login_username_attempt,
check_ip_request_rate,
check_login_captcha_required,
clear_login_failures,
record_login_failure,
)
from services.tasks import submit_account_task
logger = get_logger("app")
config = get_config()
def _admin_reauth_required() -> bool:
try:
return time.time() > float(session.get("admin_reauth_until", 0) or 0)
except Exception:
return True
def _require_admin_reauth():
if _admin_reauth_required():
return jsonify({"error": "需要二次确认", "code": "reauth_required"}), 401
return None
@admin_api_bp.route("/debug-config", methods=["GET"])
@admin_required
def debug_config():
"""调试配置信息(仅管理员可访问,生产环境应禁用)"""
if not current_app.debug:
return jsonify({"error": "调试端点已在生产环境禁用"}), 403
return jsonify(
{
"secret_key_set": bool(current_app.secret_key),
"secret_key_length": len(current_app.secret_key) if current_app.secret_key else 0,
"session_config": {
"SESSION_COOKIE_NAME": current_app.config.get("SESSION_COOKIE_NAME"),
"SESSION_COOKIE_SECURE": current_app.config.get("SESSION_COOKIE_SECURE"),
"SESSION_COOKIE_HTTPONLY": current_app.config.get("SESSION_COOKIE_HTTPONLY"),
"SESSION_COOKIE_SAMESITE": current_app.config.get("SESSION_COOKIE_SAMESITE"),
"PERMANENT_SESSION_LIFETIME": str(current_app.config.get("PERMANENT_SESSION_LIFETIME")),
},
"has_session": bool(session),
"cookies_received": list(request.cookies.keys()),
}
)
@admin_api_bp.route("/login", methods=["POST"])
@require_ip_not_locked
def admin_login():
"""管理员登录支持JSON和form-data两种格式"""
if request.is_json:
data = request.json or {}
else:
data = request.form
username = data.get("username", "").strip()
password = data.get("password", "").strip()
captcha_session = data.get("captcha_session", "")
captcha_code = data.get("captcha", "").strip()
need_captcha = data.get("need_captcha", False)
client_ip = get_rate_limit_ip()
username_key = username
scan_locked = record_login_username_attempt(client_ip, username_key)
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}"
if request.is_json:
return jsonify({"error": f"账号短时锁定,请{wait_hint}后再试", "need_captcha": True}), 429
return redirect(url_for("pages.admin_login_page"))
allowed, error_msg = check_ip_request_rate(client_ip, "login")
if not allowed:
if request.is_json:
return jsonify({"error": error_msg, "need_captcha": True}), 429
return redirect(url_for("pages.admin_login_page"))
allowed, error_msg = check_login_rate_limits(client_ip, username_key)
if not allowed:
if request.is_json:
return jsonify({"error": error_msg, "need_captcha": True}), 429
return redirect(url_for("pages.admin_login_page"))
captcha_required = check_login_captcha_required(client_ip, username_key) or scan_locked or bool(need_captcha)
if captcha_required:
if not captcha_session or not captcha_code:
if request.is_json:
return jsonify({"error": "请填写验证码", "need_captcha": True}), 400
return redirect(url_for("pages.admin_login_page"))
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
if not success:
record_login_failure(client_ip, username_key)
if request.is_json:
return jsonify({"error": message, "need_captcha": True}), 400
return redirect(url_for("pages.admin_login_page"))
admin = database.verify_admin(username, password)
if admin:
clear_login_failures(client_ip, username_key)
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
logger.info(f"[admin_login] 管理员 {username} 登录成功")
if request.is_json:
return jsonify({"success": True, "redirect": "/yuyx/admin"})
return redirect(url_for("pages.admin_page"))
record_login_failure(client_ip, username_key)
delay = get_login_failure_delay_seconds(client_ip, username_key)
if delay > 0:
time.sleep(delay)
logger.warning(f"[admin_login] 管理员 {username} 登录失败 - 用户名或密码错误")
if request.is_json:
return jsonify({"error": "管理员用户名或密码错误", "need_captcha": check_login_captcha_required(client_ip)}), 401
return redirect(url_for("pages.admin_login_page"))
@admin_api_bp.route("/logout", methods=["POST"])
@admin_required
def admin_logout():
"""管理员登出"""
session.pop("admin_id", None)
session.pop("admin_username", None)
session.pop("admin_reauth_until", None)
return jsonify({"success": True})
@admin_api_bp.route("/admin/reauth", methods=["POST"])
@admin_required
def admin_reauth():
"""管理员敏感操作二次确认"""
data = request.json or {}
password = (data.get("password") or "").strip()
if not password:
return jsonify({"error": "密码不能为空"}), 400
username = session.get("admin_username")
if not username:
return jsonify({"error": "未登录"}), 401
admin = database.verify_admin(username, password)
if not admin:
return jsonify({"error": "密码错误"}), 401
session["admin_reauth_until"] = time.time() + int(config.ADMIN_REAUTH_WINDOW_SECONDS)
session.modified = True
return jsonify({"success": True, "expires_in": int(config.ADMIN_REAUTH_WINDOW_SECONDS)})
@admin_api_bp.route("/docker/restart", methods=["POST"])
@admin_required
def restart_docker_container():
"""重启Docker容器"""
import subprocess
try:
reauth_response = _require_admin_reauth()
if reauth_response:
return reauth_response
if not os.path.exists("/.dockerenv"):
return jsonify({"error": "当前不在Docker容器中运行"}), 400
logger.info("[系统] 管理员触发Docker容器重启")
restart_script = """
import os
import time
time.sleep(3)
os._exit(0)
"""
with open("/tmp/restart_container.py", "w") as f:
f.write(restart_script)
subprocess.Popen(
["python3", "/tmp/restart_container.py"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
return jsonify({"success": True, "message": "容器将在3秒后重启请稍后刷新页面"})
except Exception as e:
logger.error(f"[系统] Docker容器重启失败: {str(e)}")
return jsonify({"error": f"重启失败: {str(e)}"}), 500
# ==================== 断点续传(管理员) ====================
@admin_api_bp.route("/checkpoint/paused")
@admin_required
def checkpoint_get_paused():
try:
user_id = request.args.get("user_id", type=int)
tasks = get_checkpoint_mgr().get_paused_tasks(user_id=user_id)
return jsonify({"success": True, "tasks": tasks})
except Exception as e:
logger.error(f"获取暂停任务失败: {e}")
return jsonify({"success": False, "message": str(e)}), 500
@admin_api_bp.route("/checkpoint/<task_id>/resume", methods=["POST"])
@admin_required
def checkpoint_resume(task_id):
try:
checkpoint_mgr = get_checkpoint_mgr()
checkpoint = checkpoint_mgr.get_checkpoint(task_id)
if not checkpoint:
return jsonify({"success": False, "message": "任务不存在"}), 404
if checkpoint["status"] != "paused":
return jsonify({"success": False, "message": "任务未暂停"}), 400
if checkpoint_mgr.resume_task(task_id):
user_id = checkpoint["user_id"]
if not safe_get_user_accounts_snapshot(user_id):
load_user_accounts(user_id)
ok, msg = submit_account_task(
user_id=user_id,
account_id=checkpoint["account_id"],
browse_type=checkpoint["browse_type"],
enable_screenshot=True,
source="resumed",
)
if not ok:
return jsonify({"success": False, "message": msg}), 400
return jsonify({"success": True})
return jsonify({"success": False}), 500
except Exception as e:
logger.error(f"恢复任务失败: {e}")
return jsonify({"success": False, "message": str(e)}), 500
@admin_api_bp.route("/checkpoint/<task_id>/abandon", methods=["POST"])
@admin_required
def checkpoint_abandon(task_id):
try:
if get_checkpoint_mgr().abandon_task(task_id):
return jsonify({"success": True})
return jsonify({"success": False}), 404
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500