mirror of https://github.com/interlegis/sapl.git
Browse Source
- Implementa sistema de assinatura digital para matérias legislativas - Suporte a certificados A1 (.pfx/.p12) com validação de senha - Carimbo visual no PDF com nome, CPF, data e logo da câmara - Modal de assinatura com interface amigável - Campos no modelo: pdf_assinado, assinatura_info, assinado_em, assinado_por - Migração 0088 para novos campos de assinatura - Adiciona dependências: pyhanko, pyhanko-certvalidator, cryptography UI/UX: - Move subnav (tabs de navegação) para abaixo do menu principal - Novo estilo minimalista para as tabs com indicador azul - Scroll horizontal em dispositivos móveispull/3858/head
10 changed files with 1633 additions and 8 deletions
@ -0,0 +1,63 @@ |
|||
# Generated migration for digital signature fields |
|||
|
|||
from django.conf import settings |
|||
from django.contrib.postgres.fields import JSONField |
|||
from django.db import migrations, models |
|||
import django.db.models.deletion |
|||
|
|||
import sapl.materia.models |
|||
import sapl.utils |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
|||
('materia', '0087_update_viewdb_materiaemtramitacao'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.AddField( |
|||
model_name='materialegislativa', |
|||
name='pdf_assinado', |
|||
field=models.FileField( |
|||
blank=True, |
|||
max_length=300, |
|||
null=True, |
|||
storage=sapl.utils.OverwriteStorage(), |
|||
upload_to=sapl.materia.models.materia_upload_path, |
|||
verbose_name='PDF Assinado' |
|||
), |
|||
), |
|||
migrations.AddField( |
|||
model_name='materialegislativa', |
|||
name='assinatura_info', |
|||
field=JSONField( |
|||
blank=True, |
|||
help_text='Metadados do certificado digital usado na assinatura', |
|||
null=True, |
|||
verbose_name='Informações da Assinatura' |
|||
), |
|||
), |
|||
migrations.AddField( |
|||
model_name='materialegislativa', |
|||
name='assinado_em', |
|||
field=models.DateTimeField( |
|||
blank=True, |
|||
null=True, |
|||
verbose_name='Data/Hora da Assinatura' |
|||
), |
|||
), |
|||
migrations.AddField( |
|||
model_name='materialegislativa', |
|||
name='assinado_por', |
|||
field=models.ForeignKey( |
|||
blank=True, |
|||
null=True, |
|||
on_delete=django.db.models.deletion.SET_NULL, |
|||
related_name='materias_assinadas', |
|||
to=settings.AUTH_USER_MODEL, |
|||
verbose_name='Assinado por' |
|||
), |
|||
), |
|||
] |
|||
@ -0,0 +1,810 @@ |
|||
""" |
|||
Views para assinatura digital de PDFs de Matérias Legislativas. |
|||
Suporta certificados A1 (arquivo .pfx/.p12) e A3 (token USB/smartcard). |
|||
""" |
|||
import io |
|||
import json |
|||
import logging |
|||
import os |
|||
import tempfile |
|||
from datetime import datetime |
|||
|
|||
from django.conf import settings |
|||
from django.contrib import messages |
|||
from django.contrib.auth.decorators import login_required |
|||
from django.core.files.base import ContentFile |
|||
from django.http import HttpResponse, JsonResponse |
|||
from django.shortcuts import get_object_or_404, redirect |
|||
from django.utils import timezone |
|||
from django.views.decorators.csrf import csrf_exempt |
|||
from django.views.decorators.http import require_http_methods |
|||
|
|||
from sapl.materia.models import MateriaLegislativa |
|||
|
|||
logger = logging.getLogger(__name__) |
|||
|
|||
|
|||
def _gerar_pdf_da_materia(materia, request): |
|||
""" |
|||
Gera o PDF da matéria para assinatura. |
|||
Primeiro tenta usar o PDF existente, depois converte DOCX via OnlyOffice. |
|||
Retorna bytes do PDF ou None em caso de erro. |
|||
""" |
|||
import requests as http_requests |
|||
import xml.etree.ElementTree as ET |
|||
from django.urls import reverse |
|||
|
|||
# Se não tem documento, retorna None |
|||
if not materia.texto_original: |
|||
return None, "Matéria não possui documento de texto original." |
|||
|
|||
file_name = materia.texto_original.name.lower() |
|||
|
|||
# Se já é PDF, retorna diretamente |
|||
if file_name.endswith('.pdf'): |
|||
try: |
|||
with open(materia.texto_original.path, 'rb') as f: |
|||
return f.read(), None |
|||
except Exception as e: |
|||
logger.error(f"Erro ao ler arquivo PDF: {e}") |
|||
return None, f"Erro ao ler o arquivo PDF: {e}" |
|||
|
|||
# Converter DOCX para PDF via OnlyOffice |
|||
from sapl.materia.onlyoffice_materia_views import generate_file_key |
|||
|
|||
download_url = request.build_absolute_uri( |
|||
reverse('sapl.materia:materia_onlyoffice_download', kwargs={'pk': materia.pk}) |
|||
) |
|||
|
|||
# Substituir pelo nome do container na rede Docker |
|||
host = request.get_host() |
|||
download_url = download_url.replace(f'http://{host}', 'http://sapl-dev:8000') |
|||
download_url = download_url.replace(f'https://{host}', 'http://sapl-dev:8000') |
|||
|
|||
conversion_url = 'http://onlyoffice:80/ConvertService.ashx' |
|||
|
|||
conversion_data = { |
|||
"async": False, |
|||
"filetype": "docx", |
|||
"key": generate_file_key("materia_sign", materia.pk, request.user.pk), |
|||
"outputtype": "pdf", |
|||
"title": f"Materia_{materia.tipo}_{materia.numero}_{materia.ano}.pdf", |
|||
"url": download_url, |
|||
} |
|||
|
|||
# Adiciona JWT se estiver habilitado |
|||
if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None): |
|||
import jwt |
|||
token = jwt.encode(conversion_data, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256') |
|||
conversion_data['token'] = token |
|||
|
|||
try: |
|||
headers = {'Content-Type': 'application/json'} |
|||
|
|||
if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None): |
|||
import jwt |
|||
header_token = jwt.encode({"payload": conversion_data}, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256') |
|||
headers['Authorization'] = f'Bearer {header_token}' |
|||
|
|||
conversion_response = http_requests.post( |
|||
conversion_url, |
|||
json=conversion_data, |
|||
headers=headers, |
|||
timeout=60 |
|||
) |
|||
|
|||
if conversion_response.status_code != 200: |
|||
return None, f"Erro na conversão OnlyOffice: status={conversion_response.status_code}" |
|||
|
|||
root = ET.fromstring(conversion_response.text) |
|||
|
|||
error_elem = root.find('Error') |
|||
if error_elem is not None: |
|||
return None, f"Erro na conversão do documento: código {error_elem.text}" |
|||
|
|||
file_url_elem = root.find('FileUrl') |
|||
if file_url_elem is None or not file_url_elem.text: |
|||
return None, "URL do PDF não retornada pelo OnlyOffice" |
|||
|
|||
pdf_url = file_url_elem.text |
|||
|
|||
if 'onlyoffice/' in pdf_url and not pdf_url.startswith('http://onlyoffice:'): |
|||
pdf_url = pdf_url.replace('http://onlyoffice/', 'http://onlyoffice:80/') |
|||
|
|||
pdf_response = http_requests.get(pdf_url, timeout=60) |
|||
|
|||
if pdf_response.status_code != 200: |
|||
return None, f"Erro ao baixar PDF convertido: status={pdf_response.status_code}" |
|||
|
|||
return pdf_response.content, None |
|||
|
|||
except Exception as e: |
|||
logger.error(f"Erro na geração de PDF: {e}") |
|||
return None, f"Erro inesperado: {e}" |
|||
|
|||
|
|||
@login_required |
|||
@csrf_exempt |
|||
@require_http_methods(["POST"]) |
|||
def materia_assinar_a1(request, pk): |
|||
""" |
|||
Assina o PDF da matéria com certificado A1 (arquivo .pfx/.p12). |
|||
|
|||
Parâmetros POST: |
|||
- certificado: arquivo .pfx ou .p12 |
|||
- senha: senha do certificado |
|||
""" |
|||
materia = get_object_or_404(MateriaLegislativa, pk=pk) |
|||
|
|||
# Permissão: qualquer usuário autenticado pode assinar |
|||
# (a autenticação é garantida pelo decorator @login_required) |
|||
|
|||
# Verifica se já está assinada |
|||
if materia.pdf_assinado: |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': 'Esta matéria já possui um PDF assinado.' |
|||
}, status=400) |
|||
|
|||
# Obtém dados do formulário |
|||
certificado_file = request.FILES.get('certificado') |
|||
senha = request.POST.get('senha', '') |
|||
|
|||
if not certificado_file: |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': 'Certificado não informado.' |
|||
}, status=400) |
|||
|
|||
if not senha: |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': 'Senha do certificado não informada.' |
|||
}, status=400) |
|||
|
|||
# Gera o PDF da matéria |
|||
pdf_bytes, error = _gerar_pdf_da_materia(materia, request) |
|||
if error: |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': error |
|||
}, status=400) |
|||
|
|||
try: |
|||
from pyhanko.sign import signers, fields |
|||
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter |
|||
from pyhanko.keys import load_cert_from_pemder |
|||
from pyhanko_certvalidator import ValidationContext |
|||
|
|||
# Lê o certificado |
|||
cert_data = certificado_file.read() |
|||
|
|||
# Cria o assinador - salva temporariamente o certificado |
|||
import tempfile as tmp_module |
|||
with tmp_module.NamedTemporaryFile(delete=False, suffix='.pfx') as tmp_cert: |
|||
tmp_cert.write(cert_data) |
|||
tmp_cert_path = tmp_cert.name |
|||
|
|||
try: |
|||
signer = signers.SimpleSigner.load_pkcs12( |
|||
pfx_file=tmp_cert_path, |
|||
passphrase=senha.encode('utf-8') |
|||
) |
|||
# Limpa arquivo temporário do certificado após carregar |
|||
if os.path.exists(tmp_cert_path): |
|||
os.unlink(tmp_cert_path) |
|||
except Exception as cert_error: |
|||
logger.error(f"Erro ao carregar certificado: {cert_error}") |
|||
# Limpa arquivo temporário do certificado |
|||
if os.path.exists(tmp_cert_path): |
|||
os.unlink(tmp_cert_path) |
|||
error_msg = str(cert_error) |
|||
if 'password' in error_msg.lower() or 'mac' in error_msg.lower(): |
|||
error_detail = 'Senha incorreta.' |
|||
elif 'decode' in error_msg.lower() or 'parse' in error_msg.lower(): |
|||
error_detail = 'Arquivo não é um certificado válido (.pfx/.p12).' |
|||
else: |
|||
error_detail = f'Detalhes: {error_msg}' |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': f'Erro ao carregar certificado: {error_detail}' |
|||
}, status=400) |
|||
|
|||
# Verifica validade do certificado |
|||
cert_info = signer.signing_cert |
|||
now = timezone.now() |
|||
# Converte datas do certificado para timezone-aware se necessário |
|||
valid_before = cert_info.not_valid_before |
|||
valid_after = cert_info.not_valid_after |
|||
if valid_before.tzinfo is None: |
|||
import pytz |
|||
valid_before = pytz.UTC.localize(valid_before) |
|||
if valid_after.tzinfo is None: |
|||
import pytz |
|||
valid_after = pytz.UTC.localize(valid_after) |
|||
if now < valid_before or now > valid_after: |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': 'Certificado expirado ou ainda não válido.' |
|||
}, status=400) |
|||
|
|||
# Cria arquivo temporário para o PDF assinado |
|||
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_signed: |
|||
temp_signed_path = temp_signed.name |
|||
|
|||
try: |
|||
from pyhanko.sign.fields import SigSeedSubFilter |
|||
from pyhanko.pdf_utils import text |
|||
from pyhanko.sign.general import SigningError |
|||
|
|||
# Informações do assinante |
|||
nome_assinante = request.user.get_full_name() or request.user.username |
|||
data_assinatura = timezone.localtime(timezone.now()) |
|||
data_formatada = data_assinatura.strftime('%d/%m/%Y %H:%M:%S') |
|||
|
|||
# Tenta obter cargo do usuário (se for parlamentar/autor) |
|||
cargo = "Usuário do Sistema" |
|||
parlamentar = None |
|||
try: |
|||
from sapl.parlamentares.models import Parlamentar |
|||
parlamentar = Parlamentar.objects.filter( |
|||
usuario=request.user |
|||
).first() |
|||
if parlamentar: |
|||
cargo = "Vereador(a)" |
|||
nome_assinante = parlamentar.nome_parlamentar |
|||
except: |
|||
pass |
|||
|
|||
# Determina tipo de certificado baseado no issuer |
|||
issuer_str = str(cert_info.issuer).upper() |
|||
if 'ICP-BRASIL' in issuer_str or 'ICP BRASIL' in issuer_str: |
|||
tipo_cert = "ICP-Brasil" |
|||
else: |
|||
tipo_cert = "Certificado Digital" |
|||
|
|||
# Assina o PDF com assinatura invisível (sem campo visual do pyhanko) |
|||
with io.BytesIO(pdf_bytes) as inf: |
|||
w = IncrementalPdfFileWriter(inf) |
|||
|
|||
# Metadados da assinatura (sem campo visível) |
|||
meta = signers.PdfSignatureMetadata( |
|||
field_name='AssinaturaDigital', |
|||
location='Câmara Municipal', |
|||
reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', |
|||
name=nome_assinante |
|||
) |
|||
|
|||
# Executa assinatura invisível |
|||
with open(temp_signed_path, 'wb') as outf: |
|||
signers.sign_pdf( |
|||
w, |
|||
meta, |
|||
signer=signer, |
|||
output=outf |
|||
) |
|||
|
|||
# Adiciona carimbo de texto visível ao PDF assinado |
|||
# Usando reportlab para adicionar o texto |
|||
from PyPDF4 import PdfFileReader, PdfFileWriter |
|||
from reportlab.pdfgen import canvas |
|||
from reportlab.lib.pagesizes import A4 |
|||
from reportlab.lib.units import mm |
|||
from reportlab.lib.utils import ImageReader |
|||
|
|||
# Tenta obter CPF do parlamentar ou do certificado |
|||
cpf = "" |
|||
try: |
|||
if parlamentar and parlamentar.cpf: |
|||
cpf = parlamentar.cpf |
|||
except: |
|||
pass |
|||
|
|||
# Se não encontrou CPF no parlamentar, tenta extrair do certificado |
|||
if not cpf: |
|||
subject_str = str(cert_info.subject) |
|||
import re |
|||
cpf_match = re.search(r'\d{3}\.?\d{3}\.?\d{3}-?\d{2}', subject_str) |
|||
if cpf_match: |
|||
cpf = cpf_match.group() |
|||
|
|||
# Formata data |
|||
data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M') |
|||
|
|||
# Cria PDF com o carimbo de assinatura |
|||
stamp_buffer = io.BytesIO() |
|||
c = canvas.Canvas(stamp_buffer, pagesize=A4) |
|||
|
|||
# Posição do carimbo (canto inferior esquerdo) |
|||
y_pos = 15 * mm |
|||
x_pos = 10 * mm |
|||
largura_carimbo = 70 * mm # Aumentado para caber texto + logo |
|||
altura_carimbo = 22 * mm |
|||
logo_width = 18 * mm |
|||
|
|||
# Desenha borda fina do carimbo |
|||
c.setStrokeColorRGB(0.5, 0.5, 0.5) |
|||
c.setLineWidth(0.5) |
|||
c.rect(x_pos, y_pos, largura_carimbo, altura_carimbo) |
|||
|
|||
# Texto do carimbo (lado esquerdo) |
|||
c.setFont("Helvetica", 6) |
|||
c.setFillColorRGB(0.3, 0.3, 0.3) |
|||
c.drawString(x_pos + 3*mm, y_pos + 17*mm, "Assinado digitalmente por") |
|||
|
|||
c.setFont("Helvetica-Bold", 7) |
|||
c.setFillColorRGB(0, 0, 0) |
|||
# Nome em maiúsculas, quebra se muito longo |
|||
nome_upper = nome_assinante.upper() |
|||
if len(nome_upper) > 28: |
|||
nome_upper = nome_upper[:28] + "..." |
|||
c.drawString(x_pos + 3*mm, y_pos + 12*mm, nome_upper) |
|||
|
|||
c.setFont("Helvetica", 6) |
|||
c.setFillColorRGB(0.3, 0.3, 0.3) |
|||
if cpf: |
|||
c.drawString(x_pos + 3*mm, y_pos + 7*mm, f"CPF: {cpf}") |
|||
c.drawString(x_pos + 3*mm, y_pos + 3*mm, f"Data: {data_simples}") |
|||
else: |
|||
c.drawString(x_pos + 3*mm, y_pos + 5*mm, f"Data: {data_simples}") |
|||
|
|||
# Logo da câmara (lado direito) |
|||
try: |
|||
# Procura o logotipo da câmara em vários locais possíveis |
|||
logo_path = None |
|||
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
|||
|
|||
possible_paths = [ |
|||
# Logo padrão do sistema |
|||
os.path.join(base_dir, 'sapl/static/sapl/frontend/img/logo-camara-padrao.png'), |
|||
os.path.join(base_dir, 'sapl/static/sapl/frontend/img/logo.png'), |
|||
os.path.join(base_dir, 'sapl/static/sapl/frontend/img/pdflogo.png'), |
|||
# Media folder |
|||
os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo/logo.png'), |
|||
os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo/logotipo.png'), |
|||
] |
|||
|
|||
# Procura qualquer imagem na pasta de logotipo do media |
|||
logo_dir = os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo') |
|||
if os.path.exists(logo_dir): |
|||
for f in os.listdir(logo_dir): |
|||
if f.lower().endswith(('.png', '.jpg', '.jpeg')): |
|||
possible_paths.insert(0, os.path.join(logo_dir, f)) |
|||
|
|||
for path in possible_paths: |
|||
if os.path.exists(path): |
|||
logo_path = path |
|||
break |
|||
|
|||
if logo_path: |
|||
# Desenha logo no lado direito do carimbo |
|||
logo = ImageReader(logo_path) |
|||
logo_x = x_pos + largura_carimbo - logo_width - 2*mm |
|||
logo_y = y_pos + 2*mm |
|||
c.drawImage(logo, logo_x, logo_y, |
|||
width=logo_width, height=logo_width, |
|||
preserveAspectRatio=True, mask='auto') |
|||
except Exception as logo_error: |
|||
logger.warning(f"Não foi possível adicionar logo: {logo_error}") |
|||
|
|||
c.save() |
|||
stamp_buffer.seek(0) |
|||
|
|||
# Mescla o carimbo com o PDF assinado |
|||
stamp_pdf = PdfFileReader(stamp_buffer) |
|||
signed_pdf = PdfFileReader(open(temp_signed_path, 'rb')) |
|||
output_pdf = PdfFileWriter() |
|||
|
|||
# Adiciona o carimbo em todas as páginas |
|||
for page_num in range(signed_pdf.getNumPages()): |
|||
page = signed_pdf.getPage(page_num) |
|||
if page_num == signed_pdf.getNumPages() - 1: # Última página |
|||
page.mergePage(stamp_pdf.getPage(0)) |
|||
output_pdf.addPage(page) |
|||
|
|||
# Salva o PDF final com carimbo |
|||
final_buffer = io.BytesIO() |
|||
output_pdf.write(final_buffer) |
|||
final_buffer.seek(0) |
|||
signed_pdf_content = final_buffer.read() |
|||
|
|||
# Salva o PDF assinado no modelo |
|||
filename = f"materia_{materia.pk}_assinado_{int(timezone.now().timestamp())}.pdf" |
|||
materia.pdf_assinado.save(filename, ContentFile(signed_pdf_content), save=False) |
|||
|
|||
# Salva informações da assinatura |
|||
materia.assinatura_info = { |
|||
'tipo_certificado': 'A1', |
|||
'tipo_certificado_display': f'{tipo_cert} – A1', |
|||
'subject': str(cert_info.subject), |
|||
'issuer': str(cert_info.issuer), |
|||
'serial': str(cert_info.serial_number), |
|||
'valid_from': cert_info.not_valid_before.isoformat(), |
|||
'valid_to': cert_info.not_valid_after.isoformat(), |
|||
'signed_by': request.user.username, |
|||
'nome_assinante': nome_assinante, |
|||
'cargo': cargo, |
|||
'data_assinatura': data_formatada, |
|||
'validade_juridica': 'Assinatura Eletrônica Qualificada' |
|||
} |
|||
materia.assinado_em = timezone.now() |
|||
materia.assinado_por = request.user |
|||
materia.save() |
|||
|
|||
logger.info(f"Matéria {materia.pk} assinada por {request.user.username}") |
|||
|
|||
return JsonResponse({ |
|||
'success': True, |
|||
'message': 'PDF assinado com sucesso!', |
|||
'certificado': { |
|||
'nome': str(cert_info.subject), |
|||
'validade': cert_info.not_valid_after.strftime('%d/%m/%Y') |
|||
} |
|||
}) |
|||
|
|||
finally: |
|||
# Remove arquivo temporário |
|||
if os.path.exists(temp_signed_path): |
|||
os.unlink(temp_signed_path) |
|||
|
|||
except ImportError: |
|||
logger.error("pyhanko não está instalado") |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': 'Biblioteca de assinatura não instalada. Contate o administrador.' |
|||
}, status=500) |
|||
except Exception as e: |
|||
logger.error(f"Erro ao assinar PDF: {e}") |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': f'Erro ao assinar o PDF: {str(e)}' |
|||
}, status=500) |
|||
|
|||
|
|||
@login_required |
|||
@require_http_methods(["POST"]) |
|||
def materia_assinar_a3_preparar(request, pk): |
|||
""" |
|||
Prepara a assinatura A3 gerando o hash do PDF para ser assinado pelo token. |
|||
|
|||
Retorna: |
|||
- hash: hash SHA-256 do PDF em hexadecimal |
|||
- pdf_base64: PDF codificado em base64 (para assinatura no cliente) |
|||
""" |
|||
import base64 |
|||
import hashlib |
|||
|
|||
materia = get_object_or_404(MateriaLegislativa, pk=pk) |
|||
|
|||
# Verifica permissão |
|||
if not request.user.has_perm('materia.change_materialegislativa'): |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': 'Você não tem permissão para assinar esta matéria.' |
|||
}, status=403) |
|||
|
|||
# Verifica se já está assinada |
|||
if materia.pdf_assinado: |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': 'Esta matéria já possui um PDF assinado.' |
|||
}, status=400) |
|||
|
|||
# Gera o PDF da matéria |
|||
pdf_bytes, error = _gerar_pdf_da_materia(materia, request) |
|||
if error: |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': error |
|||
}, status=400) |
|||
|
|||
# Calcula o hash SHA-256 |
|||
pdf_hash = hashlib.sha256(pdf_bytes).hexdigest() |
|||
|
|||
# Codifica o PDF em base64 |
|||
pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8') |
|||
|
|||
return JsonResponse({ |
|||
'success': True, |
|||
'hash': pdf_hash, |
|||
'pdf_base64': pdf_base64, |
|||
'materia_id': materia.pk |
|||
}) |
|||
|
|||
|
|||
@login_required |
|||
@require_http_methods(["POST"]) |
|||
@csrf_exempt |
|||
def materia_assinar_a3_finalizar(request, pk): |
|||
""" |
|||
Finaliza a assinatura A3 incorporando a assinatura recebida do token. |
|||
|
|||
Parâmetros POST (JSON): |
|||
- signature: assinatura em base64 |
|||
- certificate: certificado em base64 |
|||
- certificate_chain: cadeia de certificados em base64 (opcional) |
|||
""" |
|||
import base64 |
|||
|
|||
materia = get_object_or_404(MateriaLegislativa, pk=pk) |
|||
|
|||
# Verifica permissão |
|||
if not request.user.has_perm('materia.change_materialegislativa'): |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': 'Você não tem permissão para assinar esta matéria.' |
|||
}, status=403) |
|||
|
|||
# Verifica se já está assinada |
|||
if materia.pdf_assinado: |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': 'Esta matéria já possui um PDF assinado.' |
|||
}, status=400) |
|||
|
|||
try: |
|||
data = json.loads(request.body.decode('utf-8')) |
|||
except json.JSONDecodeError: |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': 'Dados inválidos.' |
|||
}, status=400) |
|||
|
|||
signature_b64 = data.get('signature') |
|||
certificate_b64 = data.get('certificate') |
|||
|
|||
if not signature_b64 or not certificate_b64: |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': 'Assinatura ou certificado não informados.' |
|||
}, status=400) |
|||
|
|||
# Gera o PDF da matéria |
|||
pdf_bytes, error = _gerar_pdf_da_materia(materia, request) |
|||
if error: |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': error |
|||
}, status=400) |
|||
|
|||
try: |
|||
from pyhanko.sign import signers, fields |
|||
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter |
|||
from pyhanko.sign.signers.pdf_cms import ExternalSigner |
|||
from cryptography import x509 |
|||
from cryptography.hazmat.backends import default_backend |
|||
|
|||
# Decodifica o certificado |
|||
cert_der = base64.b64decode(certificate_b64) |
|||
cert = x509.load_der_x509_certificate(cert_der, default_backend()) |
|||
|
|||
# Verifica validade do certificado |
|||
now = datetime.utcnow() |
|||
if now < cert.not_valid_before or now > cert.not_valid_after: |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': 'Certificado expirado ou ainda não válido.' |
|||
}, status=400) |
|||
|
|||
# Decodifica a assinatura |
|||
signature = base64.b64decode(signature_b64) |
|||
|
|||
# Cria arquivo temporário para o PDF assinado |
|||
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_signed: |
|||
temp_signed_path = temp_signed.name |
|||
|
|||
try: |
|||
# Cria o signer externo com a assinatura pré-computada |
|||
# Nota: Esta é uma implementação simplificada |
|||
# Em produção, seria necessário usar o ExternalSigner corretamente |
|||
|
|||
with io.BytesIO(pdf_bytes) as inf: |
|||
w = IncrementalPdfFileWriter(inf) |
|||
|
|||
# Adiciona campo de assinatura |
|||
sig_field = fields.SigFieldSpec( |
|||
sig_field_name='AssinaturaDigital', |
|||
box=(50, 50, 250, 100) |
|||
) |
|||
|
|||
# Metadados da assinatura |
|||
meta = signers.PdfSignatureMetadata( |
|||
field_name='AssinaturaDigital', |
|||
location='Câmara Municipal', |
|||
reason='Assinatura Digital de Matéria Legislativa (A3)', |
|||
name=request.user.get_full_name() or request.user.username |
|||
) |
|||
|
|||
# Para assinatura A3, precisamos de integração mais complexa |
|||
# Por enquanto, retornamos erro informando que A3 requer aplicação local |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': 'Assinatura A3 requer aplicação local. Use o Assinador SERPRO ou similar.' |
|||
}, status=501) |
|||
|
|||
finally: |
|||
if os.path.exists(temp_signed_path): |
|||
os.unlink(temp_signed_path) |
|||
|
|||
except ImportError as e: |
|||
logger.error(f"Biblioteca não instalada: {e}") |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': 'Biblioteca de assinatura não instalada.' |
|||
}, status=500) |
|||
except Exception as e: |
|||
logger.error(f"Erro ao finalizar assinatura A3: {e}") |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': f'Erro ao processar assinatura: {str(e)}' |
|||
}, status=500) |
|||
|
|||
|
|||
@login_required |
|||
@require_http_methods(["GET"]) |
|||
def materia_pdf_assinado(request, pk): |
|||
""" |
|||
Retorna o PDF assinado da matéria para download/visualização. |
|||
""" |
|||
materia = get_object_or_404(MateriaLegislativa, pk=pk) |
|||
|
|||
if not materia.pdf_assinado: |
|||
messages.error(request, 'Esta matéria não possui PDF assinado.') |
|||
return redirect('sapl.materia:materialegislativa_detail', pk=pk) |
|||
|
|||
try: |
|||
with open(materia.pdf_assinado.path, 'rb') as f: |
|||
content = f.read() |
|||
|
|||
filename = f"Materia_{materia.tipo}_{materia.numero}_{materia.ano}_ASSINADO.pdf" |
|||
filename = filename.replace(' ', '_').replace('/', '-') |
|||
|
|||
response = HttpResponse(content, content_type='application/pdf') |
|||
response['Content-Disposition'] = f'inline; filename="{filename}"' |
|||
return response |
|||
|
|||
except Exception as e: |
|||
logger.error(f"Erro ao ler PDF assinado: {e}") |
|||
messages.error(request, 'Erro ao ler o arquivo PDF assinado.') |
|||
return redirect('sapl.materia:materialegislativa_detail', pk=pk) |
|||
|
|||
|
|||
@login_required |
|||
@require_http_methods(["GET"]) |
|||
def materia_verificar_assinatura(request, pk): |
|||
""" |
|||
Verifica a assinatura digital do PDF da matéria. |
|||
Retorna informações sobre as assinaturas encontradas. |
|||
""" |
|||
materia = get_object_or_404(MateriaLegislativa, pk=pk) |
|||
|
|||
if not materia.pdf_assinado: |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': 'Esta matéria não possui PDF assinado.', |
|||
'assinado': False |
|||
}) |
|||
|
|||
try: |
|||
from pyhanko.sign.validation import validate_pdf_signature |
|||
from pyhanko.pdf_utils.reader import PdfFileReader |
|||
|
|||
with open(materia.pdf_assinado.path, 'rb') as f: |
|||
reader = PdfFileReader(f) |
|||
|
|||
# Lista de assinaturas encontradas |
|||
assinaturas = [] |
|||
|
|||
# Verifica cada assinatura no PDF |
|||
for sig_field_name in reader.embedded_signatures: |
|||
try: |
|||
sig = reader.embedded_signatures[sig_field_name] |
|||
|
|||
# Informações básicas da assinatura |
|||
sig_info = { |
|||
'campo': sig_field_name, |
|||
'assinante': str(sig.signer_cert.subject) if sig.signer_cert else 'Desconhecido', |
|||
'data': sig.self_reported_timestamp.isoformat() if sig.self_reported_timestamp else None, |
|||
} |
|||
|
|||
assinaturas.append(sig_info) |
|||
|
|||
except Exception as sig_error: |
|||
logger.warning(f"Erro ao verificar assinatura {sig_field_name}: {sig_error}") |
|||
assinaturas.append({ |
|||
'campo': sig_field_name, |
|||
'erro': str(sig_error) |
|||
}) |
|||
|
|||
return JsonResponse({ |
|||
'success': True, |
|||
'assinado': True, |
|||
'total_assinaturas': len(assinaturas), |
|||
'assinaturas': assinaturas, |
|||
'info_salva': materia.assinatura_info |
|||
}) |
|||
|
|||
except ImportError: |
|||
# Se pyhanko não estiver instalado, retorna info salva no modelo |
|||
return JsonResponse({ |
|||
'success': True, |
|||
'assinado': True, |
|||
'info_salva': materia.assinatura_info, |
|||
'verificacao_disponivel': False |
|||
}) |
|||
except Exception as e: |
|||
logger.error(f"Erro ao verificar assinatura: {e}") |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': f'Erro ao verificar assinatura: {str(e)}', |
|||
'assinado': True, |
|||
'info_salva': materia.assinatura_info |
|||
}) |
|||
|
|||
|
|||
@login_required |
|||
@require_http_methods(["POST"]) |
|||
def materia_remover_assinatura(request, pk): |
|||
""" |
|||
Remove a assinatura digital da matéria. |
|||
Apenas superusuários podem executar esta ação. |
|||
""" |
|||
if not request.user.is_superuser: |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': 'Apenas administradores podem remover assinaturas.' |
|||
}, status=403) |
|||
|
|||
materia = get_object_or_404(MateriaLegislativa, pk=pk) |
|||
|
|||
if not materia.pdf_assinado: |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': 'Esta matéria não possui PDF assinado.' |
|||
}, status=400) |
|||
|
|||
try: |
|||
# Remove o arquivo |
|||
materia.pdf_assinado.delete(save=False) |
|||
|
|||
# Limpa os campos |
|||
materia.pdf_assinado = None |
|||
materia.assinatura_info = None |
|||
materia.assinado_em = None |
|||
materia.assinado_por = None |
|||
materia.save() |
|||
|
|||
logger.info(f"Assinatura da matéria {materia.pk} removida por {request.user.username}") |
|||
|
|||
return JsonResponse({ |
|||
'success': True, |
|||
'message': 'Assinatura removida com sucesso.' |
|||
}) |
|||
|
|||
except Exception as e: |
|||
logger.error(f"Erro ao remover assinatura: {e}") |
|||
return JsonResponse({ |
|||
'success': False, |
|||
'error': f'Erro ao remover assinatura: {str(e)}' |
|||
}, status=500) |
|||
|
|||
|
|||
@login_required |
|||
@require_http_methods(["GET"]) |
|||
def detectar_aplicacao_a3(request): |
|||
""" |
|||
Endpoint para verificar se há uma aplicação de assinatura A3 rodando localmente. |
|||
O frontend usa isso para decidir se mostra a opção A3. |
|||
""" |
|||
# Lista de portas comuns usadas por aplicações de assinatura |
|||
portas_conhecidas = [ |
|||
{'nome': 'Assinador SERPRO', 'porta': 10443}, |
|||
{'nome': 'Web PKI Local', 'porta': 5000}, |
|||
{'nome': 'Signer Local', 'porta': 8080}, |
|||
] |
|||
|
|||
return JsonResponse({ |
|||
'success': True, |
|||
'portas_conhecidas': portas_conhecidas, |
|||
'instrucoes': 'O frontend deve tentar conectar a cada porta para detectar a aplicação.' |
|||
}) |
|||
@ -0,0 +1,73 @@ |
|||
/* Subnav - Tabs de navegação abaixo do menu principal */ |
|||
|
|||
.subnav-wrapper { |
|||
background: #f8f9fa; |
|||
border-bottom: 1px solid #e9ecef; |
|||
padding: 0; |
|||
} |
|||
|
|||
.subnav-wrapper .nav-pills { |
|||
margin: 0; |
|||
padding: 0; |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
gap: 2px; |
|||
justify-content: flex-start; |
|||
} |
|||
|
|||
.subnav-wrapper .nav-item { |
|||
margin: 0; |
|||
} |
|||
|
|||
.subnav-wrapper .nav-link { |
|||
display: block; |
|||
padding: 10px 16px; |
|||
color: #495057; |
|||
text-decoration: none; |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
border-radius: 0; |
|||
border-bottom: 2px solid transparent; |
|||
transition: all 0.15s ease; |
|||
background: transparent; |
|||
} |
|||
|
|||
.subnav-wrapper .nav-link:hover { |
|||
color: #007bff; |
|||
background: rgba(0,123,255,0.05); |
|||
text-decoration: none; |
|||
} |
|||
|
|||
.subnav-wrapper .nav-link.active { |
|||
color: #007bff; |
|||
border-bottom-color: #007bff; |
|||
background: #fff; |
|||
} |
|||
|
|||
/* Dropdown */ |
|||
.subnav-wrapper .dropdown-menu { |
|||
margin-top: 0; |
|||
border-radius: 0 0 4px 4px; |
|||
border-top: none; |
|||
box-shadow: 0 4px 6px rgba(0,0,0,0.1); |
|||
} |
|||
|
|||
/* Responsivo */ |
|||
@media (max-width: 768px) { |
|||
.subnav-wrapper .nav-pills { |
|||
flex-wrap: nowrap; |
|||
overflow-x: auto; |
|||
-webkit-overflow-scrolling: touch; |
|||
scrollbar-width: none; |
|||
} |
|||
|
|||
.subnav-wrapper .nav-pills::-webkit-scrollbar { |
|||
display: none; |
|||
} |
|||
|
|||
.subnav-wrapper .nav-link { |
|||
padding: 8px 12px; |
|||
font-size: 13px; |
|||
white-space: nowrap; |
|||
} |
|||
} |
|||
@ -0,0 +1,551 @@ |
|||
{% load i18n %} |
|||
|
|||
<!-- Modal de Assinatura Digital --> |
|||
<div class="modal fade" id="assinaturaModal" tabindex="-1" role="dialog" aria-labelledby="assinaturaModalLabel" aria-hidden="true"> |
|||
<div class="modal-dialog modal-lg" role="document"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header bg-primary text-white"> |
|||
<h5 class="modal-title" id="assinaturaModalLabel"> |
|||
<i class="fa fa-certificate"></i> {% trans "Assinatura Digital de PDF" %} |
|||
</h5> |
|||
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close"> |
|||
<span aria-hidden="true">×</span> |
|||
</button> |
|||
</div> |
|||
<div class="modal-body"> |
|||
<!-- Seleção do tipo de certificado --> |
|||
<div id="selecao-tipo-certificado"> |
|||
<div class="alert alert-info"> |
|||
<i class="fa fa-info-circle"></i> |
|||
{% trans "Selecione o tipo de certificado digital que você possui:" %} |
|||
</div> |
|||
|
|||
<div class="row mb-4"> |
|||
<div class="col-md-6"> |
|||
<div class="card h-100 tipo-certificado-card" data-tipo="A1"> |
|||
<div class="card-body text-center"> |
|||
<i class="fa fa-file-archive-o fa-3x text-primary mb-3"></i> |
|||
<h5 class="card-title">{% trans "Certificado A1" %}</h5> |
|||
<p class="card-text text-muted"> |
|||
{% trans "Arquivo .pfx ou .p12 armazenado no computador" %} |
|||
</p> |
|||
<button type="button" class="btn btn-primary btn-selecionar-tipo" data-tipo="A1"> |
|||
<i class="fa fa-check"></i> {% trans "Selecionar" %} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="col-md-6"> |
|||
<div class="card h-100 tipo-certificado-card" data-tipo="A3"> |
|||
<div class="card-body text-center"> |
|||
<i class="fa fa-usb fa-3x text-success mb-3"></i> |
|||
<h5 class="card-title">{% trans "Certificado A3" %}</h5> |
|||
<p class="card-text text-muted"> |
|||
{% trans "Token USB ou Smartcard" %} |
|||
</p> |
|||
<button type="button" class="btn btn-success btn-selecionar-tipo" data-tipo="A3"> |
|||
<i class="fa fa-check"></i> {% trans "Selecionar" %} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Formulário A1 --> |
|||
<div id="formulario-a1" style="display: none;"> |
|||
<div class="mb-3"> |
|||
<button type="button" class="btn btn-link btn-voltar-selecao"> |
|||
<i class="fa fa-arrow-left"></i> {% trans "Voltar" %} |
|||
</button> |
|||
</div> |
|||
|
|||
<form id="form-assinatura-a1" enctype="multipart/form-data"> |
|||
{% csrf_token %} |
|||
<div class="form-group"> |
|||
<label for="certificado-a1"> |
|||
<i class="fa fa-file"></i> {% trans "Arquivo do Certificado (.pfx ou .p12)" %} |
|||
</label> |
|||
<div class="custom-file"> |
|||
<input type="file" class="custom-file-input" id="certificado-a1" name="certificado" accept=".pfx,.p12" required> |
|||
<label class="custom-file-label" for="certificado-a1" data-browse="{% trans 'Procurar' %}"> |
|||
{% trans "Selecione o arquivo do certificado..." %} |
|||
</label> |
|||
</div> |
|||
<small class="form-text text-muted"> |
|||
{% trans "Selecione o arquivo .pfx ou .p12 do seu certificado A1" %} |
|||
</small> |
|||
</div> |
|||
|
|||
<div class="form-group"> |
|||
<label for="senha-a1"> |
|||
<i class="fa fa-lock"></i> {% trans "Senha do Certificado" %} |
|||
</label> |
|||
<input type="password" class="form-control" id="senha-a1" name="senha" required |
|||
placeholder="{% trans 'Digite a senha do certificado' %}"> |
|||
<small class="form-text text-muted"> |
|||
{% trans "A senha é usada apenas para esta operação e não é armazenada" %} |
|||
</small> |
|||
</div> |
|||
|
|||
<div class="alert alert-warning"> |
|||
<i class="fa fa-exclamation-triangle"></i> |
|||
{% trans "Atenção: A assinatura digital tem validade jurídica. Certifique-se de que o documento está correto antes de assinar." %} |
|||
</div> |
|||
</form> |
|||
</div> |
|||
|
|||
<!-- Formulário A3 --> |
|||
<div id="formulario-a3" style="display: none;"> |
|||
<div class="mb-3"> |
|||
<button type="button" class="btn btn-link btn-voltar-selecao"> |
|||
<i class="fa fa-arrow-left"></i> {% trans "Voltar" %} |
|||
</button> |
|||
</div> |
|||
|
|||
<div id="a3-detectando" class="text-center py-4"> |
|||
<i class="fa fa-spinner fa-spin fa-3x text-primary mb-3"></i> |
|||
<h5>{% trans "Detectando aplicação de assinatura..." %}</h5> |
|||
<p class="text-muted"> |
|||
{% trans "Procurando por aplicações de assinatura A3 instaladas no computador..." %} |
|||
</p> |
|||
</div> |
|||
|
|||
<div id="a3-nao-detectado" style="display: none;"> |
|||
<div class="alert alert-warning"> |
|||
<i class="fa fa-exclamation-triangle"></i> |
|||
<strong>{% trans "Aplicação não detectada" %}</strong> |
|||
</div> |
|||
<p> |
|||
{% trans "Para usar certificado A3, você precisa ter uma aplicação de assinatura instalada e em execução." %} |
|||
</p> |
|||
<h6>{% trans "Opções disponíveis:" %}</h6> |
|||
<ul> |
|||
<li> |
|||
<a href="https://www.serpro.gov.br/links-fixos-superiores/assinador-digital/assinador-serpro" target="_blank"> |
|||
{% trans "Assinador SERPRO" %} |
|||
</a> |
|||
- {% trans "Gratuito, desenvolvido pelo governo brasileiro" %} |
|||
</li> |
|||
<li> |
|||
<a href="https://www.lacunasoftware.com/pt/pki-express" target="_blank"> |
|||
{% trans "Lacuna Web PKI" %} |
|||
</a> |
|||
- {% trans "Solução comercial com versão gratuita limitada" %} |
|||
</li> |
|||
</ul> |
|||
<p class="text-muted"> |
|||
{% trans "Após instalar e iniciar a aplicação, clique em 'Tentar novamente'." %} |
|||
</p> |
|||
<button type="button" class="btn btn-primary btn-tentar-novamente-a3"> |
|||
<i class="fa fa-refresh"></i> {% trans "Tentar novamente" %} |
|||
</button> |
|||
</div> |
|||
|
|||
<div id="a3-detectado" style="display: none;"> |
|||
<div class="alert alert-success"> |
|||
<i class="fa fa-check-circle"></i> |
|||
<strong>{% trans "Aplicação detectada!" %}</strong> |
|||
<span id="a3-app-nome"></span> |
|||
</div> |
|||
|
|||
<div class="form-group"> |
|||
<label for="certificado-a3-select"> |
|||
<i class="fa fa-certificate"></i> {% trans "Selecione o certificado:" %} |
|||
</label> |
|||
<select class="form-control" id="certificado-a3-select"> |
|||
<option value="">{% trans "Carregando certificados..." %}</option> |
|||
</select> |
|||
</div> |
|||
|
|||
<div id="a3-cert-info" class="card mb-3" style="display: none;"> |
|||
<div class="card-body"> |
|||
<h6 class="card-title">{% trans "Informações do Certificado" %}</h6> |
|||
<p class="mb-1"><strong>{% trans "Titular:" %}</strong> <span id="a3-cert-subject"></span></p> |
|||
<p class="mb-1"><strong>{% trans "Emissor:" %}</strong> <span id="a3-cert-issuer"></span></p> |
|||
<p class="mb-0"><strong>{% trans "Validade:" %}</strong> <span id="a3-cert-validade"></span></p> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="alert alert-warning"> |
|||
<i class="fa fa-exclamation-triangle"></i> |
|||
{% trans "Atenção: A assinatura digital tem validade jurídica. Certifique-se de que o documento está correto antes de assinar." %} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Status da assinatura --> |
|||
<div id="status-assinatura" style="display: none;"> |
|||
<div id="status-processando" class="text-center py-4"> |
|||
<i class="fa fa-spinner fa-spin fa-3x text-primary mb-3"></i> |
|||
<h5>{% trans "Assinando documento..." %}</h5> |
|||
<p class="text-muted" id="status-mensagem"> |
|||
{% trans "Aguarde enquanto o documento é assinado digitalmente." %} |
|||
</p> |
|||
<div class="progress mt-3" style="max-width: 400px; margin: 0 auto;"> |
|||
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div id="status-sucesso" style="display: none;"> |
|||
<div class="text-center py-4"> |
|||
<i class="fa fa-check-circle fa-3x text-success mb-3"></i> |
|||
<h5>{% trans "Documento assinado com sucesso!" %}</h5> |
|||
<p id="sucesso-certificado-info"></p> |
|||
</div> |
|||
</div> |
|||
|
|||
<div id="status-erro" style="display: none;"> |
|||
<div class="alert alert-danger"> |
|||
<i class="fa fa-exclamation-circle"></i> |
|||
<strong>{% trans "Erro ao assinar documento" %}</strong> |
|||
<p id="erro-mensagem" class="mb-0 mt-2"></p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="modal-footer"> |
|||
<button type="button" class="btn btn-secondary" data-dismiss="modal" id="btn-fechar-modal"> |
|||
<i class="fa fa-times"></i> {% trans "Fechar" %} |
|||
</button> |
|||
<button type="button" class="btn btn-primary" id="btn-assinar" style="display: none;"> |
|||
<i class="fa fa-pencil"></i> {% trans "Assinar Documento" %} |
|||
</button> |
|||
<a href="#" class="btn btn-success" id="btn-baixar-assinado" style="display: none;" target="_blank"> |
|||
<i class="fa fa-download"></i> {% trans "Baixar PDF Assinado" %} |
|||
</a> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Script do Modal --> |
|||
<script> |
|||
document.addEventListener('DOMContentLoaded', function() { |
|||
// Função auxiliar para obter CSRF token |
|||
function getCSRFToken() { |
|||
// Primeiro tenta do formulário |
|||
var tokenInput = document.querySelector('#form-assinatura-a1 [name=csrfmiddlewaretoken]'); |
|||
if (tokenInput) { |
|||
return tokenInput.value; |
|||
} |
|||
// Fallback: busca do cookie |
|||
var name = 'csrftoken'; |
|||
var cookieValue = null; |
|||
if (document.cookie && document.cookie !== '') { |
|||
var cookies = document.cookie.split(';'); |
|||
for (var i = 0; i < cookies.length; i++) { |
|||
var cookie = cookies[i].trim(); |
|||
if (cookie.substring(0, name.length + 1) === (name + '=')) { |
|||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
return cookieValue; |
|||
} |
|||
|
|||
var AssinaturaModal = { |
|||
tipoSelecionado: null, |
|||
materiaId: null, |
|||
a3AppInfo: null, |
|||
|
|||
init: function(materiaId) { |
|||
this.materiaId = materiaId; |
|||
this.bindEvents(); |
|||
}, |
|||
|
|||
bindEvents: function() { |
|||
var self = this; |
|||
|
|||
// Seleção de tipo de certificado |
|||
document.querySelectorAll('.btn-selecionar-tipo').forEach(function(btn) { |
|||
btn.addEventListener('click', function() { |
|||
self.selecionarTipo(this.dataset.tipo); |
|||
}); |
|||
}); |
|||
|
|||
// Voltar para seleção |
|||
document.querySelectorAll('.btn-voltar-selecao').forEach(function(btn) { |
|||
btn.addEventListener('click', function() { |
|||
self.voltarSelecao(); |
|||
}); |
|||
}); |
|||
|
|||
// Botão assinar |
|||
document.getElementById('btn-assinar').addEventListener('click', function() { |
|||
self.assinar(); |
|||
}); |
|||
|
|||
// Tentar novamente A3 |
|||
var btnTentarNovamente = document.querySelector('.btn-tentar-novamente-a3'); |
|||
if (btnTentarNovamente) { |
|||
btnTentarNovamente.addEventListener('click', function() { |
|||
self.detectarA3(); |
|||
}); |
|||
} |
|||
|
|||
// Atualizar label do arquivo |
|||
document.getElementById('certificado-a1').addEventListener('change', function() { |
|||
var fileName = this.files[0] ? this.files[0].name : 'Selecione o arquivo do certificado...'; |
|||
this.nextElementSibling.textContent = fileName; |
|||
}); |
|||
|
|||
// Reset ao fechar modal |
|||
$('#assinaturaModal').on('hidden.bs.modal', function() { |
|||
self.resetModal(); |
|||
}); |
|||
}, |
|||
|
|||
selecionarTipo: function(tipo) { |
|||
this.tipoSelecionado = tipo; |
|||
|
|||
document.getElementById('selecao-tipo-certificado').style.display = 'none'; |
|||
|
|||
if (tipo === 'A1') { |
|||
document.getElementById('formulario-a1').style.display = 'block'; |
|||
document.getElementById('btn-assinar').style.display = 'inline-block'; |
|||
} else if (tipo === 'A3') { |
|||
document.getElementById('formulario-a3').style.display = 'block'; |
|||
this.detectarA3(); |
|||
} |
|||
}, |
|||
|
|||
voltarSelecao: function() { |
|||
this.tipoSelecionado = null; |
|||
document.getElementById('formulario-a1').style.display = 'none'; |
|||
document.getElementById('formulario-a3').style.display = 'none'; |
|||
document.getElementById('btn-assinar').style.display = 'none'; |
|||
document.getElementById('selecao-tipo-certificado').style.display = 'block'; |
|||
}, |
|||
|
|||
detectarA3: function() { |
|||
var self = this; |
|||
|
|||
document.getElementById('a3-detectando').style.display = 'block'; |
|||
document.getElementById('a3-nao-detectado').style.display = 'none'; |
|||
document.getElementById('a3-detectado').style.display = 'none'; |
|||
|
|||
// Tentar detectar aplicações conhecidas |
|||
var portasParaTestar = [ |
|||
{ nome: 'Assinador SERPRO', porta: 10443, url: 'https://localhost:10443/certificados' }, |
|||
{ nome: 'Web PKI Local', porta: 5000, url: 'http://localhost:5000/api/certificates' } |
|||
]; |
|||
|
|||
var tentativas = 0; |
|||
var detectado = false; |
|||
|
|||
portasParaTestar.forEach(function(app) { |
|||
fetch(app.url, { method: 'GET', mode: 'no-cors' }) |
|||
.then(function() { |
|||
if (!detectado) { |
|||
detectado = true; |
|||
self.a3AppInfo = app; |
|||
self.mostrarA3Detectado(app); |
|||
} |
|||
}) |
|||
.catch(function() { |
|||
tentativas++; |
|||
if (tentativas >= portasParaTestar.length && !detectado) { |
|||
self.mostrarA3NaoDetectado(); |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
// Timeout para mostrar não detectado |
|||
setTimeout(function() { |
|||
if (!detectado) { |
|||
self.mostrarA3NaoDetectado(); |
|||
} |
|||
}, 5000); |
|||
}, |
|||
|
|||
mostrarA3Detectado: function(app) { |
|||
document.getElementById('a3-detectando').style.display = 'none'; |
|||
document.getElementById('a3-detectado').style.display = 'block'; |
|||
document.getElementById('a3-app-nome').textContent = ' - ' + app.nome; |
|||
document.getElementById('btn-assinar').style.display = 'inline-block'; |
|||
|
|||
// Aqui carregaria os certificados da aplicação |
|||
// Por enquanto, mostra mensagem informativa |
|||
var select = document.getElementById('certificado-a3-select'); |
|||
select.innerHTML = '<option value="">A integração com ' + app.nome + ' requer configuração adicional</option>'; |
|||
}, |
|||
|
|||
mostrarA3NaoDetectado: function() { |
|||
document.getElementById('a3-detectando').style.display = 'none'; |
|||
document.getElementById('a3-nao-detectado').style.display = 'block'; |
|||
}, |
|||
|
|||
assinar: function() { |
|||
if (this.tipoSelecionado === 'A1') { |
|||
this.assinarA1(); |
|||
} else if (this.tipoSelecionado === 'A3') { |
|||
this.assinarA3(); |
|||
} |
|||
}, |
|||
|
|||
assinarA1: function() { |
|||
var self = this; |
|||
var form = document.getElementById('form-assinatura-a1'); |
|||
var certificado = document.getElementById('certificado-a1').files[0]; |
|||
var senha = document.getElementById('senha-a1').value; |
|||
|
|||
if (!certificado) { |
|||
alert('Selecione o arquivo do certificado.'); |
|||
return; |
|||
} |
|||
|
|||
if (!senha) { |
|||
alert('Digite a senha do certificado.'); |
|||
return; |
|||
} |
|||
|
|||
this.mostrarStatus('processando'); |
|||
this.atualizarProgresso(10, 'Enviando certificado...'); |
|||
|
|||
var formData = new FormData(); |
|||
formData.append('certificado', certificado); |
|||
formData.append('senha', senha); |
|||
|
|||
fetch('/materia/' + this.materiaId + '/assinar/a1/', { |
|||
method: 'POST', |
|||
body: formData, |
|||
headers: { |
|||
'X-CSRFToken': getCSRFToken() |
|||
} |
|||
}) |
|||
.then(function(response) { |
|||
self.atualizarProgresso(60, 'Processando assinatura...'); |
|||
return response.json(); |
|||
}) |
|||
.then(function(data) { |
|||
self.atualizarProgresso(100, 'Finalizando...'); |
|||
|
|||
if (data.success) { |
|||
self.mostrarSucesso(data); |
|||
} else { |
|||
self.mostrarErro(data.error); |
|||
} |
|||
}) |
|||
.catch(function(error) { |
|||
self.mostrarErro('Erro de comunicação: ' + error.message); |
|||
}); |
|||
}, |
|||
|
|||
assinarA3: function() { |
|||
// A assinatura A3 requer integração com aplicação local |
|||
this.mostrarErro('A assinatura A3 requer uma aplicação local em execução. Por favor, use o Assinador SERPRO ou similar.'); |
|||
}, |
|||
|
|||
mostrarStatus: function(tipo) { |
|||
document.getElementById('formulario-a1').style.display = 'none'; |
|||
document.getElementById('formulario-a3').style.display = 'none'; |
|||
document.getElementById('selecao-tipo-certificado').style.display = 'none'; |
|||
document.getElementById('btn-assinar').style.display = 'none'; |
|||
|
|||
document.getElementById('status-assinatura').style.display = 'block'; |
|||
document.getElementById('status-processando').style.display = tipo === 'processando' ? 'block' : 'none'; |
|||
document.getElementById('status-sucesso').style.display = tipo === 'sucesso' ? 'block' : 'none'; |
|||
document.getElementById('status-erro').style.display = tipo === 'erro' ? 'block' : 'none'; |
|||
}, |
|||
|
|||
atualizarProgresso: function(porcentagem, mensagem) { |
|||
var progressBar = document.querySelector('#status-processando .progress-bar'); |
|||
progressBar.style.width = porcentagem + '%'; |
|||
if (mensagem) { |
|||
document.getElementById('status-mensagem').textContent = mensagem; |
|||
} |
|||
}, |
|||
|
|||
mostrarSucesso: function(data) { |
|||
this.mostrarStatus('sucesso'); |
|||
|
|||
var info = ''; |
|||
if (data.certificado) { |
|||
info = 'Assinado por: ' + data.certificado.nome + '<br>Válido até: ' + data.certificado.validade; |
|||
} |
|||
document.getElementById('sucesso-certificado-info').innerHTML = info; |
|||
|
|||
var btnBaixar = document.getElementById('btn-baixar-assinado'); |
|||
btnBaixar.href = '/materia/' + this.materiaId + '/pdf-assinado/'; |
|||
btnBaixar.style.display = 'inline-block'; |
|||
|
|||
document.getElementById('btn-fechar-modal').textContent = 'Fechar'; |
|||
}, |
|||
|
|||
mostrarErro: function(mensagem) { |
|||
this.mostrarStatus('erro'); |
|||
document.getElementById('erro-mensagem').textContent = mensagem; |
|||
}, |
|||
|
|||
resetModal: function() { |
|||
this.tipoSelecionado = null; |
|||
this.a3AppInfo = null; |
|||
|
|||
document.getElementById('selecao-tipo-certificado').style.display = 'block'; |
|||
document.getElementById('formulario-a1').style.display = 'none'; |
|||
document.getElementById('formulario-a3').style.display = 'none'; |
|||
document.getElementById('status-assinatura').style.display = 'none'; |
|||
document.getElementById('btn-assinar').style.display = 'none'; |
|||
document.getElementById('btn-baixar-assinado').style.display = 'none'; |
|||
|
|||
// Limpar formulário A1 |
|||
document.getElementById('form-assinatura-a1').reset(); |
|||
document.querySelector('#certificado-a1 + label').textContent = 'Selecione o arquivo do certificado...'; |
|||
|
|||
// Reset A3 |
|||
document.getElementById('a3-detectando').style.display = 'block'; |
|||
document.getElementById('a3-nao-detectado').style.display = 'none'; |
|||
document.getElementById('a3-detectado').style.display = 'none'; |
|||
|
|||
// Reset status |
|||
document.getElementById('status-processando').style.display = 'block'; |
|||
document.getElementById('status-sucesso').style.display = 'none'; |
|||
document.getElementById('status-erro').style.display = 'none'; |
|||
document.querySelector('#status-processando .progress-bar').style.width = '0%'; |
|||
} |
|||
}; |
|||
|
|||
// Expor globalmente |
|||
window.AssinaturaModal = AssinaturaModal; |
|||
}); |
|||
</script> |
|||
|
|||
<style> |
|||
.tipo-certificado-card { |
|||
cursor: pointer; |
|||
transition: all 0.3s ease; |
|||
border: 2px solid transparent; |
|||
} |
|||
|
|||
.tipo-certificado-card:hover { |
|||
border-color: #007bff; |
|||
box-shadow: 0 4px 12px rgba(0,0,0,0.15); |
|||
} |
|||
|
|||
.tipo-certificado-card.selected { |
|||
border-color: #28a745; |
|||
background-color: #f8fff8; |
|||
} |
|||
|
|||
#assinaturaModal .modal-header { |
|||
border-bottom: none; |
|||
} |
|||
|
|||
#assinaturaModal .modal-footer { |
|||
border-top: none; |
|||
} |
|||
|
|||
#assinaturaModal .custom-file-label::after { |
|||
content: "Procurar"; |
|||
} |
|||
|
|||
#status-processando .progress { |
|||
height: 8px; |
|||
} |
|||
|
|||
#a3-cert-info { |
|||
background-color: #f8f9fa; |
|||
} |
|||
</style> |
|||
Loading…
Reference in new issue