mirror of https://github.com/interlegis/sapl.git
7 changed files with 1461 additions and 895 deletions
@ -0,0 +1,345 @@ |
|||||
|
""" |
||||
|
Cliente para o pyHanko Sign Service (microserviço externo de assinatura digital). |
||||
|
|
||||
|
Contrato real da API (descoberto via /openapi.json): |
||||
|
POST /sign — multipart/form-data |
||||
|
pdf : arquivo PDF (binário) |
||||
|
pfx : arquivo .pfx/.p12 (binário) |
||||
|
pfx_password : senha do PFX |
||||
|
reason : motivo (opcional) |
||||
|
location : local (opcional) |
||||
|
signature_page : página 1-based (opcional) |
||||
|
signature_left/bottom/width/height : posição em pontos PDF (opcional) |
||||
|
|
||||
|
Resposta 200: Content-Type: application/pdf → bytes do PDF assinado |
||||
|
Resposta 4xx/5xx: JSON com campo "detail" |
||||
|
|
||||
|
Outros endpoints: |
||||
|
POST /validate-pfx – valida certificado PFX |
||||
|
POST /validate – valida assinaturas de um PDF |
||||
|
POST /sign/batch – assina múltiplos PDFs em lote |
||||
|
GET / – health check |
||||
|
""" |
||||
|
|
||||
|
import logging |
||||
|
|
||||
|
import requests |
||||
|
from django.conf import settings |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
|
||||
|
class AssinaturaAPIError(Exception): |
||||
|
"""Erro retornado pelo microserviço de assinatura.""" |
||||
|
def __init__(self, message, status_code=None): |
||||
|
super().__init__(message) |
||||
|
self.status_code = status_code |
||||
|
|
||||
|
|
||||
|
def _api_configurada(): |
||||
|
"""Retorna True se a API externa de assinatura está configurada.""" |
||||
|
return bool(getattr(settings, 'ASSINATURA_API_URL', '').strip()) |
||||
|
|
||||
|
|
||||
|
def _montar_headers(): |
||||
|
"""Authorization só é adicionado se ASSINATURA_API_KEY estiver preenchido.""" |
||||
|
headers = {} |
||||
|
api_key = getattr(settings, 'ASSINATURA_API_KEY', '').strip() |
||||
|
if api_key: |
||||
|
headers['Authorization'] = f'Bearer {api_key}' |
||||
|
return headers |
||||
|
|
||||
|
|
||||
|
def _url(endpoint): |
||||
|
base = settings.ASSINATURA_API_URL.rstrip('/') |
||||
|
endpoint = endpoint.lstrip('/') |
||||
|
return f'{base}/{endpoint}' |
||||
|
|
||||
|
|
||||
|
def assinar_pdf_via_api(pdf_bytes, *, certificado_bytes, senha, |
||||
|
reason=None, location=None, |
||||
|
signature_page=None, |
||||
|
signature_left=None, signature_bottom=None, |
||||
|
signature_width=None, signature_height=None): |
||||
|
""" |
||||
|
Envia o PDF ao pyHanko Sign Service e retorna o PDF assinado como bytes. |
||||
|
|
||||
|
Parâmetros |
||||
|
---------- |
||||
|
pdf_bytes : bytes — PDF a assinar |
||||
|
certificado_bytes : bytes — arquivo .pfx/.p12 |
||||
|
senha : str — senha do certificado |
||||
|
reason : str — motivo (exibido na assinatura visual) |
||||
|
location : str — local (exibido na assinatura visual) |
||||
|
signature_page : int — página 1-based (None = última) |
||||
|
signature_left/bottom/width/height : float — coordenadas em pontos PDF |
||||
|
|
||||
|
Retorna: bytes do PDF assinado. |
||||
|
Lança: AssinaturaAPIError em caso de erro. |
||||
|
""" |
||||
|
if not _api_configurada(): |
||||
|
raise AssinaturaAPIError( |
||||
|
'Microserviço de assinatura não configurado (ASSINATURA_API_URL vazio).' |
||||
|
) |
||||
|
|
||||
|
files = { |
||||
|
'pdf': ('documento.pdf', pdf_bytes, 'application/pdf'), |
||||
|
'pfx': ('certificado.pfx', certificado_bytes, 'application/octet-stream'), |
||||
|
} |
||||
|
data = {'pfx_password': senha} |
||||
|
|
||||
|
if reason: |
||||
|
data['reason'] = reason |
||||
|
if location: |
||||
|
data['location'] = location |
||||
|
if signature_page is not None: |
||||
|
data['signature_page'] = str(signature_page) |
||||
|
if signature_left is not None: |
||||
|
data['signature_left'] = str(signature_left) |
||||
|
if signature_bottom is not None: |
||||
|
data['signature_bottom'] = str(signature_bottom) |
||||
|
if signature_width is not None: |
||||
|
data['signature_width'] = str(signature_width) |
||||
|
if signature_height is not None: |
||||
|
data['signature_height'] = str(signature_height) |
||||
|
|
||||
|
timeout = getattr(settings, 'ASSINATURA_API_TIMEOUT', 120) |
||||
|
|
||||
|
try: |
||||
|
response = requests.post( |
||||
|
_url('sign'), |
||||
|
files=files, |
||||
|
data=data, |
||||
|
headers=_montar_headers(), |
||||
|
timeout=timeout, |
||||
|
) |
||||
|
except requests.exceptions.ConnectionError as exc: |
||||
|
logger.error(f'[assinatura-api] Falha de conexao: {exc}') |
||||
|
raise AssinaturaAPIError( |
||||
|
'Nao foi possivel conectar ao microservico de assinatura. ' |
||||
|
'Verifique se o servico esta disponivel.' |
||||
|
) |
||||
|
except requests.exceptions.Timeout: |
||||
|
raise AssinaturaAPIError( |
||||
|
f'Timeout ao aguardar resposta do microservico de assinatura ' |
||||
|
f'(limite: {timeout}s).' |
||||
|
) |
||||
|
except requests.exceptions.RequestException as exc: |
||||
|
logger.error(f'[assinatura-api] Erro inesperado: {exc}') |
||||
|
raise AssinaturaAPIError(f'Erro ao comunicar com o microservico: {exc}') |
||||
|
|
||||
|
if not response.ok: |
||||
|
try: |
||||
|
detail = response.json() |
||||
|
msg = detail.get('detail') or detail.get('error') or str(detail) |
||||
|
except Exception: |
||||
|
msg = response.text[:300] or f'HTTP {response.status_code}' |
||||
|
logger.error(f'[assinatura-api] Erro HTTP {response.status_code}: {msg}') |
||||
|
raise AssinaturaAPIError(msg, status_code=response.status_code) |
||||
|
|
||||
|
# A API retorna o PDF assinado diretamente como application/pdf |
||||
|
content_type = response.headers.get('Content-Type', '') |
||||
|
if 'pdf' not in content_type and len(response.content) < 100: |
||||
|
raise AssinaturaAPIError( |
||||
|
f'Resposta inesperada do microservico (Content-Type: {content_type}).' |
||||
|
) |
||||
|
|
||||
|
logger.info('[assinatura-api] PDF assinado com sucesso pelo microservico.') |
||||
|
return response.content |
||||
|
|
||||
|
|
||||
|
def assinar_pdf_lote_via_api(itens, *, certificado_bytes, senha, |
||||
|
reason=None, location=None, |
||||
|
download_workers=8): |
||||
|
""" |
||||
|
Assina múltiplos PDFs em uma única chamada POST /sign/batch e baixa os |
||||
|
resultados em paralelo via download_url do S3. |
||||
|
|
||||
|
Parâmetros |
||||
|
---------- |
||||
|
itens : list[dict] — cada item deve ter: |
||||
|
'id' : identificador (qualquer hashable — preservado no resultado) |
||||
|
'pdf_bytes' : bytes do PDF a assinar |
||||
|
'signature_page' : int 1-based (opcional, mesmo para todos) |
||||
|
'signature_left' : float (opcional) |
||||
|
'signature_bottom': float (opcional) |
||||
|
'signature_width' : float (opcional) |
||||
|
'signature_height': float (opcional) |
||||
|
certificado_bytes : bytes — arquivo .pfx/.p12 (compartilhado por todos) |
||||
|
senha : str — senha do certificado |
||||
|
reason, location : str — metadados da assinatura |
||||
|
download_workers : int — threads para baixar resultados do S3 (default 8) |
||||
|
|
||||
|
Retorna: list[dict] na mesma ordem de `itens`, com campos: |
||||
|
'id' : o mesmo id do item de entrada |
||||
|
'ok' : True / False |
||||
|
'pdf_bytes' : bytes do PDF assinado (apenas quando ok=True) |
||||
|
'error' : mensagem de erro (apenas quando ok=False) |
||||
|
""" |
||||
|
if not _api_configurada(): |
||||
|
raise AssinaturaAPIError( |
||||
|
'Microserviço de assinatura não configurado (ASSINATURA_API_URL vazio).' |
||||
|
) |
||||
|
|
||||
|
timeout = getattr(settings, 'ASSINATURA_API_TIMEOUT', 120) |
||||
|
|
||||
|
# ── 1. Enviar todos os PDFs em uma única chamada /sign/batch ───────────── |
||||
|
# O campo 'signature_page/left/bottom/width/height' é único para o lote — |
||||
|
# usamos os valores do primeiro item (todos partilham a mesma posição). |
||||
|
primeiro = itens[0] if itens else {} |
||||
|
data = {'pfx_password': senha} |
||||
|
if reason: |
||||
|
data['reason'] = reason |
||||
|
if location: |
||||
|
data['location'] = location |
||||
|
if primeiro.get('signature_page') is not None: |
||||
|
data['signature_page'] = str(primeiro['signature_page']) |
||||
|
if primeiro.get('signature_left') is not None: |
||||
|
data['signature_left'] = str(primeiro['signature_left']) |
||||
|
if primeiro.get('signature_bottom') is not None: |
||||
|
data['signature_bottom'] = str(primeiro['signature_bottom']) |
||||
|
if primeiro.get('signature_width') is not None: |
||||
|
data['signature_width'] = str(primeiro['signature_width']) |
||||
|
if primeiro.get('signature_height') is not None: |
||||
|
data['signature_height'] = str(primeiro['signature_height']) |
||||
|
|
||||
|
# multipart: múltiplos campos 'pdfs' + um 'pfx' |
||||
|
files = [('pfx', ('certificado.pfx', certificado_bytes, 'application/octet-stream'))] |
||||
|
for idx, item in enumerate(itens): |
||||
|
filename = f'doc{idx + 1}.pdf' |
||||
|
files.append(('pdfs', (filename, item['pdf_bytes'], 'application/pdf'))) |
||||
|
|
||||
|
try: |
||||
|
response = requests.post( |
||||
|
_url('sign/batch'), |
||||
|
files=files, |
||||
|
data=data, |
||||
|
headers=_montar_headers(), |
||||
|
timeout=timeout, |
||||
|
) |
||||
|
except requests.exceptions.ConnectionError as exc: |
||||
|
logger.error(f'[assinatura-api/batch] Falha de conexão: {exc}') |
||||
|
raise AssinaturaAPIError( |
||||
|
'Não foi possível conectar ao microserviço de assinatura.' |
||||
|
) |
||||
|
except requests.exceptions.Timeout: |
||||
|
raise AssinaturaAPIError( |
||||
|
f'Timeout ao aguardar resposta do microserviço (limite: {timeout}s).' |
||||
|
) |
||||
|
except requests.exceptions.RequestException as exc: |
||||
|
raise AssinaturaAPIError(f'Erro ao comunicar com o microserviço: {exc}') |
||||
|
|
||||
|
if not response.ok: |
||||
|
try: |
||||
|
detail = response.json() |
||||
|
msg = detail.get('detail') or str(detail) |
||||
|
except Exception: |
||||
|
msg = response.text[:300] or f'HTTP {response.status_code}' |
||||
|
logger.error(f'[assinatura-api/batch] Erro HTTP {response.status_code}: {msg}') |
||||
|
raise AssinaturaAPIError(msg, status_code=response.status_code) |
||||
|
|
||||
|
try: |
||||
|
batch_result = response.json() |
||||
|
except Exception: |
||||
|
raise AssinaturaAPIError('Resposta do /sign/batch não é JSON válido.') |
||||
|
|
||||
|
resultados_api = batch_result.get('results', []) |
||||
|
if len(resultados_api) != len(itens): |
||||
|
raise AssinaturaAPIError( |
||||
|
f'Resposta do /sign/batch retornou {len(resultados_api)} itens, ' |
||||
|
f'esperado {len(itens)}.' |
||||
|
) |
||||
|
|
||||
|
logger.info(f'[assinatura-api/batch] {len(resultados_api)} PDFs assinados. Baixando...') |
||||
|
|
||||
|
# ── 2. Baixar PDFs assinados em paralelo via download_url ───────────────── |
||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed |
||||
|
|
||||
|
def _baixar(idx_url): |
||||
|
idx, url = idx_url |
||||
|
try: |
||||
|
r = requests.get(url, timeout=60) |
||||
|
if not r.ok: |
||||
|
return idx, None, f'Erro ao baixar PDF assinado: HTTP {r.status_code}' |
||||
|
if not r.content[:5] == b'%PDF-': |
||||
|
return idx, None, 'Conteúdo baixado não é um PDF válido.' |
||||
|
return idx, r.content, None |
||||
|
except Exception as exc: |
||||
|
return idx, None, str(exc) |
||||
|
|
||||
|
urls_indexadas = [ |
||||
|
(idx, res['download_url']) |
||||
|
for idx, res in enumerate(resultados_api) |
||||
|
] |
||||
|
|
||||
|
resultados_finais = [None] * len(itens) |
||||
|
with ThreadPoolExecutor(max_workers=download_workers) as executor: |
||||
|
futures = {executor.submit(_baixar, item): item for item in urls_indexadas} |
||||
|
for future in as_completed(futures): |
||||
|
idx, pdf_bytes, error = future.result() |
||||
|
item_id = itens[idx]['id'] |
||||
|
if error: |
||||
|
logger.error(f'[assinatura-api/batch] item {idx} (id={item_id}): {error}') |
||||
|
resultados_finais[idx] = {'id': item_id, 'ok': False, 'error': error} |
||||
|
else: |
||||
|
resultados_finais[idx] = {'id': item_id, 'ok': True, 'pdf_bytes': pdf_bytes} |
||||
|
|
||||
|
return resultados_finais |
||||
|
|
||||
|
|
||||
|
def validar_pfx_via_api(certificado_bytes, senha): |
||||
|
""" |
||||
|
Valida um certificado PFX no microservico. |
||||
|
Retorna dict com informacoes do certificado ou lanca AssinaturaAPIError. |
||||
|
""" |
||||
|
if not _api_configurada(): |
||||
|
raise AssinaturaAPIError('ASSINATURA_API_URL nao configurado.') |
||||
|
|
||||
|
files = {'pfx': ('certificado.pfx', certificado_bytes, 'application/octet-stream')} |
||||
|
data = {'pfx_password': senha} |
||||
|
timeout = getattr(settings, 'ASSINATURA_API_TIMEOUT', 120) |
||||
|
|
||||
|
try: |
||||
|
response = requests.post( |
||||
|
_url('validate-pfx'), |
||||
|
files=files, |
||||
|
data=data, |
||||
|
headers=_montar_headers(), |
||||
|
timeout=timeout, |
||||
|
) |
||||
|
except requests.exceptions.RequestException as exc: |
||||
|
raise AssinaturaAPIError(f'Erro ao comunicar com o microservico: {exc}') |
||||
|
|
||||
|
if not response.ok: |
||||
|
try: |
||||
|
detail = response.json() |
||||
|
msg = detail.get('detail') or str(detail) |
||||
|
except Exception: |
||||
|
msg = response.text[:300] or f'HTTP {response.status_code}' |
||||
|
raise AssinaturaAPIError(msg, status_code=response.status_code) |
||||
|
|
||||
|
try: |
||||
|
return response.json() |
||||
|
except Exception: |
||||
|
return {} |
||||
|
|
||||
|
|
||||
|
def verificar_health(): |
||||
|
""" |
||||
|
Verifica se o microservico esta disponivel (GET /). |
||||
|
Retorna (ok: bool, mensagem: str). |
||||
|
""" |
||||
|
if not _api_configurada(): |
||||
|
return False, 'ASSINATURA_API_URL nao configurado.' |
||||
|
|
||||
|
try: |
||||
|
response = requests.get( |
||||
|
_url('/'), |
||||
|
headers=_montar_headers(), |
||||
|
timeout=10, |
||||
|
) |
||||
|
if response.ok: |
||||
|
return True, 'Microservico disponivel.' |
||||
|
return False, f'Microservico respondeu com HTTP {response.status_code}.' |
||||
|
except requests.exceptions.RequestException as exc: |
||||
|
return False, f'Falha de conexao: {exc}' |
||||
File diff suppressed because it is too large
Loading…
Reference in new issue