PDFComposer¶
Servicio que genera los PDF de GDI a partir de plantillas HTML: documentos, carátulas, pases, notas, informes de legajo y la hoja informativa de un PDF importado. No guarda nada: recibe datos y devuelve un PDF.
| Propiedad | Valor |
|---|---|
| Puerto | :8002 (desarrollo) |
| Framework | FastAPI |
| Motor PDF | WeasyPrint (en el mismo contenedor) |
| Plantillas | Jinja2, una por tipo de documento (app/templates/) |
| Firmas y certificación | pyHanko (revisiones incrementales y detección de DocMDP) |
| Autenticación | Header X-API-Key (todos los endpoints menos / y /health) |
| Cliente | Solo el Backend |
La versión vigente sale de app/version.py. La lista de endpoints sale de main.py.
Endpoints¶
Todos reciben multipart/form-data y devuelven el PDF en el body (application/pdf), con nombre
UUID. Los PDF finales (no las vistas previas) traen además el header X-PDF-SHA256.
| Endpoint | Qué genera |
|---|---|
POST /preview-pdf/ |
Vista previa de un documento, con marca de agua "PREVISUALIZACIÓN" |
POST /generate-pdf/ |
Documento final, sin marca de agua. Acepta un PDF anexo y adjuntos embebidos |
POST /create-case/ |
Carátula de expediente |
POST /move/ |
Pase de expediente |
POST /import/ |
PDF externo + hoja informativa de GDI al final |
POST /embed-files/ |
Agrega adjuntos embebidos a un PDF importado |
POST /note-preview/ |
Vista previa de una nota, con marca de agua |
POST /note/ |
Nota final |
POST /ifrlm/ |
Informe de legajo |
GET /health |
Estado básico, sin API Key |
GET /health/details |
Estado con prueba de WeasyPrint |
GET /cache/stats |
Estadísticas de la caché de logos (por proceso) |
Topes de tamaño¶
| Qué | Tope | Error |
|---|---|---|
| Pedido entero (HTML + PDF anexo + adjuntos) | 30 MB (MAX_REQUEST_BODY_SIZE_MB) |
413 antes de leer |
| Un campo de texto del formulario (por ejemplo, el HTML) | 25 MB (MAX_FORM_FIELD_SIZE_MB) |
413 |
PDF a importar (/import/) |
25 MB | 413 |
PDF anexo de /generate-pdf/ |
25 MB | 413 |
| Un adjunto embebido | 20 MB | 413 |
| Suma de adjuntos embebidos | 20 MB | 413 |
PDF importado + sus adjuntos (/embed-files/) |
25 MB | 413 |
| Logo | 2 MB | El PDF sale sin logo |
El tope de 30 MB del pedido es el mismo que usa el Backend al llamar a PDFComposer. Un middleware
lo controla antes de leer el body: si Content-Length lo pasa, responde 413 enseguida. Si el
cliente no declara el tamaño, el middleware corta apenas lo leído pasa el tope.
Por qué 25 MB para un campo de texto
Starlette corta por defecto cada campo del formulario en 1 MB. Un documento con una imagen
de ~1 MB (que viaja en base64 dentro del HTML) no entraba. PDFComposer lee el formulario
con max_part_size de 25 MB.
/import/: PDF externo¶
/import/ recibe un PDF de afuera, cuenta sus páginas y le agrega al final la hoja informativa
de GDI.
Parámetros (Form Data): pdf_file (obligatorio), NameAcronyType, document_type,
reference (obligatorios), urlLogo y frase_anual (opcionales).
Cómo agrega la hoja¶
| El PDF trae | Qué hace PDFComposer |
|---|---|
| Sin firmas | Reescribe el PDF con la hoja al final (PyMuPDF) |
| Firmas comunes | Agrega la hoja como revisión incremental (pyHanko): el original queda igual, byte a byte, y las firmas siguen válidas |
| Firma de certificación (DocMDP) íntegra | No lo toca: devuelve los bytes de entrada, sin hoja |
| Firmas y la revisión incremental falla | Rechaza con 422 IMPORT_INCREMENTAL_FAILED. Nunca devuelve un PDF reescrito con las firmas rotas |
La revisión incremental funciona también con árboles de páginas anidados (/Pages dentro de
/Pages, como los que arman Acrobat o los generadores de oficina). PDFComposer agrega la página
con su propia función (_append_page en app/services/import_merge.py) y actualiza el /Count
de cada nivel. La función de pyHanko fallaba con esos árboles.
Header X-GDI-Certified¶
Toda respuesta 200 de /import/ trae el header X-GDI-Certified:
| Valor | Qué significa |
|---|---|
false |
PDFComposer agregó la hoja informativa |
true |
El PDF trae una firma de certificación íntegra y volvió sin tocar |
Con true, el Backend guarda el PDF tal cual y lo numera sin pasar por Notary (Notary lo
rechazaría con 422 PDF_CERTIFIED). La marca queda en la base, en
document_draft.certified_pdf_sha256 (ver Schema Municipio).
Validación estricta de la certificación¶
Un PDF que parece certificado no alcanza. Una firma copiada de otro PDF, o un contenido
cambiado con una revisión posterior, tendría la misma estructura. Por eso, antes de devolverlo
sin tocar, PDFComposer valida cada firma de certificación con pyHanko
(validate_certification_integrity en app/services/certification.py). Tienen que cumplirse
las 4 condiciones:
intact: el hash del/ByteRangecoincide con lo firmado.valid: la firma criptográfica es válida.- La firma cubre el archivo entero o la revisión entera (
ENTIRE_FILEoENTIRE_REVISION). docmdp_ok: las revisiones posteriores respetan el nivelP.
La validación no revisa quién firmó (no usa raíces de confianza ni red). Revisa que la firma esté sana.
Detalles de la validación:
- Igual que Acrobat. La política de cambios es la estándar de pyHanko con un solo cambio: acepta una firma de aprobación visible nueva después de certificar (P=2 o P=3). Es el caso normal de un convenio certificado que otro firma después. Por la misma razón pasa la "Sneaky Signature", como en Acrobat. Siguen rechazados: firma copiada, contenido cambiado, PDF reescrito o comprimido y "Evil Annotation".
- SHA-1 permitido. pyHanko rechaza SHA-1 por defecto, pero Acrobat lo sigue validando. PDFComposer acepta certificaciones firmadas con SHA-1. MD2 y MD5 siguen rechazados.
- Lectura estricta. La validación lee siempre en modo estricto, como Notary.
Si falla cualquier condición, o la validación tira una excepción, la respuesta es
422 IMPORT_CERTIFICATION_BROKEN.
Errores de /import/¶
| Código | error_code |
Cuándo |
|---|---|---|
| 400 | - | El archivo no empieza con %PDF- |
| 413 | - | El PDF pasa de 25 MB |
| 422 | IMPORT_INCREMENTAL_FAILED |
El PDF tiene firmas y no se pudo agregar la hoja sin invalidarlas |
| 422 | IMPORT_CERTIFICATION_BROKEN |
Parece certificado, pero la firma de certificación no está íntegra |
| 422 | IMPORT_CERTIFICATION_INCONSISTENT |
/Perms/DocMDP que ninguna firma respalda, con otras firmas |
| 422 | IMPORT_PDF_ENCRYPTED_PASSWORD |
El PDF pide contraseña para abrirse |
| 422 | IMPORT_SIGNATURES_UNREADABLE |
Hay firmas que no se pueden leer |
Dos casos más:
- Firmas ilegibles en modo estricto.
/import/reintenta en modo permisivo, solo para detectar. Si encuentra certificación, sigue como certificado (y la validación estricta decide). Si encuentra alguna firma, rechaza conIMPORT_SIGNATURES_UNREADABLE. Si no encuentra nada, reescribe como un PDF sin firmas. /Perms/DocMDPsin ninguna firma. Reescribe el PDF y borra/Perms. Si no lo borrará, Notary no podría firmarlo nunca.
/embed-files/: adjuntos en un importado¶
/embed-files/ agrega adjuntos embebidos (/EmbeddedFiles) a un PDF importado, que ya trae su
hoja informativa. No toca el contenido de las páginas.
Parámetros (Form Data):
| Parámetro | Obligatorio | Descripción |
|---|---|---|
pdf_file |
Sí | El PDF importado |
embedded_files |
Sí (al menos 1) | Archivos a embeber. Campo repetido, igual que en /generate-pdf/ |
Reglas:
- Extensiones permitidas:
pdf,xls,xlsx,doc,docx,odt,ods,csv,txt,png,jpg,jpeg,dxf. El contenido tiene que coincidir con la extensión (magic bytes). - Conserva los adjuntos que el PDF ya traía (por ejemplo, el XML de una factura PDF/A-3).
Un nombre nuevo que choca con uno existente se renombra con
_2,_3… - Si el PDF trae firmas comunes, los adjuntos van como revisión incremental y las firmas siguen válidas.
| Código | error_code |
Cuándo |
|---|---|---|
| 400 | - | PDF inválido, sin adjuntos o con un adjunto que no pasa la validación de contenido |
| 413 | - | Un adjunto > 20 MB, adjuntos > 20 MB o PDF + adjuntos > 25 MB |
| 422 | EMBED_CERTIFIED_PDF |
El PDF trae firma de certificación: agregar archivos la rompe en cualquier nivel P |
| 422 | EMBED_CERTIFICATION_INCONSISTENT |
/Perms/DocMDP que ninguna firma respalda |
| 422 | EMBED_PDF_ENCRYPTED_PASSWORD |
El PDF pide contraseña para abrirse |
| 422 | EMBED_SIGNATURES_UNREADABLE |
Hay firmas que no se pueden leer |
| 422 | EMBED_INCREMENTAL_FAILED |
El PDF tiene firmas y no se pudieron agregar los adjuntos sin invalidarlas |
Acá no hay reintento permisivo: el PDF que llega ya pasó por /import/.
La regla de certificación¶
PDFComposer y Notary usan la
misma regla para decidir si un PDF está certificado. Está escrita dos veces, una en cada
servicio (app/services/certification.py y app/certification.py). Si se cambia una copia, se
cambia la otra.
Resumen: la función detect_certification devuelve uno de 7 estados (none, signed,
certified, orphan_perms, inconsistent, unreadable, encrypted_password).
PDF encriptado¶
Un PDF encriptado con contraseña de dueño (sin contraseña de apertura) se abre con
decrypt(""). Si trae una firma común, la revisión incremental vuelve a encriptar con
encrypt(""). Así el documento sale como llegó. Un PDF que pide contraseña para abrirse recibe
422 IMPORT_PDF_ENCRYPTED_PASSWORD o 422 EMBED_PDF_ENCRYPTED_PASSWORD.
Logo¶
El parámetro urlLogo acepta dos formas:
| Forma | Qué hace |
|---|---|
https://... o http://... |
Descarga el logo, con control SSRF y caché por proceso |
data:image/png;base64,... |
Usa el logo tal cual, sin red |
El data: URI sirve para el logo por defecto de un municipio sin logo propio: anda sin bucket
ni internet. Las dos formas siguen las mismas reglas:
- tipos permitidos:
image/png,image/jpeg,image/jpg,image/svg+xml,image/webp,image/gif; - hasta 2 MB;
- en un
data:URI, el base64 tiene que ser válido.
Si el logo no cumple, el PDF sale sin logo (queda un warning en el log).
Protección SSRF¶
Antes de descargar un logo o una imagen, validate_url_safety (en
app/services/pdf_service.py) revisa la URL:
- Solo acepta
httpyhttps. - Rechaza hostnames bloqueados y los que terminan en
.internal. - Resuelve el hostname y revisa cada IP. Rechaza loopback, link-local y
0.0.0.0siempre. Rechaza IP privadas o reservadas, salvo que el host esté enSSRF_ALLOWED_PRIVATE_HOSTS. - Salvo para los hosts de confianza, fija la IP resuelta para la descarga. Así un cambio de DNS entre el control y la descarga no lleva a otra IP.
Una IPv6 IPv4-mapped (::ffff:a.b.c.d) se revisa dos veces: como IPv6 y como la IPv4 que
lleva adentro. Ejemplo: ::ffff:127.0.0.1 es loopback, aunque is_loopback de la IPv6 dé
False. Sin este doble control, esa dirección pasaba.
Variables de entorno¶
| Variable | Default | Descripción |
|---|---|---|
API_KEY |
(requerida) | API Key de autenticación |
MAX_REQUEST_BODY_SIZE_MB |
30 |
Tope del pedido entero |
MAX_FORM_FIELD_SIZE_MB |
25 |
Tope de un campo de texto del formulario |
PDF_MAX_CONCURRENCY |
2 |
PDF que se generan a la vez por proceso |
PDF_MAX_QUEUE |
30 |
Pedidos en curso o en espera por proceso. Al llegar al tope, responde 503 |
SSRF_ALLOWED_PRIVATE_HOSTS |
(vacío) | Nombres de host que pueden resolver a IP privada (solo nombres, no IP) |