468 lines
20 KiB
Python
468 lines
20 KiB
Python
"""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
|