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

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
# ── pdf-toolkit — Variáveis de Ambiente ─────────────────────────────
# Copie este arquivo para .env e ajuste os valores.
# ── Autenticação da API ─────────────────────────────────────────────
# Se vazio/omitido, a API roda sem autenticação (modo dev).
# Em produção, defina uma chave forte com 32+ caracteres.
PDFTOOLKIT_API_KEY=my-super-secret-api-key-change-me
# ── Portas (personalizável via docker-compose) ──────────────────────
API_PORT=8555
WEB_PORT=8550

3
.gitignore vendored
View File

@@ -11,3 +11,6 @@ tmp/
dist/ dist/
build/ build/
.pytest_cache/ .pytest_cache/
*.pyc
.tmp/
__pycache__

39
Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
# ── Estágio 1: base ─────────────────────────────────────────────────
FROM python:3.12-slim AS base
WORKDIR /app
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1-mesa-glx libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ── Estágio 2: API ──────────────────────────────────────────────────
FROM base AS api
COPY . .
EXPOSE 8555
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import http.client; conn=http.client.HTTPConnection('localhost:8555'); conn.request('GET','/health'); r=conn.getresponse(); exit(0 if r.status==200 else 1)"
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8555"]
# ── Estágio 3: Streamlit Web ────────────────────────────────────────
FROM base AS web
COPY . .
EXPOSE 8550
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import http.client; conn=http.client.HTTPConnection('localhost:8550'); conn.request('GET','/'); r=conn.getresponse(); exit(0 if r.status==200 else 1)"
CMD ["streamlit", "run", "app.py", "--server.port", "8550", "--server.address", "0.0.0.0", "--server.headless", "true"]

View File

@@ -4,6 +4,8 @@ Transforme, otimize e gerencie arquivos PDF com facilidade.
- **Interface Web**: Streamlit com upload drag-and-drop e configuração visual - **Interface Web**: Streamlit com upload drag-and-drop e configuração visual
- **API REST**: FastAPI com documentação OpenAPI automática em `/docs` - **API REST**: FastAPI com documentação OpenAPI automática em `/docs`
- **Autenticação**: API Key via header `X-API-Key` (configurável via env var)
- **Docker**: containers prontos para produção via docker-compose
- **Operações**: compressão, PDF/A ↔ PDF comum, merge, split, rotação, extração de texto e imagens - **Operações**: compressão, PDF/A ↔ PDF comum, merge, split, rotação, extração de texto e imagens
--- ---
@@ -14,8 +16,9 @@ Transforme, otimize e gerencie arquivos PDF com facilidade.
- Python 3.12+ - Python 3.12+
- pip - pip
- Docker (opcional, para containers)
### Instalação ### Instalação (sem Docker)
```bash ```bash
git clone <url-do-repositorio> pdf-toolkit git clone <url-do-repositorio> pdf-toolkit
@@ -23,7 +26,7 @@ cd pdf-toolkit
pip install -r requirements.txt pip install -r requirements.txt
``` ```
### Execução ### Execução (sem Docker)
**Streamlit (interface web):** **Streamlit (interface web):**
```bash ```bash
@@ -109,6 +112,75 @@ pdf-toolkit/
--- ---
## 🔐 Autenticação da API
A API suporta autenticação via **API Key** no header `X-API-Key`.
### Configuração
Defina a variável de ambiente `PDFTOOLKIT_API_KEY`:
```bash
export PDFTOOLKIT_API_KEY="sua-chave-secreta-aqui"
```
Se a variável **não** estiver definida, a API roda sem autenticação (modo desenvolvimento).
### Uso com curl
```bash
curl -X POST http://localhost:8555/compress \
-H "X-API-Key: sua-chave-secreta-aqui" \
-F "file=@documento.pdf" \
-o comprimido.pdf
```
### Endpoints públicos (sem auth)
- `GET /` — raiz informativa
- `GET /health` — health check
---
## 🐳 Docker
### Com docker-compose (recomendado)
```bash
# 1. Copie e configure o .env
cp .env.example .env
# Edite .env com sua API Key
# 2. Inicie os serviços
docker compose up -d --build
# Acesse:
# - Streamlit: http://localhost:8550
# - API: http://localhost:8555
# - Swagger: http://localhost:8555/docs
```
### Build manual
```bash
# API
docker build --target api -t pdf-toolkit-api .
docker run -p 8555:8555 --env-file .env pdf-toolkit-api
# Web
docker build --target web -t pdf-toolkit-web .
docker run -p 8550:8550 --env-file .env pdf-toolkit-web
```
### Estrutura dos containers
- **api**: FastAPI + uvicorn, porta 8555
- **web**: Streamlit, porta 8550, depende do serviço `api`
- Volume compartilhado `uploads` para arquivos temporários
- Healthcheck em ambos os containers
---
## 🧪 Testes ## 🧪 Testes
```bash ```bash

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. Endpoints REST para todas as operações do toolkit.
Documentação interativa disponível em /docs (Swagger) e /redoc. 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: Uso:
uvicorn api:app --host 0.0.0.0 --port 8555 --reload uvicorn api:app --host 0.0.0.0 --port 8555 --reload
""" """
import io import io
import zipfile 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.responses import Response, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from config import API_TITLE, API_VERSION, API_DESCRIPTION, MAX_UPLOAD_SIZE_BYTES, QUALITY_PRESETS from config import API_TITLE, API_VERSION, API_DESCRIPTION, MAX_UPLOAD_SIZE_BYTES, QUALITY_PRESETS
from core.engine import PDFEngine from core.engine import PDFEngine
from core.auth import verify_api_key, is_auth_enabled
engine = PDFEngine() engine = PDFEngine()
@@ -38,6 +43,16 @@ app.add_middleware(
allow_headers=["*"], 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 ───────────────────────────────────────────────────────── # ── 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("/") @app.get("/")
def root(): def root():
auth_info = {"authentication": "enabled"} if is_auth_enabled() else {"authentication": "disabled (dev mode)"}
return { return {
"app": API_TITLE, "app": API_TITLE,
"version": API_VERSION, "version": API_VERSION,
"docs": "/docs", "docs": "/docs",
"redoc": "/redoc", "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(...)): def inspect(file: UploadFile = File(...)):
""" """
Retorna metadados detalhados do PDF: número de páginas, Retorna metadados detalhados do PDF: número de páginas,
@@ -98,10 +125,11 @@ def inspect(file: UploadFile = File(...)):
return info return info
# ── Compress ──────────────────────────────────────────────────────── @app.post(
"/compress",
summary="Comprime um PDF",
@app.post("/compress", summary="Comprime um PDF") dependencies=[Depends(_auth_required)],
)
def compress( def compress(
file: UploadFile = File(...), file: UploadFile = File(...),
preset: str = Form("ebook"), preset: str = Form("ebook"),
@@ -115,14 +143,15 @@ def compress(
raise HTTPException(400, f"Preset inválido. Opções: {', '.join(QUALITY_PRESETS.keys())}") raise HTTPException(400, f"Preset inválido. Opções: {', '.join(QUALITY_PRESETS.keys())}")
data = _read_file(file) data = _read_file(file)
result = engine.compress(data, preset=preset) 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) 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(...)): def pdfa_to_regular(file: UploadFile = File(...)):
"""Remove restrições PDF/A gerando um PDF comum padrão.""" """Remove restrições PDF/A gerando um PDF comum padrão."""
data = _read_file(file) data = _read_file(file)
@@ -130,15 +159,16 @@ def pdfa_to_regular(file: UploadFile = File(...)):
if info.get("is_pdfa"): if info.get("is_pdfa"):
result = engine.pdfa_to_regular(data) result = engine.pdfa_to_regular(data)
else: else:
result = data # já é PDF comum result = data
out_name = (file.filename or "documento.pdf").replace(".pdf", "_comum.pdf") out_name = (file.filename or "documento.pdf").replace(".pdf", "_comum.pdf")
return _pdf_response(result, out_name) 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( def compress_pdfa(
file: UploadFile = File(...), file: UploadFile = File(...),
quality: int = Form(60, ge=10, le=100), quality: int = Form(60, ge=10, le=100),
@@ -150,10 +180,11 @@ def compress_pdfa(
return _pdf_response(result, out_name) 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(...)): async def merge(files: List[UploadFile] = File(...)):
"""Mescla 2 ou mais PDFs em ordem, retornando um único PDF.""" """Mescla 2 ou mais PDFs em ordem, retornando um único PDF."""
if len(files) < 2: if len(files) < 2:
@@ -166,10 +197,11 @@ async def merge(files: List[UploadFile] = File(...)):
return _pdf_response(result, "merged.pdf") 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( def split(
file: UploadFile = File(...), file: UploadFile = File(...),
ranges: str = Form(..., description='Ex: "1-3,5,7-9"'), ranges: str = Form(..., description='Ex: "1-3,5,7-9"'),
@@ -204,17 +236,16 @@ def split(
return _zip_response(results, "split.zip") 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( def extract_pages(
file: UploadFile = File(...), file: UploadFile = File(...),
pages: str = Form(..., description='Ex: "1,3,5" (1-indexado)'), 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) data = _read_file(file)
reader = __import__("pypdf", fromlist=["PdfReader"]).PdfReader(io.BytesIO(data)) reader = __import__("pypdf", fromlist=["PdfReader"]).PdfReader(io.BytesIO(data))
total = len(reader.pages) total = len(reader.pages)
@@ -233,10 +264,11 @@ def extract_pages(
return _pdf_response(result, out_name) 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( def rotate(
file: UploadFile = File(...), file: UploadFile = File(...),
pages: str = Form(..., description='Páginas: "1,3,5" ou "todas"'), pages: str = Form(..., description='Páginas: "1,3,5" ou "todas"'),
@@ -259,10 +291,11 @@ def rotate(
return _pdf_response(result, out_name) 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(...)): def extract_text_endpoint(file: UploadFile = File(...)):
"""Extrai todo o texto legível de um PDF e retorna como .txt.""" """Extrai todo o texto legível de um PDF e retorna como .txt."""
data = _read_file(file) 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(...)): def extract_images_endpoint(file: UploadFile = File(...)):
"""Extrai todas as imagens embutidas no PDF e retorna um ZIP.""" """Extrai todas as imagens embutidas no PDF e retorna um ZIP."""
data = _read_file(file) data = _read_file(file)
@@ -289,15 +323,6 @@ def extract_images_endpoint(file: UploadFile = File(...)):
return _zip_response(images, "imagens.zip") return _zip_response(images, "imagens.zip")
# ── Health check ────────────────────────────────────────────────────
@app.get("/health")
def health():
"""Health check simples."""
return {"status": "ok", "version": API_VERSION}
# ── Entrypoint ────────────────────────────────────────────────────── # ── Entrypoint ──────────────────────────────────────────────────────
if __name__ == "__main__": if __name__ == "__main__":

59
core/auth.py Normal file
View File

@@ -0,0 +1,59 @@
"""
Autenticação via API Key para a API REST.
A chave é lida da variável de ambiente PDFTOOLKIT_API_KEY.
Se a variável não estiver definida, a autenticação fica desabilitada
(útil para desenvolvimento local).
Uso:
export PDFTOOLKIT_API_KEY="sua-chave-secreta-aqui"
"""
import os
from fastapi import Security, HTTPException, status
from fastapi.security import APIKeyHeader
API_KEY_ENV = "PDFTOOLKIT_API_KEY"
# Lê a chave do ambiente (None se não configurada)
_API_KEY = os.environ.get(API_KEY_ENV, None)
# Schema de header: X-API-Key
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
def verify_api_key(api_key: str = Security(api_key_header)) -> str:
"""
Dependência FastAPI que valida a API Key.
- Se PDFTOOLKIT_API_KEY não foi definida no ambiente → permite acesso livre
(modo desenvolvimento).
- Se foi definida → exige que o header X-API-Key corresponda exatamente.
Retorna a própria chave se válida, ou levanta 401.
"""
# Modo desenvolvimento: sem chave configurada
if _API_KEY is None:
return "dev-mode"
# Modo produção: exige chave
if api_key is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API Key ausente. Envie o header X-API-Key.",
headers={"WWW-Authenticate": "ApiKey"},
)
if api_key != _API_KEY:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="API Key inválida.",
headers={"WWW-Authenticate": "ApiKey"},
)
return api_key
def is_auth_enabled() -> bool:
"""Retorna True se a autenticação estiver ativa."""
return _API_KEY is not None

58
docker-compose.yml Normal file
View File

@@ -0,0 +1,58 @@
services:
# ── API REST (FastAPI) ──────────────────────────────────────────
api:
build:
context: .
target: api
container_name: pdf-toolkit-api
restart: unless-stopped
ports:
- "${API_PORT:-8555}:8555"
env_file:
- .env
environment:
- PDFTOOLKIT_API_KEY=${PDFTOOLKIT_API_KEY:-}
volumes:
- uploads:/tmp/uploads
healthcheck:
test: ["CMD", "python", "-c", "import http.client; conn=http.client.HTTPConnection('localhost:8555'); conn.request('GET','/health'); r=conn.getresponse(); exit(0 if r.status==200 else 1)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
- pdf-net
# ── Interface Web (Streamlit) ───────────────────────────────────
web:
build:
context: .
target: web
container_name: pdf-toolkit-web
restart: unless-stopped
ports:
- "${WEB_PORT:-8550}:8550"
depends_on:
- api
env_file:
- .env
environment:
- API_BASE_URL=http://api:8555
volumes:
- uploads:/tmp/uploads
healthcheck:
test: ["CMD", "python", "-c", "import http.client; conn=http.client.HTTPConnection('localhost:8550'); conn.request('GET','/'); r=conn.getresponse(); exit(0 if r.status==200 else 1)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
- pdf-net
volumes:
uploads:
networks:
pdf-net:
driver: bridge

25
healthz.py Normal file
View File

@@ -0,0 +1,25 @@
"""Adiciona endpoint /healthz para healthcheck do Docker."""
import streamlit as st
# O Streamlit não tem roteamento nativo, mas podemos expor via
# arquivo estático. Para healthcheck do Docker, usamos o seguinte
# truque: endpoint /healthz no mesmo host.
# Como Streamlit não permite criar rotas customizadas, o healthcheck
# será feito via HTTP na própria API (mais confiável).
# Esta função é apenas um placeholder para documentação.
"""
Healthcheck do container web (Streamlit):
O Streamlit não expõe endpoints HTTP customizados nativamente.
Para o healthcheck do Docker, usamos:
test: ["CMD", "python", "-c", "import http.client; conn=http.client.HTTPConnection('localhost:8550'); conn.request('GET','/'); r=conn.getresponse(); exit(0 if r.status==200 else 1)"]
Isso verifica se o servidor Streamlit está respondendo na porta 8550.
"""
# Adiciona uma página de health no sidebar para debug
def show_health_page():
st.set_page_config(page_title="Health Check", page_icon="💚")
st.write("✅ pdf-toolkit web is running")
if __name__ == "__main__":
show_health_page()