feat: sync latest ARR implementation
This commit is contained in:
484
arr_web/app.py
484
arr_web/app.py
@@ -16,12 +16,18 @@ 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,
|
||||
@@ -38,9 +44,7 @@ from arr_web.company_jobs import (
|
||||
UnavailableCompanyReportCoordinator,
|
||||
)
|
||||
from arr_web.services import (
|
||||
AgentResultCoordinator,
|
||||
MonthlyCoordinator,
|
||||
UnavailableAgentResultCoordinator,
|
||||
UnavailableMonthlyCoordinator,
|
||||
UnavailableUploadCoordinator,
|
||||
UploadCoordinator,
|
||||
@@ -57,7 +61,38 @@ STATIC_ROUTES = {
|
||||
"/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]:
|
||||
@@ -78,6 +113,19 @@ def _strict_json(raw: bytes) -> Mapping[str, Any]:
|
||||
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
|
||||
@@ -85,39 +133,75 @@ class RuntimeHealth:
|
||||
monthly_ready: bool
|
||||
download_ready: bool = False
|
||||
company_reports_ready: bool = False
|
||||
agent_writeback_ready: bool = False
|
||||
company_source_upload_ready: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SessionRecord:
|
||||
username: str
|
||||
csrf: str
|
||||
expires_at: float
|
||||
|
||||
|
||||
class SessionLedger:
|
||||
"""Small in-process CSRF ledger; deployment can replace it with shared sessions."""
|
||||
"""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, tuple[str, float]] = {}
|
||||
self._sessions: Dict[str, _SessionRecord] = {}
|
||||
|
||||
def issue(self) -> tuple[str, str]:
|
||||
@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] = (csrf, now + self._lifetime)
|
||||
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[1] > now
|
||||
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
|
||||
now = time.time()
|
||||
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:
|
||||
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)
|
||||
self._sessions.pop(session_id, None)
|
||||
|
||||
|
||||
class PortalApplication:
|
||||
@@ -125,27 +209,33 @@ class PortalApplication:
|
||||
self,
|
||||
repository: Optional[PortalRepository] = None,
|
||||
upload: Optional[UploadCoordinator] = None,
|
||||
agent_results: Optional[AgentResultCoordinator] = 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._agent_results = agent_results or UnavailableAgentResultCoordinator()
|
||||
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(
|
||||
@@ -154,30 +244,44 @@ class PortalApplication:
|
||||
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":
|
||||
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,
|
||||
"username": current_session[0],
|
||||
"csrf_token": current_session[1],
|
||||
"max_upload_bytes": MAX_UPLOAD_BYTES,
|
||||
"retention": "month_minimum",
|
||||
}
|
||||
),
|
||||
{"Set-Cookie": cookie},
|
||||
)
|
||||
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(
|
||||
@@ -189,7 +293,7 @@ class PortalApplication:
|
||||
"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,
|
||||
"company_source_upload_ready": self._health.company_source_upload_ready,
|
||||
"business_time_zone": "Asia/Bangkok",
|
||||
"checked_at": datetime.now(ZoneInfo("Asia/Bangkok")).isoformat(),
|
||||
}
|
||||
@@ -198,15 +302,48 @@ class PortalApplication:
|
||||
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)))
|
||||
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", 100, 1, 200)
|
||||
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,
|
||||
success(self._repository.list_monthly_runs(month, limit)),
|
||||
_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()))
|
||||
@@ -275,13 +412,34 @@ class PortalApplication:
|
||||
)
|
||||
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)
|
||||
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,
|
||||
success(self._company_reports.list_jobs(report_month, limit)),
|
||||
_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 (
|
||||
@@ -320,29 +478,124 @@ class PortalApplication:
|
||||
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 = _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/integrations/super-agent/results"
|
||||
):
|
||||
content_type = normalized_headers.get("content-type", "").split(";", 1)[0]
|
||||
if content_type.strip().lower() != "application/json":
|
||||
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(
|
||||
"CONTENT_TYPE_INVALID",
|
||||
"Agent 回写必须使用 application/json",
|
||||
415,
|
||||
"BOOKING_EXCEL_REVIEW_REQUEST_INVALID",
|
||||
"批量删除记录请求无效",
|
||||
)
|
||||
return Response.json(
|
||||
200,
|
||||
success(self._agent_results.accept(body)),
|
||||
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)
|
||||
@@ -359,6 +612,18 @@ class PortalApplication:
|
||||
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)
|
||||
@@ -381,7 +646,7 @@ class PortalApplication:
|
||||
raise PortalError("ROUTE_NOT_FOUND", "页面或接口不存在", 404)
|
||||
except PortalDataError as error:
|
||||
status = 503
|
||||
if error.code == "DOWNLOAD_NOT_FOUND":
|
||||
if error.code in {"DOWNLOAD_NOT_FOUND", "JOB_NOT_FOUND"}:
|
||||
status = 404
|
||||
elif error.code == "DOWNLOAD_REQUEST_INVALID":
|
||||
status = 400
|
||||
@@ -397,6 +662,119 @@ class PortalApplication:
|
||||
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()
|
||||
@@ -425,13 +803,7 @@ class PortalApplication:
|
||||
)
|
||||
|
||||
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
|
||||
session_id = self._session_id(headers)
|
||||
if not self._sessions.verify(session_id, headers.get("x-arr-csrf")):
|
||||
raise PortalError("SESSION_INVALID", "页面会话已失效", 403)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user