feat: add API Key authentication, Dockerfile, and docker-compose

- core/auth.py: API Key auth via header X-API-Key (env PDFTOOLKIT_API_KEY)
  - Modo dev: sem chave configurada → acesso livre
  - Modo produção: exige chave, retorna 401/403
- api.py: endpoints protegidos com Depends(_auth_required)
  - /health e / permanecem públicos
- Dockerfile: multi-stage (base → api → web)
- docker-compose.yml: api + web com healthcheck, volumes e rede
- .env.example: template de variáveis de ambiente
- README.md: seções de autenticação e Docker
This commit is contained in:
CM Consultor
2026-08-09 02:38:19 +00:00
parent 84d4a1eab5
commit f16c53bd4c
8 changed files with 351 additions and 59 deletions

137
api.py
View File

@@ -1,23 +1,28 @@
"""
pdf-toolkit API — FastAPI com documentação OpenAPI automática.
pdf-toolkit API — FastAPI com documentação OpenAPI automática e autenticação.
Endpoints REST para todas as operações do toolkit.
Documentação interativa disponível em /docs (Swagger) e /redoc.
Autenticação via header X-API-Key.
Variável de ambiente: PDFTOOLKIT_API_KEY
Se não definida, a API roda sem autenticação (modo dev).
Uso:
uvicorn api:app --host 0.0.0.0 --port 8555 --reload
"""
import io
import zipfile
from typing import List, Optional
from typing import List
from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Query
from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Depends
from fastapi.responses import Response, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from config import API_TITLE, API_VERSION, API_DESCRIPTION, MAX_UPLOAD_SIZE_BYTES, QUALITY_PRESETS
from core.engine import PDFEngine
from core.auth import verify_api_key, is_auth_enabled
engine = PDFEngine()
@@ -38,6 +43,16 @@ app.add_middleware(
allow_headers=["*"],
)
# ── Dependência de autenticação ─────────────────────────────────────
# Endpoints públicos (health, root) usam auth opcional
# Endpoints de operação usam auth obrigatório (se configurado)
def _auth_required(api_key: str = Depends(verify_api_key)):
"""Dependência que aplica autenticação nos endpoints operacionais."""
return api_key
# ── Helpers ─────────────────────────────────────────────────────────
@@ -69,23 +84,35 @@ def _zip_response(files: List[tuple], zip_name: str = "resultados.zip") -> Strea
)
# ── Endpoint Raiz ───────────────────────────────────────────────────
# ── Endpoints Públicos (sem auth) ───────────────────────────────────
@app.get("/")
def root():
auth_info = {"authentication": "enabled"} if is_auth_enabled() else {"authentication": "disabled (dev mode)"}
return {
"app": API_TITLE,
"version": API_VERSION,
"docs": "/docs",
"redoc": "/redoc",
**auth_info,
}
# ── Inspect ─────────────────────────────────────────────────────────
@app.get("/health")
def health():
"""Health check — sem autenticação."""
return {"status": "ok", "version": API_VERSION, "auth": is_auth_enabled()}
@app.post("/inspect", summary="Inspeciona metadados de um PDF")
# ── Endpoints Protegidos ────────────────────────────────────────────
@app.post(
"/inspect",
summary="Inspeciona metadados de um PDF",
dependencies=[Depends(_auth_required)],
)
def inspect(file: UploadFile = File(...)):
"""
Retorna metadados detalhados do PDF: número de páginas,
@@ -98,10 +125,11 @@ def inspect(file: UploadFile = File(...)):
return info
# ── Compress ────────────────────────────────────────────────────────
@app.post("/compress", summary="Comprime um PDF")
@app.post(
"/compress",
summary="Comprime um PDF",
dependencies=[Depends(_auth_required)],
)
def compress(
file: UploadFile = File(...),
preset: str = Form("ebook"),
@@ -115,14 +143,15 @@ def compress(
raise HTTPException(400, f"Preset inválido. Opções: {', '.join(QUALITY_PRESETS.keys())}")
data = _read_file(file)
result = engine.compress(data, preset=preset)
out_name = (file.filename or "documento.pdf").replace(".pdf", f"_comprimido.pdf")
out_name = (file.filename or "documento.pdf").replace(".pdf", "_comprimido.pdf")
return _pdf_response(result, out_name)
# ── PDF/A → PDF comum ──────────────────────────────────────────────
@app.post("/pdfa-to-regular", summary="Converte PDF/A para PDF comum")
@app.post(
"/pdfa-to-regular",
summary="Converte PDF/A para PDF comum",
dependencies=[Depends(_auth_required)],
)
def pdfa_to_regular(file: UploadFile = File(...)):
"""Remove restrições PDF/A gerando um PDF comum padrão."""
data = _read_file(file)
@@ -130,15 +159,16 @@ def pdfa_to_regular(file: UploadFile = File(...)):
if info.get("is_pdfa"):
result = engine.pdfa_to_regular(data)
else:
result = data # já é PDF comum
result = data
out_name = (file.filename or "documento.pdf").replace(".pdf", "_comum.pdf")
return _pdf_response(result, out_name)
# ── Compress PDF/A ─────────────────────────────────────────────────
@app.post("/compress-pdfa", summary="Comprime mantendo PDF/A")
@app.post(
"/compress-pdfa",
summary="Comprime mantendo PDF/A",
dependencies=[Depends(_auth_required)],
)
def compress_pdfa(
file: UploadFile = File(...),
quality: int = Form(60, ge=10, le=100),
@@ -150,10 +180,11 @@ def compress_pdfa(
return _pdf_response(result, out_name)
# ── Merge ───────────────────────────────────────────────────────────
@app.post("/merge", summary="Junta múltiplos PDFs em um")
@app.post(
"/merge",
summary="Junta múltiplos PDFs em um",
dependencies=[Depends(_auth_required)],
)
async def merge(files: List[UploadFile] = File(...)):
"""Mescla 2 ou mais PDFs em ordem, retornando um único PDF."""
if len(files) < 2:
@@ -166,10 +197,11 @@ async def merge(files: List[UploadFile] = File(...)):
return _pdf_response(result, "merged.pdf")
# ── Split ───────────────────────────────────────────────────────────
@app.post("/split", summary="Extrai intervalos de páginas de um PDF")
@app.post(
"/split",
summary="Extrai intervalos de páginas de um PDF",
dependencies=[Depends(_auth_required)],
)
def split(
file: UploadFile = File(...),
ranges: str = Form(..., description='Ex: "1-3,5,7-9"'),
@@ -204,17 +236,16 @@ def split(
return _zip_response(results, "split.zip")
# ── Extract pages ──────────────────────────────────────────────────
@app.post("/extract-pages", summary="Extrai páginas específicas")
@app.post(
"/extract-pages",
summary="Extrai páginas específicas",
dependencies=[Depends(_auth_required)],
)
def extract_pages(
file: UploadFile = File(...),
pages: str = Form(..., description='Ex: "1,3,5" (1-indexado)'),
):
"""
Extrai páginas específicas de um PDF em ordem.
"""
"""Extrai páginas específicas de um PDF em ordem."""
data = _read_file(file)
reader = __import__("pypdf", fromlist=["PdfReader"]).PdfReader(io.BytesIO(data))
total = len(reader.pages)
@@ -233,10 +264,11 @@ def extract_pages(
return _pdf_response(result, out_name)
# ── Rotate ──────────────────────────────────────────────────────────
@app.post("/rotate", summary="Rotaciona páginas de um PDF")
@app.post(
"/rotate",
summary="Rotaciona páginas de um PDF",
dependencies=[Depends(_auth_required)],
)
def rotate(
file: UploadFile = File(...),
pages: str = Form(..., description='Páginas: "1,3,5" ou "todas"'),
@@ -259,10 +291,11 @@ def rotate(
return _pdf_response(result, out_name)
# ── Extract text ────────────────────────────────────────────────────
@app.post("/extract-text", summary="Extrai texto de um PDF")
@app.post(
"/extract-text",
summary="Extrai texto de um PDF",
dependencies=[Depends(_auth_required)],
)
def extract_text_endpoint(file: UploadFile = File(...)):
"""Extrai todo o texto legível de um PDF e retorna como .txt."""
data = _read_file(file)
@@ -276,10 +309,11 @@ def extract_text_endpoint(file: UploadFile = File(...)):
)
# ── Extract images ──────────────────────────────────────────────────
@app.post("/extract-images", summary="Extrai imagens de um PDF como ZIP")
@app.post(
"/extract-images",
summary="Extrai imagens de um PDF como ZIP",
dependencies=[Depends(_auth_required)],
)
def extract_images_endpoint(file: UploadFile = File(...)):
"""Extrai todas as imagens embutidas no PDF e retorna um ZIP."""
data = _read_file(file)
@@ -289,15 +323,6 @@ def extract_images_endpoint(file: UploadFile = File(...)):
return _zip_response(images, "imagens.zip")
# ── Health check ────────────────────────────────────────────────────
@app.get("/health")
def health():
"""Health check simples."""
return {"status": "ok", "version": API_VERSION}
# ── Entrypoint ──────────────────────────────────────────────────────
if __name__ == "__main__":