174 lines
5.8 KiB
Python
174 lines
5.8 KiB
Python
from __future__ import annotations
|
|
|
|
import hmac
|
|
from contextlib import asynccontextmanager
|
|
from typing import Annotated
|
|
|
|
import uvicorn
|
|
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, Response
|
|
from fastapi.responses import JSONResponse
|
|
from sqlalchemy import text
|
|
|
|
from app.config import Settings, load_settings
|
|
from app.repository import Repository
|
|
from app.security import WebhookValidationError, parse_bounded_form, validate_webhook
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
settings = load_settings()
|
|
app.state.settings = settings
|
|
app.state.repository = (
|
|
Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
|
|
if settings.enabled and settings.database_url
|
|
else None
|
|
)
|
|
yield
|
|
if app.state.repository:
|
|
await app.state.repository.close()
|
|
|
|
|
|
app = FastAPI(
|
|
title="HAN Bitrix Sync",
|
|
version="0.1.0",
|
|
docs_url=None,
|
|
redoc_url=None,
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
|
|
def settings(request: Request) -> Settings:
|
|
return request.app.state.settings
|
|
|
|
|
|
def repository(request: Request) -> Repository:
|
|
repo = request.app.state.repository
|
|
if repo is None:
|
|
raise HTTPException(status_code=503, detail="sync_disabled")
|
|
return repo
|
|
|
|
|
|
async def require_service_token(
|
|
request: Request,
|
|
authorization: Annotated[str | None, Header()] = None,
|
|
) -> None:
|
|
configured = settings(request).service_token
|
|
expected = f"Bearer {configured.get_secret_value()}" if configured else ""
|
|
if not authorization or not hmac.compare_digest(authorization, expected):
|
|
raise HTTPException(status_code=401, detail="unauthorized")
|
|
|
|
|
|
@app.get("/health/live", include_in_schema=True)
|
|
async def live() -> dict[str, str]:
|
|
return {"status": "live"}
|
|
|
|
|
|
@app.get("/health/ready", include_in_schema=True)
|
|
async def ready(request: Request) -> Response:
|
|
config = settings(request)
|
|
if not config.enabled:
|
|
return JSONResponse(
|
|
status_code=503,
|
|
content={"status": "not_ready", "reason": "sync_disabled"},
|
|
)
|
|
repo = repository(request)
|
|
if not await repo.ping():
|
|
return JSONResponse(status_code=503, content={"status": "not_ready", "reason": "database"})
|
|
return JSONResponse({"status": "ready", "mode": config.mode})
|
|
|
|
|
|
@app.get(
|
|
"/internal/sync/v1/status",
|
|
dependencies=[Depends(require_service_token)],
|
|
include_in_schema=True,
|
|
)
|
|
async def sync_status(request: Request) -> dict:
|
|
result = await repository(request).status()
|
|
result["mode"] = settings(request).mode
|
|
return result
|
|
|
|
|
|
@app.post("/bitrix/sync/webhook/contact", status_code=202, include_in_schema=True)
|
|
async def contact_webhook(
|
|
request: Request,
|
|
token: Annotated[str | None, Query(max_length=256)] = None,
|
|
ID: Annotated[str | None, Query(pattern=r"^[1-9][0-9]{0,19}$")] = None, # noqa: N803
|
|
) -> Response:
|
|
return await _receive(request, "contact", {"token": token or "", "ID": ID or ""})
|
|
|
|
|
|
@app.post("/bitrix/sync/webhook/alert", status_code=202, include_in_schema=True)
|
|
async def alert_webhook(
|
|
request: Request,
|
|
token: Annotated[str | None, Query(max_length=256)] = None,
|
|
ID: Annotated[str | None, Query(pattern=r"^[1-9][0-9]{0,19}$")] = None, # noqa: N803
|
|
) -> Response:
|
|
return await _receive(request, "alert", {"token": token or "", "ID": ID or ""})
|
|
|
|
|
|
async def _receive(request: Request, receiver: str, query: dict[str, str]) -> Response:
|
|
config = settings(request)
|
|
if not config.enabled:
|
|
raise HTTPException(status_code=503, detail="sync_disabled")
|
|
if request.headers.get("content-type", "").split(";", 1)[0].lower() != (
|
|
"application/x-www-form-urlencoded"
|
|
):
|
|
raise HTTPException(status_code=400, detail="invalid_content_type")
|
|
content_length = request.headers.get("content-length")
|
|
if content_length and (
|
|
not content_length.isdigit() or int(content_length) > config.webhook_max_body_bytes
|
|
):
|
|
raise HTTPException(status_code=413, detail="body_too_large")
|
|
body = await request.body()
|
|
if len(body) > config.webhook_max_body_bytes:
|
|
raise HTTPException(status_code=413, detail="body_too_large")
|
|
form = parse_bounded_form(body, max_fields=config.webhook_max_fields)
|
|
# The container is reachable only from the trusted VM2 nginx network.
|
|
# nginx overwrites X-Real-IP from the TCP peer after its CIDR check.
|
|
source_ip = request.headers.get("x-real-ip") or (request.client.host if request.client else "")
|
|
alert_entity_type_id = (
|
|
await _alert_entity_type(repository(request)) if receiver == "alert" else None
|
|
)
|
|
try:
|
|
event = validate_webhook(
|
|
receiver,
|
|
query,
|
|
form,
|
|
source_ip,
|
|
config,
|
|
alert_entity_type_id=alert_entity_type_id,
|
|
)
|
|
except PermissionError as exc:
|
|
raise HTTPException(status_code=403, detail="forbidden") from exc
|
|
except WebhookValidationError as exc:
|
|
raise HTTPException(status_code=400, detail="malformed_webhook") from exc
|
|
await repository(request).insert_webhook(
|
|
event.receiver_type, event.event_type, event.entity_id, event.source_ip
|
|
)
|
|
return Response(status_code=202)
|
|
|
|
|
|
async def _alert_entity_type(repo: Repository) -> int | None:
|
|
async with repo.engine.connect() as connection:
|
|
value = (
|
|
await connection.execute(
|
|
text(
|
|
"""
|
|
SELECT (value_json->>'entity_type_id')::integer
|
|
FROM bitrix_sync.settings
|
|
WHERE key='business_alerts' AND active=true AND validation_status='valid'
|
|
"""
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
return value
|
|
|
|
|
|
def run() -> None:
|
|
uvicorn.run(
|
|
"app.main:app",
|
|
host="0.0.0.0", # noqa: S104 - container-only port, not host-published
|
|
port=8080,
|
|
proxy_headers=False,
|
|
)
|