Projeto com pdf e ajuste de emojs
This commit is contained in:
4
pdf-service/.dockerignore
Normal file
4
pdf-service/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.venv
|
||||
*.md
|
||||
22
pdf-service/Dockerfile
Normal file
22
pdf-service/Dockerfile
Normal 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"]
|
||||
0
pdf-service/app/__init__.py
Normal file
0
pdf-service/app/__init__.py
Normal file
135
pdf-service/app/main.py
Normal file
135
pdf-service/app/main.py
Normal 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}
|
||||
3
pdf-service/requirements.txt
Normal file
3
pdf-service/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.115,<1.0
|
||||
uvicorn>=0.32,<1.0
|
||||
pymupdf>=1.25,<2.0
|
||||
Reference in New Issue
Block a user