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