"""Framework-free ARR portal application with fixed, auditable routes.""" from __future__ import annotations import base64 import json import secrets import threading import time from dataclasses import dataclass from datetime import date, datetime from http.cookies import SimpleCookie from pathlib import Path from typing import Any, Dict, Mapping, Optional from urllib.parse import parse_qs, urlsplit from urllib.parse import quote from zoneinfo import ZoneInfo from arr_web.contracts import ( MAX_UPLOAD_BYTES, PortalError, Response, failure, success, validate_month, validate_upload_filename, validate_xml_payload, ) from arr_web.repository import ( PortalDataError, PortalRepository, UnavailablePortalRepository, ) from arr_web.downloads import ArtifactReader, UnavailableArtifactReader from arr_web.company_jobs import ( COMPANY_NAMES, CompanyReportCoordinator, UnavailableCompanyReportCoordinator, ) from arr_web.services import ( AgentResultCoordinator, MonthlyCoordinator, UnavailableAgentResultCoordinator, UnavailableMonthlyCoordinator, UnavailableUploadCoordinator, UploadCoordinator, ) STATIC_ROOT = Path(__file__).resolve().parent / "static" STATIC_ROUTES = { "/": ("index.html", "text/html; charset=utf-8"), "/index.html": ("index.html", "text/html; charset=utf-8"), "/h5": ("h5.html", "text/html; charset=utf-8"), "/h5.html": ("h5.html", "text/html; charset=utf-8"), "/assets/styles.css": ("styles.css", "text/css; charset=utf-8"), "/assets/app.js": ("app.js", "text/javascript; charset=utf-8"), "/assets/h5.css": ("h5.css", "text/css; charset=utf-8"), "/assets/h5.js": ("h5.js", "text/javascript; charset=utf-8"), } def _strict_json(raw: bytes) -> Mapping[str, Any]: def pairs(values: list[tuple[str, Any]]) -> Dict[str, Any]: output: Dict[str, Any] = {} for key, value in values: if key in output: raise ValueError("duplicate key") output[key] = value return output try: value = json.loads(raw.decode("utf-8"), object_pairs_hook=pairs) except (UnicodeDecodeError, json.JSONDecodeError, ValueError): raise PortalError("REQUEST_JSON_INVALID", "请求内容不是有效 JSON") from None if not isinstance(value, Mapping): raise PortalError("REQUEST_JSON_INVALID", "请求内容不是有效 JSON") return value @dataclass(frozen=True) class RuntimeHealth: database_ready: bool upload_ready: bool monthly_ready: bool download_ready: bool = False company_reports_ready: bool = False agent_writeback_ready: bool = False class SessionLedger: """Small in-process CSRF ledger; deployment can replace it with shared sessions.""" def __init__(self, lifetime_seconds: int = 8 * 60 * 60) -> None: self._lifetime = lifetime_seconds self._lock = threading.Lock() self._sessions: Dict[str, tuple[str, float]] = {} def issue(self) -> tuple[str, str]: session_id = secrets.token_urlsafe(24) csrf = secrets.token_urlsafe(24) now = time.time() with self._lock: self._sessions[session_id] = (csrf, now + self._lifetime) if len(self._sessions) > 2048: self._sessions = { key: value for key, value in self._sessions.items() if value[1] > now } return session_id, csrf def verify(self, session_id: Optional[str], csrf: Optional[str]) -> bool: if not session_id or not csrf: return False now = time.time() with self._lock: value = self._sessions.get(session_id) if value is None or value[1] <= now: self._sessions.pop(session_id, None) return False return secrets.compare_digest(value[0], csrf) class PortalApplication: def __init__( self, repository: Optional[PortalRepository] = None, upload: Optional[UploadCoordinator] = None, agent_results: Optional[AgentResultCoordinator] = None, monthly: Optional[MonthlyCoordinator] = None, company_reports: Optional[CompanyReportCoordinator] = None, artifact_reader: Optional[ArtifactReader] = None, *, health: Optional[RuntimeHealth] = None, static_root: Path = STATIC_ROOT, sessions: Optional[SessionLedger] = None, secure_cookies: bool = False, ) -> None: self._repository = repository or UnavailablePortalRepository() self._upload = upload or UnavailableUploadCoordinator() self._agent_results = agent_results or UnavailableAgentResultCoordinator() self._monthly = monthly or UnavailableMonthlyCoordinator() self._company_reports = ( company_reports or UnavailableCompanyReportCoordinator() ) self._artifact_reader = artifact_reader or UnavailableArtifactReader() self._health = health or RuntimeHealth(False, False, False) self._static_root = static_root.resolve() self._sessions = sessions or SessionLedger() self._secure_cookies = secure_cookies def handle( self, method: str, target: str, headers: Mapping[str, str], body: bytes = b"", ) -> Response: normalized_headers = {key.lower(): value for key, value in headers.items()} route = urlsplit(target) try: if method == "GET" and route.path in STATIC_ROUTES: return self._static(route.path) if method == "GET" and route.path == "/api/session": session_id, csrf = self._sessions.issue() cookie = ( f"arr_session={session_id}; Path=/; HttpOnly; " "SameSite=Strict; Max-Age=28800" ) if self._secure_cookies: cookie += "; Secure" return Response.json( 200, success( { "csrf_token": csrf, "max_upload_bytes": MAX_UPLOAD_BYTES, "retention": "month_minimum", } ), {"Set-Cookie": cookie}, ) if method == "GET" and route.path == "/api/health": return Response.json( 200, success( { "database_ready": self._health.database_ready, "processing_ready": self._health.upload_ready, "monthly_ready": self._health.monthly_ready, "download_ready": self._health.download_ready, "company_reports_ready": self._health.company_reports_ready, "agent_writeback_ready": self._health.agent_writeback_ready, "business_time_zone": "Asia/Bangkok", "checked_at": datetime.now(ZoneInfo("Asia/Bangkok")).isoformat(), } ), ) if method == "GET" and route.path == "/api/jobs": query = parse_qs(route.query, keep_blank_values=True) month = validate_month(self._one(query, "month", self._current_month())) limit = self._integer(query, "limit", 100, 1, 200) return Response.json(200, success(self._repository.list_jobs(month, limit))) if method == "GET" and route.path == "/api/monthly-runs": query = parse_qs(route.query, keep_blank_values=True) month = validate_month(self._one(query, "month", self._current_month())) limit = self._integer(query, "limit", 100, 1, 200) return Response.json( 200, success(self._repository.list_monthly_runs(month, limit)), ) if method == "GET" and route.path == "/api/months": return Response.json(200, success(self._repository.list_months())) if method == "GET" and route.path == "/api/h5/months": return Response.json( 200, { "months": [ { "month_key": item.get("month_key"), "updated_at": item.get("updated_at"), "max_arrival_date": item.get("max_arrival_date"), } for item in self._repository.list_months() ] }, ) if method == "GET" and route.path == "/api/analytics": query = parse_qs(route.query, keep_blank_values=True) month = validate_month(self._one(query, "month", self._current_month())) return Response.json(200, success(self._repository.read_dashboard(month))) analytics_parts = route.path.strip("/").split("/") if ( method == "GET" and len(analytics_parts) == 4 and analytics_parts[:2] == ["api", "monthly"] and analytics_parts[3] == "analytics" ): try: month = validate_month(analytics_parts[2]) except PortalError as error: raise PortalError( "MONTH_KEY_INVALID", error.safe_message, 422, ) from None try: dashboard = self._repository.read_dashboard(month) except PortalDataError as error: if error.code == "ANALYTICS_MONTH_NOT_FOUND": raise PortalError( "MONTHLY_NOT_FOUND", "找不到该月份的正式月报。", 404, ) from None raise return Response.json(200, dashboard) if method == "GET" and route.path == "/api/channel-detail": query = parse_qs(route.query, keep_blank_values=True) month = validate_month(self._one(query, "month", self._current_month())) worksheet = self._one(query, "worksheet", "") if not worksheet or len(worksheet) > 31: raise PortalError("CHANNEL_INVALID", "渠道名称无效") limit = self._integer(query, "limit", 100, 1, 500) offset = self._integer(query, "offset", 0, 0, 10_000_000) return Response.json( 200, success( self._repository.read_channel_detail( month, worksheet, limit, offset, ) ), ) if method == "GET" and route.path == "/api/company-reports/jobs": query = parse_qs(route.query, keep_blank_values=True) limit = self._integer(query, "limit", 100, 1, 200) report_month = None if "month" in query: report_month = validate_month(self._one(query, "month", "")) return Response.json( 200, success(self._company_reports.list_jobs(report_month, limit)), ) company_parts = route.path.strip("/").split("/") if ( method == "GET" and len(company_parts) == 6 and company_parts[:3] == ["api", "company-reports", "jobs"] and company_parts[4] == "downloads" ): job_id = company_parts[3] company = company_parts[5] if company not in COMPANY_NAMES: raise PortalError( "COMPANY_REPORT_COMPANY_INVALID", "公司名称无效" ) return self._download( self._company_reports.resolve_download(job_id, company) ) if ( method == "GET" and len(company_parts) == 4 and company_parts[:3] == ["api", "company-reports", "jobs"] ): return Response.json( 200, success(self._company_reports.get_job(company_parts[3])), ) if method == "GET" and route.path == "/api/download/daily": query = parse_qs(route.query, keep_blank_values=True) job_id = self._one(query, "job_id", "") if not job_id: raise PortalError("DOWNLOAD_REQUEST_INVALID", "下载请求无效") return self._download(self._repository.resolve_daily_download(job_id)) if method == "GET" and route.path == "/api/download/monthly": query = parse_qs(route.query, keep_blank_values=True) report_id = self._integer(query, "report_id", 0, 1, 9_223_372_036_854_775_807) return self._download(self._repository.resolve_monthly_download(report_id)) if method == "POST" and route.path == "/api/jobs": self._require_csrf(normalized_headers) encoded = normalized_headers.get("x-arr-filename-b64", "") try: padding = "=" * (-len(encoded) % 4) filename = base64.urlsafe_b64decode(encoded + padding).decode("utf-8") except (ValueError, UnicodeDecodeError): raise PortalError("UPLOAD_FILENAME_INVALID", "请选择 XML 文件") from None filename = validate_upload_filename(filename) validate_xml_payload(body) return Response.json(202, success(self._upload.submit(filename, body))) if ( method == "POST" and route.path == "/api/integrations/super-agent/results" ): content_type = normalized_headers.get("content-type", "").split(";", 1)[0] if content_type.strip().lower() != "application/json": raise PortalError( "CONTENT_TYPE_INVALID", "Agent 回写必须使用 application/json", 415, ) return Response.json( 200, success(self._agent_results.accept(body)), ) if method == "POST" and route.path == "/api/monthly-runs": self._require_csrf(normalized_headers) if len(body) > 8192: raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413) payload = _strict_json(body) if set(payload) != {"month", "as_of_date"}: raise PortalError("MONTHLY_REQUEST_INVALID", "月报请求字段无效") month = validate_month(str(payload.get("month", ""))) try: as_of = date.fromisoformat(str(payload.get("as_of_date", ""))) except ValueError: raise PortalError("MONTHLY_REQUEST_INVALID", "月报截止日期无效") from None return Response.json(202, success(self._monthly.generate(month, as_of))) if method == "POST" and route.path == "/api/company-reports/jobs": self._require_csrf(normalized_headers) if len(body) > 8192: raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413) payload = _strict_json(body) if set(payload) != {"report_month", "period"}: raise PortalError( "COMPANY_REPORT_REQUEST_INVALID", "渠道明细请求字段无效", ) report_month = payload.get("report_month") period = payload.get("period") if not isinstance(report_month, str) or not isinstance(period, str): raise PortalError( "COMPANY_REPORT_REQUEST_INVALID", "渠道明细请求字段无效", ) return Response.json( 202, success(self._company_reports.create(report_month, period)), ) raise PortalError("ROUTE_NOT_FOUND", "页面或接口不存在", 404) except PortalDataError as error: status = 503 if error.code == "DOWNLOAD_NOT_FOUND": status = 404 elif error.code == "DOWNLOAD_REQUEST_INVALID": status = 400 return Response.json( status, failure(PortalError(error.code, error.safe_message, status)), ) except PortalError as error: return Response.json(error.status, failure(error)) except Exception: return Response.json( 500, failure(PortalError("INTERNAL_ERROR", "服务暂时无法完成请求", 500)), ) def _static(self, route: str) -> Response: filename, mime_type = STATIC_ROUTES[route] path = (self._static_root / filename).resolve() try: path.relative_to(self._static_root) body = path.read_bytes() except (ValueError, OSError): raise PortalError("STATIC_NOT_FOUND", "页面资源不存在", 404) from None return Response(200, body, mime_type, {"Cache-Control": "no-cache"}) def _download(self, descriptor: Any) -> Response: body = self._artifact_reader.read(descriptor) encoded = quote(descriptor.original_filename, safe="") return Response( 200, body, descriptor.mime_type, { "Cache-Control": "private, no-store", "Content-Disposition": ( 'attachment; filename="arr-report.xlsx"; ' f"filename*=UTF-8''{encoded}" ), "X-Artifact-SHA256": descriptor.sha256, }, ) def _require_csrf(self, headers: Mapping[str, str]) -> None: cookie = SimpleCookie() try: cookie.load(headers.get("cookie", "")) except Exception: raise PortalError("SESSION_INVALID", "页面会话已失效", 403) from None item = cookie.get("arr_session") session_id = item.value if item is not None else None if not self._sessions.verify(session_id, headers.get("x-arr-csrf")): raise PortalError("SESSION_INVALID", "页面会话已失效", 403) @staticmethod def _current_month() -> str: return datetime.now(ZoneInfo("Asia/Bangkok")).strftime("%Y-%m") @staticmethod def _one(query: Mapping[str, list[str]], key: str, default: str) -> str: values = query.get(key) if values is None: return default if len(values) != 1: raise PortalError("QUERY_INVALID", "查询参数无效") return values[0] @classmethod def _integer( cls, query: Mapping[str, list[str]], key: str, default: int, minimum: int, maximum: int, ) -> int: raw = cls._one(query, key, str(default)) try: value = int(raw) except ValueError: raise PortalError("QUERY_INVALID", "查询参数无效") from None if not minimum <= value <= maximum: raise PortalError("QUERY_INVALID", "查询参数无效") return value