from __future__ import annotations import html import json from datetime import datetime from typing import Any, Dict from urllib.parse import quote from flask import Response, jsonify, request from outlook_web.audit import log_audit from outlook_web.errors import build_error_payload from outlook_web.repositories import accounts as accounts_repo from outlook_web.repositories import groups as groups_repo from outlook_web.repositories import temp_emails as temp_emails_repo from outlook_web.security.auth import ( consume_export_verify_token, get_client_ip, get_user_agent, login_required, ) def sanitize_input(text: str, max_length: int = 500) -> str: """ 净化用户输入,防止XSS攻击 - 转义HTML特殊字符 - 限制长度 - 移除控制字符 """ if not text: return "" # 限制长度 text = text[:max_length] # 移除控制字符(保留换行和制表符) text = "".join(char for char in text if char.isprintable() or char in "\n\t") # 转义HTML特殊字符 text = html.escape(text, quote=True) return text # ==================== 分组 API ==================== @login_required def api_get_groups() -> Any: """获取所有分组""" groups = groups_repo.load_groups() # 添加每个分组的邮箱数量 for group in groups: if group["name"] == "临时邮箱": # 临时邮箱分组从 temp_emails 表获取数量 group["account_count"] = temp_emails_repo.get_temp_email_count() else: group["account_count"] = groups_repo.get_group_account_count(group["id"]) return jsonify({"success": True, "groups": groups}) @login_required def api_get_group(group_id: int) -> Any: """获取单个分组""" group = groups_repo.get_group_by_id(group_id) if not group: return jsonify({"success": False, "error": "分组不存在"}) group["account_count"] = groups_repo.get_group_account_count(group_id) return jsonify({"success": True, "group": group}) @login_required def api_add_group() -> Any: """添加分组""" data = request.json name = sanitize_input(data.get("name", "").strip(), max_length=100) description = sanitize_input(data.get("description", ""), max_length=500) color = data.get("color", "#1a1a1a") proxy_url = data.get("proxy_url", "").strip() if not name: return jsonify({"success": False, "error": "分组名称不能为空"}) group_id = groups_repo.add_group(name, description, color, proxy_url) if group_id: log_audit("create", "group", str(group_id), f"创建分组:{name}") return jsonify({"success": True, "message": "分组创建成功", "group_id": group_id}) else: return jsonify({"success": False, "error": "分组名称已存在"}) @login_required def api_update_group(group_id: int) -> Any: """更新分组""" data = request.json name = sanitize_input(data.get("name", "").strip(), max_length=100) description = sanitize_input(data.get("description", ""), max_length=500) color = data.get("color", "#1a1a1a") proxy_url = data.get("proxy_url", "").strip() if not name: return jsonify({"success": False, "error": "分组名称不能为空"}) existing = groups_repo.get_group_by_id(group_id) if not existing: error_payload = build_error_payload( code="GROUP_NOT_FOUND", message="分组不存在", err_type="NotFoundError", status=404, details=f"group_id={group_id}", ) return jsonify({"success": False, "error": error_payload}), 404 # 系统分组保护:不允许重命名(避免破坏系统逻辑) if existing.get("is_system") and name != existing.get("name"): error_payload = build_error_payload( code="SYSTEM_GROUP_PROTECTED", message="系统分组不允许重命名", err_type="ForbiddenError", status=403, details=f"group_id={group_id}", ) return jsonify({"success": False, "error": error_payload}), 403 if groups_repo.update_group(group_id, name, description, color, proxy_url): # 不记录 proxy_url 明文(可能包含代理账号/密码) details = json.dumps( { "name": name, "has_description": bool(description), "color": color, "proxy_configured": bool(proxy_url), }, ensure_ascii=False, ) log_audit("update", "group", str(group_id), details) return jsonify({"success": True, "message": "分组更新成功"}) else: return jsonify({"success": False, "error": "更新失败"}) @login_required def api_delete_group(group_id: int) -> Any: """删除分组""" group = groups_repo.get_group_by_id(group_id) if not group: error_payload = build_error_payload( code="GROUP_NOT_FOUND", message="分组不存在", err_type="NotFoundError", status=404, details=f"group_id={group_id}", ) return jsonify({"success": False, "error": error_payload}), 404 if group.get("is_system"): error_payload = build_error_payload( code="SYSTEM_GROUP_PROTECTED", message="系统分组不能删除", err_type="ForbiddenError", status=403, details=f"group_id={group_id}", ) return jsonify({"success": False, "error": error_payload}), 403 default_group_id = groups_repo.get_default_group_id() if group_id == default_group_id or group.get("name") == "默认分组": error_payload = build_error_payload( code="DEFAULT_GROUP_PROTECTED", message="默认分组不能删除", err_type="ForbiddenError", status=403, details=f"group_id={group_id}", ) return jsonify({"success": False, "error": error_payload}), 403 if groups_repo.delete_group(group_id): log_audit("delete", "group", str(group_id), "删除分组并迁移账号到默认分组") return jsonify({"success": True, "message": "分组已删除,邮箱已移至默认分组"}) else: return jsonify({"success": False, "error": "删除失败"}) @login_required def api_export_group(group_id: int) -> Any: """导出分组下的所有邮箱账号为 TXT 文件(需要二次验证)""" # 从请求头获取二次验证 token(避免 URL 泄露) verify_token = request.headers.get("X-Export-Token") client_ip = get_client_ip() user_agent = get_user_agent() ok, error_message = consume_export_verify_token(verify_token, client_ip, user_agent) if not ok: return ( jsonify({"success": False, "error": error_message, "need_verify": True}), 401, ) group = groups_repo.get_group_by_id(group_id) if not group: return jsonify({"success": False, "error": "分组不存在"}) # 使用 load_accounts 获取该分组下的所有账号(自动解密) accounts = accounts_repo.load_accounts(group_id) if not accounts: return jsonify({"success": False, "error": "该分组下没有邮箱账号"}) # 记录审计日志 log_audit( "export", "group", str(group_id), f"导出分组 '{group['name']}' 的 {len(accounts)} 个账号", ) # 生成导出内容(格式:email----password----client_id----refresh_token) lines = [] for acc in accounts: line = f"{acc['email']}----{acc.get('password', '')}----{acc['client_id']}----{acc['refresh_token']}" lines.append(line) content = "\n".join(lines) # 生成文件名(使用 URL 编码处理中文) filename = f"{group['name']}_accounts_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" encoded_filename = quote(filename) # 返回文件下载响应 return Response( content, mimetype="text/plain; charset=utf-8", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"}, )