840 lines
34 KiB
Python
840 lines
34 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.auth import LoginAttemptLedger, LoginCredentials
|
|
from arr_web.booking_uploads import (
|
|
BookingSourceCoordinator,
|
|
UnavailableBookingSourceCoordinator,
|
|
)
|
|
from arr_web.contracts import (
|
|
MAX_UPLOAD_BYTES,
|
|
PortalError,
|
|
Response,
|
|
failure,
|
|
success,
|
|
validate_job_id,
|
|
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 (
|
|
MonthlyCoordinator,
|
|
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"),
|
|
"/login": ("login.html", "text/html; charset=utf-8"),
|
|
"/login.html": ("login.html", "text/html; charset=utf-8"),
|
|
"/assets/login.css": ("login.css", "text/css; charset=utf-8"),
|
|
"/assets/login.js": ("login.js", "text/javascript; charset=utf-8"),
|
|
}
|
|
LOGIN_STATIC_ROUTES = {
|
|
"/login",
|
|
"/login.html",
|
|
"/assets/login.css",
|
|
"/assets/login.js",
|
|
}
|
|
LOGIN_DOCUMENT_ROUTES = {"/login", "/login.html"}
|
|
H5_DOCUMENT_ROUTES = {"/h5", "/h5.html"}
|
|
|
|
|
|
def _paged_success(
|
|
data: list[Dict[str, Any]],
|
|
*,
|
|
total: int,
|
|
limit: int,
|
|
offset: int,
|
|
) -> Dict[str, Any]:
|
|
return success(
|
|
data,
|
|
pagination={
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"has_previous": offset > 0,
|
|
"has_next": offset + len(data) < total,
|
|
},
|
|
)
|
|
|
|
|
|
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
|
|
|
|
|
|
def _decode_filename_header(
|
|
encoded: str,
|
|
*,
|
|
code: str,
|
|
safe_message: str,
|
|
) -> str:
|
|
try:
|
|
padding = "=" * (-len(encoded) % 4)
|
|
return base64.urlsafe_b64decode(encoded + padding).decode("utf-8")
|
|
except (ValueError, UnicodeDecodeError):
|
|
raise PortalError(code, safe_message) from None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RuntimeHealth:
|
|
database_ready: bool
|
|
upload_ready: bool
|
|
monthly_ready: bool
|
|
download_ready: bool = False
|
|
company_reports_ready: bool = False
|
|
company_source_upload_ready: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _SessionRecord:
|
|
username: str
|
|
csrf: str
|
|
expires_at: float
|
|
|
|
|
|
class SessionLedger:
|
|
"""Small authenticated-session ledger for the single-process Web runtime."""
|
|
|
|
def __init__(self, lifetime_seconds: int = 8 * 60 * 60) -> None:
|
|
self._lifetime = lifetime_seconds
|
|
self._lock = threading.Lock()
|
|
self._sessions: Dict[str, _SessionRecord] = {}
|
|
|
|
@property
|
|
def lifetime_seconds(self) -> int:
|
|
return self._lifetime
|
|
|
|
def issue(self, username: str) -> tuple[str, str]:
|
|
session_id = secrets.token_urlsafe(24)
|
|
csrf = secrets.token_urlsafe(24)
|
|
now = time.time()
|
|
with self._lock:
|
|
self._sessions[session_id] = _SessionRecord(
|
|
username=username,
|
|
csrf=csrf,
|
|
expires_at=now + self._lifetime,
|
|
)
|
|
if len(self._sessions) > 2048:
|
|
self._sessions = {
|
|
key: value
|
|
for key, value in self._sessions.items()
|
|
if value.expires_at > now
|
|
}
|
|
if len(self._sessions) > 2048:
|
|
oldest = sorted(
|
|
self._sessions,
|
|
key=lambda key: self._sessions[key].expires_at,
|
|
)
|
|
for key in oldest[: len(self._sessions) - 2048]:
|
|
self._sessions.pop(key, None)
|
|
return session_id, csrf
|
|
|
|
def current(self, session_id: Optional[str]) -> Optional[tuple[str, str]]:
|
|
if not session_id:
|
|
return None
|
|
now = time.time()
|
|
with self._lock:
|
|
value = self._sessions.get(session_id)
|
|
if value is None or value.expires_at <= now:
|
|
self._sessions.pop(session_id, None)
|
|
return None
|
|
return value.username, value.csrf
|
|
|
|
def verify(self, session_id: Optional[str], csrf: Optional[str]) -> bool:
|
|
if not session_id or not csrf:
|
|
return False
|
|
current = self.current(session_id)
|
|
return current is not None and secrets.compare_digest(current[1], csrf)
|
|
|
|
def revoke(self, session_id: Optional[str]) -> None:
|
|
if not session_id:
|
|
return
|
|
with self._lock:
|
|
self._sessions.pop(session_id, None)
|
|
|
|
|
|
class PortalApplication:
|
|
def __init__(
|
|
self,
|
|
repository: Optional[PortalRepository] = None,
|
|
upload: Optional[UploadCoordinator] = None,
|
|
monthly: Optional[MonthlyCoordinator] = None,
|
|
company_reports: Optional[CompanyReportCoordinator] = None,
|
|
booking_sources: Optional[BookingSourceCoordinator] = None,
|
|
artifact_reader: Optional[ArtifactReader] = None,
|
|
*,
|
|
health: Optional[RuntimeHealth] = None,
|
|
static_root: Path = STATIC_ROOT,
|
|
sessions: Optional[SessionLedger] = None,
|
|
credentials: Optional[LoginCredentials] = None,
|
|
login_attempts: Optional[LoginAttemptLedger] = None,
|
|
secure_cookies: bool = False,
|
|
) -> None:
|
|
self._repository = repository or UnavailablePortalRepository()
|
|
self._upload = upload or UnavailableUploadCoordinator()
|
|
self._monthly = monthly or UnavailableMonthlyCoordinator()
|
|
self._company_reports = (
|
|
company_reports or UnavailableCompanyReportCoordinator()
|
|
)
|
|
self._booking_sources = (
|
|
booking_sources or UnavailableBookingSourceCoordinator()
|
|
)
|
|
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._credentials = credentials
|
|
self._login_attempts = login_attempts or LoginAttemptLedger()
|
|
self._secure_cookies = secure_cookies
|
|
|
|
def handle(
|
|
self,
|
|
method: str,
|
|
target: str,
|
|
headers: Mapping[str, str],
|
|
body: bytes = b"",
|
|
client_id: str = "direct",
|
|
) -> Response:
|
|
normalized_headers = {key.lower(): value for key, value in headers.items()}
|
|
route = urlsplit(target)
|
|
try:
|
|
session_id = self._session_id(normalized_headers)
|
|
current_session = self._sessions.current(session_id)
|
|
if method == "GET" and route.path == "/healthz":
|
|
return self._readiness()
|
|
if method == "GET" and route.path in LOGIN_STATIC_ROUTES:
|
|
if route.path in LOGIN_DOCUMENT_ROUTES and current_session is not None:
|
|
return self._redirect(self._safe_next(route.query))
|
|
return self._static(route.path)
|
|
if method == "POST" and route.path == "/api/login":
|
|
return self._login(body, normalized_headers, client_id)
|
|
if current_session is None:
|
|
return self._authentication_required(method, route.path)
|
|
if method == "GET" and route.path in STATIC_ROUTES:
|
|
return self._static(route.path)
|
|
if method == "GET" and route.path == "/api/session":
|
|
return Response.json(
|
|
200,
|
|
success(
|
|
{
|
|
"username": current_session[0],
|
|
"csrf_token": current_session[1],
|
|
"max_upload_bytes": MAX_UPLOAD_BYTES,
|
|
"retention": "month_minimum",
|
|
}
|
|
),
|
|
)
|
|
if method == "POST" and route.path == "/api/logout":
|
|
self._require_csrf(normalized_headers)
|
|
self._sessions.revoke(session_id)
|
|
return Response.json(
|
|
200,
|
|
success({"logged_out": True}),
|
|
{"Set-Cookie": self._session_cookie("", max_age=0)},
|
|
)
|
|
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,
|
|
"company_source_upload_ready": self._health.company_source_upload_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", 50, 1, 200)
|
|
offset = self._integer(query, "offset", 0, 0, 10_000_000)
|
|
jobs, total = self._repository.list_jobs(month, limit, offset)
|
|
return Response.json(
|
|
200,
|
|
_paged_success(
|
|
jobs,
|
|
total=total,
|
|
limit=limit,
|
|
offset=offset,
|
|
),
|
|
)
|
|
job_parts = route.path.strip("/").split("/")
|
|
if (
|
|
method == "GET"
|
|
and len(job_parts) == 4
|
|
and job_parts[:2] == ["api", "jobs"]
|
|
and job_parts[3] == "trace"
|
|
):
|
|
job_id = validate_job_id(job_parts[2])
|
|
return Response.json(
|
|
200,
|
|
success(self._repository.get_job_trace(job_id)),
|
|
)
|
|
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", 50, 1, 200)
|
|
offset = self._integer(query, "offset", 0, 0, 10_000_000)
|
|
runs, total = self._repository.list_monthly_runs(
|
|
month,
|
|
limit,
|
|
offset,
|
|
)
|
|
return Response.json(
|
|
200,
|
|
_paged_success(
|
|
runs,
|
|
total=total,
|
|
limit=limit,
|
|
offset=offset,
|
|
),
|
|
)
|
|
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", 50, 1, 200)
|
|
offset = self._integer(query, "offset", 0, 0, 10_000_000)
|
|
report_month = None
|
|
if "month" in query:
|
|
report_month = validate_month(self._one(query, "month", ""))
|
|
jobs, total = self._company_reports.list_jobs(
|
|
report_month,
|
|
limit,
|
|
offset,
|
|
)
|
|
return Response.json(
|
|
200,
|
|
_paged_success(
|
|
jobs,
|
|
total=total,
|
|
limit=limit,
|
|
offset=offset,
|
|
),
|
|
)
|
|
if method == "GET" and route.path == "/api/company-reports/source":
|
|
return Response.json(200, success(self._booking_sources.current()))
|
|
if method == "GET" and route.path == "/api/company-reports/source/draft":
|
|
query = parse_qs(route.query, keep_blank_values=True)
|
|
limit = self._integer(query, "limit", 50, 1, 200)
|
|
offset = self._integer(query, "offset", 0, 0, 10_000_000)
|
|
return Response.json(
|
|
200,
|
|
success(self._booking_sources.draft(limit, offset)),
|
|
)
|
|
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)
|
|
filename = _decode_filename_header(
|
|
normalized_headers.get("x-arr-filename-b64", ""),
|
|
code="UPLOAD_FILENAME_INVALID",
|
|
safe_message="请选择 XML 文件",
|
|
)
|
|
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/company-reports/source":
|
|
self._require_csrf(normalized_headers)
|
|
filename = _decode_filename_header(
|
|
normalized_headers.get("x-arr-filename-b64", ""),
|
|
code="BOOKING_EXCEL_FILENAME_INVALID",
|
|
safe_message="请选择 .xlsx 格式的 Excel 文件",
|
|
)
|
|
return Response.json(
|
|
200,
|
|
success(self._booking_sources.submit(filename, body)),
|
|
)
|
|
if method == "DELETE" and route.path == "/api/company-reports/source/draft/items":
|
|
self._require_csrf(normalized_headers)
|
|
if len(body) > 8192:
|
|
raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
|
|
payload = _strict_json(body)
|
|
if set(payload) != {"draft_id", "item_ids"}:
|
|
raise PortalError(
|
|
"BOOKING_EXCEL_REVIEW_REQUEST_INVALID",
|
|
"批量删除记录请求无效",
|
|
)
|
|
return Response.json(
|
|
200,
|
|
success(
|
|
self._booking_sources.delete_items(
|
|
payload.get("draft_id"),
|
|
payload.get("item_ids"),
|
|
)
|
|
),
|
|
)
|
|
if (
|
|
method in {"PATCH", "DELETE"}
|
|
and len(company_parts) == 6
|
|
and company_parts[:5]
|
|
== ["api", "company-reports", "source", "draft", "items"]
|
|
):
|
|
self._require_csrf(normalized_headers)
|
|
try:
|
|
item_id = int(company_parts[5])
|
|
except ValueError:
|
|
raise PortalError(
|
|
"BOOKING_EXCEL_REVIEW_ITEM_INVALID",
|
|
"待确认记录编号无效",
|
|
) from None
|
|
if item_id <= 0:
|
|
raise PortalError(
|
|
"BOOKING_EXCEL_REVIEW_ITEM_INVALID",
|
|
"待确认记录编号无效",
|
|
)
|
|
if len(body) > 8192:
|
|
raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
|
|
payload = _strict_json(body)
|
|
if method == "PATCH":
|
|
if set(payload) != {"draft_id", "room_type", "quantity"}:
|
|
raise PortalError(
|
|
"BOOKING_EXCEL_REVIEW_REQUEST_INVALID",
|
|
"人工确认字段无效",
|
|
)
|
|
return Response.json(
|
|
200,
|
|
success(
|
|
self._booking_sources.update_item(
|
|
payload.get("draft_id"),
|
|
item_id,
|
|
payload.get("room_type"),
|
|
payload.get("quantity"),
|
|
)
|
|
),
|
|
)
|
|
if set(payload) != {"draft_id"}:
|
|
raise PortalError(
|
|
"BOOKING_EXCEL_REVIEW_REQUEST_INVALID",
|
|
"删除记录请求无效",
|
|
)
|
|
return Response.json(
|
|
200,
|
|
success(
|
|
self._booking_sources.delete_item(
|
|
payload.get("draft_id"),
|
|
item_id,
|
|
)
|
|
),
|
|
)
|
|
if method == "DELETE" and route.path == "/api/company-reports/source/draft":
|
|
self._require_csrf(normalized_headers)
|
|
if len(body) > 8192:
|
|
raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
|
|
payload = _strict_json(body)
|
|
if set(payload) != {"draft_id"}:
|
|
raise PortalError(
|
|
"BOOKING_EXCEL_REVIEW_REQUEST_INVALID",
|
|
"放弃提取请求无效",
|
|
)
|
|
return Response.json(
|
|
200,
|
|
success(self._booking_sources.discard(payload.get("draft_id"))),
|
|
)
|
|
if method == "POST" and route.path == "/api/company-reports/source/draft/activate":
|
|
self._require_csrf(normalized_headers)
|
|
if len(body) > 8192:
|
|
raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
|
|
payload = _strict_json(body)
|
|
if set(payload) != {"draft_id"}:
|
|
raise PortalError(
|
|
"BOOKING_EXCEL_REVIEW_REQUEST_INVALID",
|
|
"确认启用请求无效",
|
|
)
|
|
return Response.json(
|
|
200,
|
|
success(self._booking_sources.activate(payload.get("draft_id"))),
|
|
)
|
|
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 self._booking_sources.draft(1, 0) is not None:
|
|
raise PortalError(
|
|
"BOOKING_EXCEL_REVIEW_OPEN",
|
|
"请先完成或放弃当前 Excel 提取草稿",
|
|
409,
|
|
)
|
|
if self._booking_sources.current() is None:
|
|
raise PortalError(
|
|
"BOOKING_EXCEL_SOURCE_REQUIRED",
|
|
"请先上传并校验 Excel 报表",
|
|
409,
|
|
)
|
|
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 in {"DOWNLOAD_NOT_FOUND", "JOB_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 _readiness(self) -> Response:
|
|
ready = self._health.database_ready and self._health.upload_ready
|
|
return Response(
|
|
200 if ready else 503,
|
|
b"ready\n" if ready else b"unavailable\n",
|
|
"text/plain; charset=utf-8",
|
|
{"Cache-Control": "no-store"},
|
|
)
|
|
|
|
def _login(
|
|
self,
|
|
body: bytes,
|
|
headers: Mapping[str, str],
|
|
client_id: str,
|
|
) -> Response:
|
|
media_type = headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
|
if media_type != "application/json":
|
|
raise PortalError("LOGIN_REQUEST_INVALID", "登录请求格式无效", 415)
|
|
if len(body) > 8192:
|
|
raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
|
|
retry_after = self._login_attempts.retry_after(client_id)
|
|
if retry_after:
|
|
return self._login_rate_limited(retry_after)
|
|
payload = _strict_json(body)
|
|
if set(payload) != {"username", "password"}:
|
|
raise PortalError("LOGIN_REQUEST_INVALID", "登录请求字段无效")
|
|
username = payload.get("username")
|
|
password = payload.get("password")
|
|
if self._credentials is None or not self._credentials.verify(
|
|
username,
|
|
password,
|
|
):
|
|
retry_after = self._login_attempts.record_failure(client_id)
|
|
if retry_after:
|
|
return self._login_rate_limited(retry_after)
|
|
raise PortalError("LOGIN_FAILED", "用户名或密码不正确", 401)
|
|
assert isinstance(username, str)
|
|
self._login_attempts.record_success(client_id)
|
|
self._sessions.revoke(self._session_id(headers))
|
|
session_id, csrf = self._sessions.issue(username)
|
|
return Response.json(
|
|
200,
|
|
success(
|
|
{
|
|
"username": username,
|
|
"csrf_token": csrf,
|
|
"expires_in": self._sessions.lifetime_seconds,
|
|
}
|
|
),
|
|
{"Set-Cookie": self._session_cookie(session_id)},
|
|
)
|
|
|
|
@staticmethod
|
|
def _login_rate_limited(retry_after: int) -> Response:
|
|
return Response.json(
|
|
429,
|
|
failure(
|
|
PortalError(
|
|
"LOGIN_RATE_LIMITED",
|
|
"登录尝试过于频繁,请稍后再试",
|
|
429,
|
|
)
|
|
),
|
|
{"Retry-After": str(retry_after)},
|
|
)
|
|
|
|
def _authentication_required(self, method: str, route_path: str) -> Response:
|
|
if method == "GET" and not route_path.startswith("/api/"):
|
|
next_path = "/h5" if route_path in H5_DOCUMENT_ROUTES else "/"
|
|
return self._redirect(f"/login?next={quote(next_path, safe='')}")
|
|
return Response.json(
|
|
401,
|
|
failure(PortalError("AUTH_REQUIRED", "请先登录", 401)),
|
|
)
|
|
|
|
@staticmethod
|
|
def _redirect(location: str) -> Response:
|
|
return Response(
|
|
303,
|
|
b"",
|
|
"text/plain; charset=utf-8",
|
|
{"Cache-Control": "no-store", "Location": location},
|
|
)
|
|
|
|
@staticmethod
|
|
def _safe_next(query: str) -> str:
|
|
values = parse_qs(query, keep_blank_values=True).get("next", [])
|
|
if len(values) == 1 and values[0] in {"/", "/h5"}:
|
|
return values[0]
|
|
return "/"
|
|
|
|
@staticmethod
|
|
def _session_id(headers: Mapping[str, str]) -> Optional[str]:
|
|
cookie = SimpleCookie()
|
|
try:
|
|
cookie.load(headers.get("cookie", ""))
|
|
except Exception:
|
|
return None
|
|
item = cookie.get("arr_session")
|
|
return item.value if item is not None else None
|
|
|
|
def _session_cookie(self, session_id: str, *, max_age: Optional[int] = None) -> str:
|
|
age = self._sessions.lifetime_seconds if max_age is None else max_age
|
|
cookie = (
|
|
f"arr_session={session_id}; Path=/; HttpOnly; "
|
|
f"SameSite=Strict; Max-Age={age}"
|
|
)
|
|
if age == 0:
|
|
cookie += "; Expires=Thu, 01 Jan 1970 00:00:00 GMT"
|
|
if self._secure_cookies:
|
|
cookie += "; Secure"
|
|
return cookie
|
|
|
|
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:
|
|
session_id = self._session_id(headers)
|
|
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
|