diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..638b5dc --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index fe0fe8f..421aa51 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,7 @@ tmp/ *.egg-info/ dist/ build/ -.pytest_cache/ \ No newline at end of file +.pytest_cache/ +*.pyc +.tmp/ +__pycache__ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bb04b13 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md index 00ae32e..d0bb284 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Transforme, otimize e gerencie arquivos PDF com facilidade. - **Interface Web**: Streamlit com upload drag-and-drop e configuração visual - **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 --- @@ -14,8 +16,9 @@ Transforme, otimize e gerencie arquivos PDF com facilidade. - Python 3.12+ - pip +- Docker (opcional, para containers) -### Instalação +### Instalação (sem Docker) ```bash git clone pdf-toolkit @@ -23,7 +26,7 @@ cd pdf-toolkit pip install -r requirements.txt ``` -### Execução +### Execução (sem Docker) **Streamlit (interface web):** ```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 ```bash diff --git a/api.py b/api.py index 7c3b1ce..78d593f 100644 --- a/api.py +++ b/api.py @@ -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__": diff --git a/core/auth.py b/core/auth.py new file mode 100644 index 0000000..800de0e --- /dev/null +++ b/core/auth.py @@ -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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1d15cc9 --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/healthz.py b/healthz.py new file mode 100644 index 0000000..a4c6c4f --- /dev/null +++ b/healthz.py @@ -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() \ No newline at end of file