Проект разделен на два репозитория

This commit is contained in:
mi
2026-08-14 15:42:45 +03:00
parent e06a77ee1d
commit bbef7a30c9
521 changed files with 2597 additions and 2302 deletions
@@ -0,0 +1,235 @@
from __future__ import annotations
import hmac
import json
import uuid
from typing import Annotated
from fastapi import Depends, FastAPI, Header, Request
from fastapi.responses import JSONResponse
from pydantic import TypeAdapter, ValidationError
from app.contracts import CheckRequest, ErrorBody, ErrorEnvelope, Pending, Verdict
from app.db import TaskStatus
from app.rate_limit import RateLimited
from app.repository import ConflictError
from app.service import CapabilityUnavailable, SafetyService, TaskFailed
CHECK_ADAPTER = TypeAdapter(CheckRequest)
MAX_BODY = 16_384
def error(
status: int, code: str, request_id: str, details: dict[str, object] | None = None
) -> JSONResponse:
body = ErrorEnvelope(
error=ErrorBody(
code=code,
message={
"validation_error": "Request is invalid",
"service_unauthorized": "Service authentication failed",
}.get(code, "Request could not be completed"),
request_id=request_id,
details=details or {},
)
)
return JSONResponse(status_code=status, content=body.model_dump(mode="json"))
def create_app(service: SafetyService, token: str) -> FastAPI:
app = FastAPI(title="HAN Message Safety", version="2.0.0", docs_url=None, redoc_url=None)
async def authenticate(
request: Request,
provided: Annotated[str | None, Header(alias="X-Service-Token")] = None,
) -> None:
if not provided or not hmac.compare_digest(provided.encode(), token.encode()):
request.state.auth_failed = True
raise PermissionError
@app.exception_handler(PermissionError)
async def auth_error(request: Request, _: PermissionError) -> JSONResponse:
return error(401, "service_unauthorized", request.state.request_id)
@app.middleware("http")
async def request_context(request: Request, call_next):
supplied = request.headers.get("X-Request-ID")
try:
request.state.request_id = str(uuid.UUID(supplied)) if supplied else str(uuid.uuid4())
except ValueError:
request.state.request_id = str(uuid.uuid4())
response = await call_next(request)
response.headers["X-Request-ID"] = request.state.request_id
response.headers["Cache-Control"] = "no-store"
return response
@app.get("/health/live")
async def live() -> dict[str, str]:
return {"status": "ok"}
@app.get("/health/ready")
async def ready() -> JSONResponse:
mode = "mock" if service.mode.mock else "standard"
components = {
"postgres": "ok",
"redis": "degraded",
"s3_quarantine": "bypassed"
if service.mode.mock
else ("ok" if service.files_ready else "down"),
"worker": "bypassed" if service.mode.mock else "ok",
"antivirus": "bypassed"
if service.mode.mock
else ("ok" if service.files_ready else "down"),
"dns": "bypassed" if service.mode.mock else ("ok" if service.links_ready else "down"),
"rules": "bypassed" if service.mode.mock else "ok",
}
capabilities = {
"text": "ready",
"links": "bypassed"
if service.mode.mock
else ("ready" if service.links_ready else "unavailable"),
"files": "bypassed"
if service.mode.mock
else ("ready" if service.files_ready else "unavailable"),
"worker": "bypassed" if service.mode.mock else "ready",
}
body: dict[str, object] = {
"status": "degraded"
if service.mode.mock or "degraded" in components.values()
else "ok",
"processing_mode": mode,
"config_version": service.config.version,
"components": components,
"capabilities": capabilities,
}
if service.mode.mock:
body["mock_policy"] = {
"text": "allow" if service.mode.text_free else "deny",
"file": "allow" if service.mode.file_free else "deny",
}
return JSONResponse(content=body)
@app.post("/internal/safety/v2/messages/check", dependencies=[Depends(authenticate)])
async def check(request: Request) -> JSONResponse:
content_type = request.headers.get("content-type", "").lower().replace(" ", "")
if content_type not in {"application/json", "application/json;charset=utf-8"}:
return error(
400,
"validation_error",
request.state.request_id,
{"field": "content-type", "constraint": "application/json; charset=utf-8"},
)
body = await request.body()
if len(body) > MAX_BODY:
return error(
400,
"validation_error",
request.state.request_id,
{"field": "body", "constraint": "max_bytes"},
)
try:
payload = CHECK_ADAPTER.validate_json(body, strict=True)
result = await service.check(payload)
except (ValidationError, json.JSONDecodeError, ValueError):
return error(
400,
"validation_error",
request.state.request_id,
{"field": "body", "constraint": "strict_dto"},
)
except ConflictError:
message_id = "unknown"
try:
message_id = str(json.loads(body).get("message_id", "unknown"))
except (ValueError, AttributeError):
pass
return error(
409,
"safety_request_conflict",
request.state.request_id,
{"message_id": message_id, "terminal": True, "retryable": False},
)
except CapabilityUnavailable as exc:
return error(
503,
"dependency_unavailable",
request.state.request_id,
{"dependency_category": exc.category, "terminal": False, "retryable": True},
)
except RateLimited as exc:
response = error(
429,
"rate_limit_exceeded",
request.state.request_id,
{"retryable": True, "retry_after_sec": exc.retry_after},
)
response.headers["Retry-After"] = str(exc.retry_after)
return response
except TaskFailed as exc:
return error(
503,
"task_failed",
request.state.request_id,
{
"task_id": str(exc.task_id),
"task_status": "failed",
"terminal": True,
"retryable": False,
},
)
status = 202 if isinstance(result, Pending) else (200 if result.verdict == "allow" else 403)
response = JSONResponse(status_code=status, content=result.model_dump(mode="json"))
if isinstance(result, Pending):
response.headers["Location"] = f"/internal/safety/v2/messages/tasks/{result.task_id}"
response.headers["Retry-After"] = str(result.poll_after_ms // 1000)
return response
@app.get("/internal/safety/v2/messages/tasks/{task_id}", dependencies=[Depends(authenticate)])
async def get_task(request: Request, task_id: str) -> JSONResponse:
try:
parsed = uuid.UUID(task_id)
except ValueError:
return error(
400,
"validation_error",
request.state.request_id,
{"field": "task_id", "constraint": "uuid"},
)
task = await service.repository.task(parsed)
if not task:
return error(404, "task_not_found", request.state.request_id)
if task.status == TaskStatus.failed:
return error(
503,
"task_failed",
request.state.request_id,
{
"task_id": str(task.id),
"task_status": "failed",
"terminal": True,
"retryable": False,
},
)
if task.status in {TaskStatus.pending, TaskStatus.processing}:
result: Pending | Verdict = Pending(
config_version=task.config_version,
task_id=task.id,
expires_at=task.expires_at,
rules_version=task.rules_version,
)
else:
result = service._verdict(
task.status == TaskStatus.allowed,
task.processing_mode,
task.rule_id or "safety.all_checks_passed",
task.rules_version,
config_version=task.config_version,
)
status = 202 if isinstance(result, Pending) else (200 if result.verdict == "allow" else 403)
response = JSONResponse(status_code=status, content=result.model_dump(mode="json"))
if isinstance(result, Pending):
response.headers["Location"] = str(request.url.path)
response.headers["Retry-After"] = "2"
return response
return app