Sluzba prijme adresu HTML dokumentu nebo HTML v tele requestu a vrati PDF. Navrzena pro dokumenty o stovkach az tisicich stranek. Rendering: - WeasyPrint jako vychozi engine, spravne CSS Paged Media, nizka pametova narocnost, bez JavaScriptu - Chromium pres Playwright pro dokumenty dokreslovane skripty - rezim auto s detekci skriptu a fallbackem pri selhani WeasyPrintu Velke dokumenty: - deleni na casti na strukturalnich hranicich, rez nikdy uvnitr tabulky nebo odstavce - dvoupruchodovy render obsahu se skutecnymi cisly stranek, pozice nadpisu se ctou z kotev hlasenych u kazde stranky - cislovani stranek bud pres CSS countery, nebo pres cislovaci vrstvu nastampovanou na hotove PDF, rozmer stranky se cte z vysledneho souboru - Chromium se restartuje po N jobech, nikdy vsak behem beziciho renderu API: - POST /convert synchronne, POST /jobs asynchronne se sledovanim stavu, stahovanim vysledku, rusenim a volitelnym callbackem - GET /health s overenim dostupnosti obou enginu a stavem fronty - OpenAPI respektuje prefix reverse proxy pres root_path Bezpecnost a provoz: - SSRF kontrola po DNS resolvu, na kazdem presmerovani a u vsech pozadavku prohlizece - nedostupne assety render nezastavi, ale hlasi se v odpovedi i v logu - fronta s omezenym poctem workeru, rozpracovane joby se pri ukonceni oznaci jako failed, nezmizi potichu - strukturovane JSON logovani s job_id - vsechny limity vypnute ve vychozim stavu Dockerfile je dvoufazovy, obsahuje zavislosti WeasyPrintu, Chromium a fonty s ceskou diakritikou. Autentizace zamerne neni implementovana, zpusob predavani neni domluveny. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
135 lines
3.9 KiB
Python
135 lines
3.9 KiB
Python
"""Service entry point.
|
|
|
|
The application runs behind the AppFactory reverse proxy under
|
|
/apps/<app-id>. The prefix is stripped before the request reaches the
|
|
container, so only the OpenAPI document has to know about it. That is what
|
|
root_path does.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from .config import get_settings
|
|
from .deps import Container, set_container
|
|
from .engines.chromium import ChromiumEngine
|
|
from .engines.weasy import WeasyPrintEngine
|
|
from .errors import ConversionError
|
|
from .logging_setup import setup_logging
|
|
from .routers import convert as convert_router
|
|
from .routers import health as health_router
|
|
from .routers import jobs as jobs_router
|
|
from .services.jobs import JobManager
|
|
from .services.pipeline import ConversionPipeline
|
|
from .services.storage import Storage
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DESCRIPTION = """
|
|
Sluzba prevadi HTML dokument na PDF. Prijme adresu dokumentu nebo HTML primo
|
|
v tele requestu a vrati soubor PDF.
|
|
|
|
Pro dokumenty o stovkach az tisicich stranek pouzijte asynchronni endpoint
|
|
POST /jobs. Synchronni POST /convert je urceny pro mensi dokumenty.
|
|
|
|
Dva render enginy:
|
|
|
|
- weasyprint je vychozi, ma spravne strankovani a nizkou pametovou narocnost,
|
|
nespousti JavaScript
|
|
- chromium zvladne i dokumenty dokreslovane JavaScriptem, ale nema pouzitelne
|
|
CSS countery, takze cisla stranek se dopisuji do hotoveho PDF
|
|
"""
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
settings = get_settings()
|
|
setup_logging(settings.log_level)
|
|
|
|
engines: dict = {}
|
|
|
|
weasy = WeasyPrintEngine()
|
|
if await weasy.available():
|
|
engines["weasyprint"] = weasy
|
|
else:
|
|
logger.error("WeasyPrint engine is unavailable, the service will rely on Chromium only")
|
|
|
|
chromium = ChromiumEngine()
|
|
if settings.chromium_enabled:
|
|
engines["chromium"] = chromium
|
|
else:
|
|
logger.info("Chromium engine is disabled by configuration")
|
|
|
|
if not engines:
|
|
logger.error("No render engine is available, conversion requests will fail")
|
|
|
|
storage = Storage(settings.storage_dir)
|
|
pipeline = ConversionPipeline(engines, settings)
|
|
manager = JobManager(pipeline, storage, settings)
|
|
|
|
set_container(
|
|
Container(
|
|
settings=settings,
|
|
storage=storage,
|
|
pipeline=pipeline,
|
|
jobs=manager,
|
|
engines=engines,
|
|
)
|
|
)
|
|
|
|
await manager.start()
|
|
logger.info(
|
|
"Service started",
|
|
extra={"engines": sorted(engines), "root_path": settings.root_path},
|
|
)
|
|
|
|
try:
|
|
yield
|
|
finally:
|
|
await manager.stop()
|
|
for engine in engines.values():
|
|
await engine.shutdown()
|
|
logger.info("Service stopped")
|
|
|
|
|
|
settings = get_settings()
|
|
setup_logging(settings.log_level)
|
|
|
|
app = FastAPI(
|
|
title=settings.app_name,
|
|
version=settings.app_version,
|
|
description=DESCRIPTION,
|
|
root_path=settings.root_path,
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
|
|
@app.exception_handler(ConversionError)
|
|
async def conversion_error_handler(request: Request, exc: ConversionError) -> JSONResponse:
|
|
logger.warning(
|
|
"Request failed",
|
|
extra={"error_code": exc.error_code, "path": request.url.path, "status_code": exc.status_code},
|
|
)
|
|
return JSONResponse(status_code=exc.status_code, content=exc.to_dict())
|
|
|
|
|
|
@app.exception_handler(Exception)
|
|
async def unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
logger.exception("Unhandled error", extra={"path": request.url.path})
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={
|
|
"error_code": "internal_error",
|
|
"message": "Doslo k neocekavane chybe sluzby.",
|
|
},
|
|
)
|
|
|
|
|
|
app.include_router(health_router.router)
|
|
app.include_router(convert_router.router)
|
|
app.include_router(jobs_router.router)
|