Projeto com pdf e ajuste de emojs

This commit is contained in:
2026-10-16 23:15:31 -03:00
parent 2aef483e37
commit fd86667c4e
36 changed files with 4353 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
__pycache__
*.pyc
.venv
*.md

22
pdf-service/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
STORAGE_DIR=/storage
WORKDIR /app
# Mesmo uid/gid (1001) do usuário do container do Next: os dois gravam no volume compartilhado
RUN groupadd -g 1001 pdf && useradd -u 1001 -g pdf -M -s /usr/sbin/nologin pdf \
&& mkdir -p /storage && chown pdf:pdf /storage
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
USER pdf
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]

View File

135
pdf-service/app/main.py Normal file
View File

@@ -0,0 +1,135 @@
"""Serviço interno de PDFs dos convites.
Recebe do Next.js apenas identificadores (evento, convite, versão do modelo) e o token do convite.
Os caminhos são montados aqui, dentro de STORAGE_DIR, com a mesma estrutura de src/lib/pdf/storage.ts:
<STORAGE_DIR>/events/<eventId>/template/modelo-v<versão>.pdf
<STORAGE_DIR>/events/<eventId>/generated/<invitationId>.pdf
O link novo é <INVITE_LINK_BASE_URL>/r/<token>; sem INVITE_LINK_BASE_URL, vale o APP_URL.
A personalização troca somente a URI dos links que contêm INVITE_LINK_PATTERN (ex.: "com.br/r/")
pelo link do convite; layout, imagens, textos, fontes e demais links ficam intactos.
"""
import logging
import os
import tempfile
from pathlib import Path
import fitz # PyMuPDF
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("pdf-service")
STORAGE_DIR = Path(os.environ.get("STORAGE_DIR", "/storage")).resolve()
# Sem INVITE_LINK_BASE_URL, usa o endereço público do app (APP_URL)
LINK_BASE_URL = (
os.environ.get("INVITE_LINK_BASE_URL", "").strip() or os.environ.get("APP_URL", "").strip()
).rstrip("/")
LINK_PATTERN = os.environ.get("INVITE_LINK_PATTERN", "com.br/r/").strip()
UUID_RE = r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
TOKEN_RE = r"^[A-Za-z0-9_-]{1,64}$"
app = FastAPI(title="pdf-service", docs_url=None, redoc_url=None, openapi_url=None)
class TemplateRequest(BaseModel):
event_id: str = Field(pattern=UUID_RE)
template_version: int = Field(ge=1)
class GenerateRequest(TemplateRequest):
invitation_id: str = Field(pattern=UUID_RE)
token: str = Field(pattern=TOKEN_RE)
def _inside_storage(path: Path) -> Path:
# Defesa extra contra path traversal: os ids já são validados como UUID
resolved = path.resolve()
if STORAGE_DIR not in resolved.parents:
raise HTTPException(status_code=400, detail="Caminho inválido.")
return resolved
def _template_path(event_id: str, version: int) -> Path:
return _inside_storage(STORAGE_DIR / "events" / event_id.lower() / "template" / f"modelo-v{version}.pdf")
def _generated_path(event_id: str, invitation_id: str) -> Path:
return _inside_storage(STORAGE_DIR / "events" / event_id.lower() / "generated" / f"{invitation_id.lower()}.pdf")
def _is_invite_link(link: dict) -> bool:
return LINK_PATTERN in (link.get("uri") or "")
def _open_template(path: Path) -> fitz.Document:
if not path.is_file():
raise HTTPException(status_code=404, detail="PDF modelo não encontrado no armazenamento.")
try:
return fitz.open(path)
except Exception:
log.exception("Falha ao abrir o modelo %s", path)
raise HTTPException(status_code=422, detail="Não foi possível ler o PDF modelo.")
@app.get("/health")
def health() -> dict:
return {"status": "ok", "storage": STORAGE_DIR.is_dir(), "link_base_url": bool(LINK_BASE_URL)}
@app.post("/templates/inspect")
def inspect_template(req: TemplateRequest) -> dict:
"""Conta os links de convite do modelo (0 = modelo inválido para personalização)."""
doc = _open_template(_template_path(req.event_id, req.template_version))
try:
links = sum(1 for page in doc for link in page.get_links() if _is_invite_link(link))
finally:
doc.close()
log.info("Modelo evento=%s versão=%s: %s link(s) de convite", req.event_id, req.template_version, links)
return {"links": links}
@app.post("/invitations/generate")
def generate_invitation(req: GenerateRequest) -> dict:
if not LINK_BASE_URL:
raise HTTPException(status_code=500, detail="Endereço do link não configurado: defina APP_URL (ou INVITE_LINK_BASE_URL) no .env.")
new_link = f"{LINK_BASE_URL}/r/{req.token}"
output = _generated_path(req.event_id, req.invitation_id)
doc = _open_template(_template_path(req.event_id, req.template_version))
tmp_name = None
try:
replaced = 0
for page in doc:
for link in page.get_links():
if _is_invite_link(link):
link["uri"] = new_link
page.update_link(link)
replaced += 1
if replaced == 0:
raise HTTPException(status_code=422, detail="Link do convite não encontrado no PDF modelo.")
# Grava em arquivo temporário e troca no fim: o PDF anterior só é substituído se tudo der certo
output.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(dir=output.parent, suffix=".tmp")
os.close(fd)
doc.save(tmp_name)
os.replace(tmp_name, output)
tmp_name = None
except HTTPException:
raise
except Exception:
log.exception("Falha ao gerar o PDF do convite %s", req.invitation_id)
raise HTTPException(status_code=500, detail="Falha ao gerar o PDF do convite.")
finally:
doc.close()
if tmp_name and os.path.exists(tmp_name):
os.remove(tmp_name)
log.info("PDF gerado convite=%s evento=%s links=%s", req.invitation_id, req.event_id, replaced)
return {"links": replaced}

View File

@@ -0,0 +1,3 @@
fastapi>=0.115,<1.0
uvicorn>=0.32,<1.0
pymupdf>=1.25,<2.0