mirror of https://github.com/interlegis/sapl.git
11 changed files with 1285 additions and 25 deletions
@ -1,9 +1,19 @@ |
|||
FROM python:3.6 |
|||
ENV PYTHONUNBUFFERED 1 |
|||
FROM python:3.12-slim-bookworm |
|||
|
|||
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 \ |
|||
DEBIAN_FRONTEND=noninteractive |
|||
|
|||
WORKDIR /sapl-dev |
|||
|
|||
RUN apt-get update && apt-get install -y --no-install-recommends \ |
|||
build-essential git pkg-config \ |
|||
libpq-dev libxml2-dev libjpeg-dev libssl-dev libffi-dev libxslt1-dev \ |
|||
libcairo2-dev libpango1.0-dev libgdk-pixbuf-2.0-dev libharfbuzz-dev \ |
|||
libfreetype6-dev zlib1g-dev graphviz libmagic1 \ |
|||
&& rm -rf /var/lib/apt/lists/* |
|||
|
|||
COPY ./requirements ./requirements/ |
|||
RUN apt update && \ |
|||
apt -y install graphviz-dev && \ |
|||
pip install --upgrade pip && \ |
|||
RUN pip install --upgrade pip && \ |
|||
pip install -r ./requirements/dev-requirements.txt |
|||
|
|||
EXPOSE 8000 |
|||
|
|||
@ -0,0 +1,277 @@ |
|||
# Fluxo de Proposição do Vereador no SAPL |
|||
|
|||
Este documento descreve o processo completo de criação, envio e tramitação de uma proposição por um Vereador no Sistema de Apoio ao Processo Legislativo (SAPL). |
|||
|
|||
## Visão Geral |
|||
|
|||
O fluxo de proposição envolve três atores principais: |
|||
|
|||
| Ator | Papel | |
|||
|------|-------| |
|||
| **Vereador/Autor** | Cria e envia a proposição | |
|||
| **Protocolo/Mesa** | Recebe, valida e incorpora a proposição | |
|||
| **Sistema** | Gerencia estados, gera hash e registra histórico | |
|||
|
|||
## Diagrama de Estados |
|||
|
|||
``` |
|||
┌─────────────────┐ |
|||
│ ELABORAÇÃO │ ← Vereador cria/edita proposição |
|||
└────────┬────────┘ |
|||
│ clica "Enviar" |
|||
▼ |
|||
┌─────────────────┐ |
|||
│ ENVIADA │ ← Aguardando Protocolo |
|||
└────────┬────────┘ |
|||
│ Protocolo digita código hash |
|||
▼ |
|||
┌─────────────────┐ |
|||
│ CONFIRMAÇÃO │ ← Protocolo revisa |
|||
└────────┬────────┘ |
|||
│ |
|||
┌────┴───────┐ |
|||
▼ ▼ |
|||
┌────────┐ ┌──────────┐ |
|||
│DEVOLVER│ │INCORPORAR│ |
|||
└───┬────┘ └────┬─────┘ |
|||
│ │ |
|||
▼ ▼ |
|||
┌──────────┐ ┌───────────────────┐ |
|||
│ELABORAÇÃO│ │MATÉRIA LEGISLATIVA│ |
|||
│(corrigir)│ │ ou DOCUMENTO │ |
|||
└──────────┘ └───────────────────┘ |
|||
``` |
|||
|
|||
## Etapas Detalhadas |
|||
|
|||
### 1. Criação da Proposição (Vereador) |
|||
|
|||
**Acesso:** `/proposicao/create` |
|||
|
|||
O Vereador cria uma nova proposição preenchendo: |
|||
|
|||
| Campo | Descrição | |
|||
|-------|-----------| |
|||
| Tipo | Tipo de proposição (Projeto de Lei, Moção, Requerimento, etc.) | |
|||
| Descrição/Ementa | Texto descritivo da proposição | |
|||
| Texto | Pode ser: arquivo digital (upload), Texto Articulado ou OnlyOffice | |
|||
|
|||
**Opções de criação de texto:** |
|||
|
|||
- **Arquivo Digital (D):** Upload de arquivo DOCX/DOC/PDF |
|||
- **Texto Articulado (T):** Editor estruturado de normas (XML/LexML) |
|||
- **OnlyOffice (O):** Editor online de documentos |
|||
|
|||
### 2. Envio da Proposição (Vereador) |
|||
|
|||
**Acesso:** `/proposicao/<id>` → botão "Enviar" |
|||
|
|||
**Código fonte:** `sapl/materia/views.py:912-970` |
|||
|
|||
#### O que acontece ao clicar "Enviar": |
|||
|
|||
1. **Validações:** |
|||
- Proposição não pode ter sido enviada anteriormente |
|||
- Deve possuir pelo menos um tipo de texto associado |
|||
|
|||
2. **Processamento:** |
|||
- Se houver Texto Articulado, ele é **bloqueado para edição** |
|||
- Data de envio é registrada (`data_envio = timestamp atual`) |
|||
- Hash MD5 é gerado para verificação de integridade |
|||
|
|||
3. **Registro:** |
|||
- Histórico é criado com status `E` (Enviada) |
|||
- IP e usuário são registrados |
|||
|
|||
4. **Resposta:** |
|||
- Mensagem de sucesso com número provável da futura matéria |
|||
- Recibo disponibilizado com código de barras/QR |
|||
|
|||
#### Código do hash gerado: |
|||
|
|||
``` |
|||
Formato: P<hash_md5>/<proposicao_id> |
|||
Exemplo: P8f14e45fceea167a5a36dedd4bea2543/26 |
|||
``` |
|||
|
|||
### 3. Geração do Recibo |
|||
|
|||
**Acesso:** `/proposicao/recibo/<id>` |
|||
|
|||
**Código fonte:** `sapl/materia/views.py:1224-1279` |
|||
|
|||
O sistema gera um recibo contendo: |
|||
|
|||
- Código de barras com o hash |
|||
- QR Code para leitura rápida |
|||
- Informações da proposição (tipo, autor, data) |
|||
|
|||
> **Importante:** Este recibo é usado pelo Protocolo para recuperar a proposição. Não constitui assinatura digital. |
|||
|
|||
### 4. Recebimento pelo Protocolo |
|||
|
|||
**Acesso:** `/proposicao/receber/` |
|||
|
|||
**Código fonte:** `sapl/materia/views.py:617-667` |
|||
|
|||
**Permissão necessária:** `materia.detail_proposicao_enviada` |
|||
|
|||
#### Processo: |
|||
|
|||
1. Operador do Protocolo acessa a área de recebimento |
|||
2. Digita ou escaneia o código hash do recibo |
|||
3. Sistema valida: |
|||
- Proposição existe e está enviada |
|||
- Hash confere (documento não foi alterado) |
|||
4. Redireciona para tela de confirmação |
|||
|
|||
### 5. Confirmação/Incorporação (Protocolo) |
|||
|
|||
**Acesso:** `/proposicao/confirmar/<hash>/<id>` |
|||
|
|||
**Código fonte:** `sapl/materia/views.py:701-800` |
|||
|
|||
O Protocolo visualiza os dados da proposição e tem duas opções: |
|||
|
|||
#### Opção A: Incorporar |
|||
|
|||
Campos do formulário: |
|||
|
|||
| Campo | Obrigatório | Descrição | |
|||
|-------|-------------|-----------| |
|||
| Número de Matéria | Não | Número sugerido ou automático | |
|||
| Regime de Tramitação | Sim* | Como a matéria tramitará | |
|||
| Matéria de Vínculo | Condicional | Obrigatório para documentos | |
|||
| Gerar Protocolo | Configurável | Se deve criar protocolo administrativo | |
|||
| Número de Páginas | Não | Para o protocolo | |
|||
|
|||
*Obrigatório apenas para Matérias Legislativas |
|||
|
|||
**Resultado da incorporação:** |
|||
|
|||
1. Proposição marcada como recebida (`data_recebimento = timestamp`) |
|||
2. Criação de **MateriaLegislativa** ou **DocumentoAcessorio** |
|||
3. Registro de **Autoria** vinculando o autor |
|||
4. Opcionalmente, criação de **Protocolo** administrativo |
|||
5. Histórico atualizado com status `R` (Recebida) |
|||
|
|||
#### Opção B: Devolver |
|||
|
|||
**Código fonte:** `sapl/materia/forms.py:2244-2330` |
|||
|
|||
Campos: |
|||
|
|||
| Campo | Obrigatório | Descrição | |
|||
|-------|-------------|-----------| |
|||
| Justificativa | Sim | Motivo da devolução | |
|||
| Observação | Não | Informações adicionais | |
|||
|
|||
**Resultado da devolução:** |
|||
|
|||
1. Proposição marcada como devolvida (`data_devolucao = timestamp`) |
|||
2. Datas de envio e recebimento são limpas |
|||
3. Texto Articulado é **desbloqueado para edição** |
|||
4. Histórico atualizado com status `D` (Devolvida) |
|||
5. Vereador pode corrigir e reenviar |
|||
|
|||
### 6. Retorno pelo Autor (Opcional) |
|||
|
|||
**Acesso:** `/proposicao/<id>` → botão "Retornar" |
|||
|
|||
**Código fonte:** `sapl/materia/views.py:971-998` |
|||
|
|||
Se a proposição foi enviada mas **ainda não foi recebida** pelo Protocolo, o Vereador pode retorná-la: |
|||
|
|||
- Data de envio é limpa |
|||
- Texto Articulado é desbloqueado |
|||
- Histórico registra status `T` (Retornada) |
|||
|
|||
## Modelo de Dados |
|||
|
|||
### Proposição (`sapl/materia/models.py:754-1050`) |
|||
|
|||
```python |
|||
class Proposicao(models.Model): |
|||
autor # Quem propôs |
|||
tipo # Tipo de proposição |
|||
descricao # Ementa/descrição |
|||
texto_original # Arquivo digital (se houver) |
|||
texto_articulado # Texto estruturado (se houver) |
|||
|
|||
# Controle de fluxo |
|||
data_envio # Quando foi enviada |
|||
data_recebimento # Quando foi recebida/incorporada |
|||
data_devolucao # Quando foi devolvida |
|||
|
|||
# Usuários envolvidos |
|||
usuario_envio # Quem enviou |
|||
usuario_recebimento # Quem recebeu |
|||
usuario_devolucao # Quem devolveu |
|||
|
|||
# Resultado |
|||
conteudo_gerado_related # Matéria ou Documento criado |
|||
materia_de_vinculo # Matéria relacionada |
|||
|
|||
# Segurança |
|||
hash_code # MD5 para verificação |
|||
cancelado # Se foi cancelada |
|||
``` |
|||
|
|||
### Histórico (`sapl/materia/models.py`) |
|||
|
|||
```python |
|||
class HistoricoProposicao(models.Model): |
|||
proposicao # Referência à proposição |
|||
status # E=Enviada, R=Recebida, T=Retornada, D=Devolvida |
|||
data_hora # Timestamp da ação |
|||
user # Usuário que executou |
|||
ip # IP do usuário |
|||
observacao # Justificativa (em devoluções) |
|||
``` |
|||
|
|||
## Permissões |
|||
|
|||
| Permissão | Descrição | |
|||
|-----------|-----------| |
|||
| `detail_proposicao_enviada` | Acessar proposições enviadas (Protocolo) | |
|||
| `detail_proposicao_devolvida` | Acessar proposições devolvidas | |
|||
| `detail_proposicao_incorporada` | Acessar proposições incorporadas | |
|||
|
|||
## Sobre Assinatura Digital |
|||
|
|||
> **Importante:** O SAPL atualmente **não possui integração com assinatura digital** (PKI/ICP-Brasil). |
|||
|
|||
O que existe para segurança: |
|||
|
|||
| Mecanismo | Função | |
|||
|-----------|--------| |
|||
| Hash MD5 | Verificar integridade do documento | |
|||
| Registro de IP | Rastreabilidade de ações | |
|||
| Registro de Usuário | Identificação do responsável | |
|||
| Histórico completo | Auditoria de mudanças de estado | |
|||
| Bloqueio de edição | Impedir alterações após envio | |
|||
|
|||
O hash gerado serve para verificar que o documento não foi alterado entre o envio e o recebimento, mas **não constitui assinatura eletrônica** no sentido jurídico. |
|||
|
|||
## Arquivos do Código-Fonte |
|||
|
|||
| Arquivo | Responsabilidade | |
|||
|---------|------------------| |
|||
| `sapl/materia/models.py` | Modelos Proposicao, HistoricoProposicao, TipoProposicao | |
|||
| `sapl/materia/views.py` | Views de envio, recebimento, confirmação | |
|||
| `sapl/materia/forms.py` | Formulários de proposição | |
|||
| `sapl/materia/onlyoffice_views.py` | Integração com editor OnlyOffice | |
|||
| `sapl/materia/urls.py` | Rotas e URLs | |
|||
| `sapl/templates/materia/proposicao_*.html` | Templates de apresentação | |
|||
|
|||
## Resumo do Fluxo |
|||
|
|||
1. **Vereador cria** proposição com texto (upload, Texto Articulado ou OnlyOffice) |
|||
2. **Vereador envia** → Sistema gera hash e bloqueia edição |
|||
3. **Sistema gera recibo** com código de barras |
|||
4. **Protocolo recebe** digitando/escaneando o código hash |
|||
5. **Protocolo valida** integridade (hash confere?) |
|||
6. **Protocolo decide:** |
|||
- **Incorporar** → Cria Matéria Legislativa ou Documento |
|||
- **Devolver** → Volta para Vereador corrigir |
|||
7. **Se incorporado** → Matéria entra em tramitação normal |
|||
@ -0,0 +1,327 @@ |
|||
/** |
|||
* Estilos para o Modal de Assinatura Digital |
|||
* SAPL - Sistema de Apoio ao Processo Legislativo |
|||
*/ |
|||
|
|||
/* Modal de Assinatura */ |
|||
#assinaturaModal .modal-header { |
|||
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%); |
|||
border-bottom: none; |
|||
border-radius: 0.3rem 0.3rem 0 0; |
|||
} |
|||
|
|||
#assinaturaModal .modal-header .modal-title { |
|||
font-weight: 600; |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 0.5rem; |
|||
} |
|||
|
|||
#assinaturaModal .modal-header .close { |
|||
color: white; |
|||
opacity: 0.8; |
|||
text-shadow: none; |
|||
} |
|||
|
|||
#assinaturaModal .modal-header .close:hover { |
|||
opacity: 1; |
|||
} |
|||
|
|||
#assinaturaModal .modal-footer { |
|||
border-top: 1px solid #e9ecef; |
|||
background-color: #f8f9fa; |
|||
} |
|||
|
|||
/* Cards de Tipo de Certificado */ |
|||
.tipo-certificado-card { |
|||
cursor: pointer; |
|||
transition: all 0.3s ease; |
|||
border: 2px solid #dee2e6; |
|||
border-radius: 0.5rem; |
|||
} |
|||
|
|||
.tipo-certificado-card:hover { |
|||
border-color: #007bff; |
|||
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.2); |
|||
transform: translateY(-2px); |
|||
} |
|||
|
|||
.tipo-certificado-card.selected { |
|||
border-color: #28a745; |
|||
background-color: #f8fff8; |
|||
} |
|||
|
|||
.tipo-certificado-card .card-body { |
|||
padding: 2rem 1.5rem; |
|||
} |
|||
|
|||
.tipo-certificado-card .fa { |
|||
transition: transform 0.3s ease; |
|||
} |
|||
|
|||
.tipo-certificado-card:hover .fa { |
|||
transform: scale(1.1); |
|||
} |
|||
|
|||
.tipo-certificado-card .card-title { |
|||
font-weight: 600; |
|||
margin-bottom: 0.75rem; |
|||
} |
|||
|
|||
.tipo-certificado-card .card-text { |
|||
font-size: 0.9rem; |
|||
min-height: 2.5rem; |
|||
} |
|||
|
|||
/* Formulário A1 */ |
|||
#formulario-a1 .form-group label { |
|||
font-weight: 600; |
|||
color: #495057; |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 0.5rem; |
|||
} |
|||
|
|||
#formulario-a1 .custom-file-label { |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
white-space: nowrap; |
|||
} |
|||
|
|||
#formulario-a1 .custom-file-label::after { |
|||
content: "Procurar"; |
|||
background-color: #007bff; |
|||
color: white; |
|||
border-left: 1px solid #007bff; |
|||
} |
|||
|
|||
#formulario-a1 .custom-file-input:focus ~ .custom-file-label { |
|||
border-color: #80bdff; |
|||
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); |
|||
} |
|||
|
|||
/* Formulário A3 */ |
|||
#a3-detectando { |
|||
padding: 2rem; |
|||
} |
|||
|
|||
#a3-detectando .fa-spinner { |
|||
color: #007bff; |
|||
} |
|||
|
|||
#a3-nao-detectado ul { |
|||
margin-top: 1rem; |
|||
padding-left: 1.5rem; |
|||
} |
|||
|
|||
#a3-nao-detectado ul li { |
|||
margin-bottom: 0.5rem; |
|||
} |
|||
|
|||
#a3-nao-detectado ul a { |
|||
font-weight: 600; |
|||
} |
|||
|
|||
#a3-cert-info { |
|||
background-color: #f8f9fa; |
|||
border: 1px solid #e9ecef; |
|||
} |
|||
|
|||
#a3-cert-info .card-title { |
|||
font-size: 0.9rem; |
|||
color: #6c757d; |
|||
text-transform: uppercase; |
|||
letter-spacing: 0.5px; |
|||
} |
|||
|
|||
/* Status da Assinatura */ |
|||
#status-assinatura { |
|||
min-height: 200px; |
|||
} |
|||
|
|||
#status-processando { |
|||
padding: 2rem; |
|||
} |
|||
|
|||
#status-processando .fa-spinner { |
|||
animation: fa-spin 1s infinite linear; |
|||
} |
|||
|
|||
#status-processando .progress { |
|||
height: 8px; |
|||
border-radius: 4px; |
|||
overflow: hidden; |
|||
background-color: #e9ecef; |
|||
} |
|||
|
|||
#status-processando .progress-bar { |
|||
background: linear-gradient(90deg, #007bff 0%, #00d4ff 100%); |
|||
transition: width 0.5s ease; |
|||
} |
|||
|
|||
#status-sucesso { |
|||
padding: 2rem; |
|||
} |
|||
|
|||
#status-sucesso .fa-check-circle { |
|||
color: #28a745; |
|||
} |
|||
|
|||
#status-sucesso h5 { |
|||
color: #28a745; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
#status-erro { |
|||
padding: 1rem; |
|||
} |
|||
|
|||
#status-erro .alert { |
|||
margin-bottom: 0; |
|||
} |
|||
|
|||
/* Alertas customizados */ |
|||
#assinaturaModal .alert-info { |
|||
background-color: #e7f3ff; |
|||
border-color: #b8daff; |
|||
color: #004085; |
|||
} |
|||
|
|||
#assinaturaModal .alert-warning { |
|||
background-color: #fff3cd; |
|||
border-color: #ffeeba; |
|||
color: #856404; |
|||
} |
|||
|
|||
#assinaturaModal .alert-success { |
|||
background-color: #d4edda; |
|||
border-color: #c3e6cb; |
|||
color: #155724; |
|||
} |
|||
|
|||
#assinaturaModal .alert-danger { |
|||
background-color: #f8d7da; |
|||
border-color: #f5c6cb; |
|||
color: #721c24; |
|||
} |
|||
|
|||
/* Botões */ |
|||
#assinaturaModal .btn-voltar-selecao { |
|||
padding: 0; |
|||
font-size: 0.9rem; |
|||
color: #6c757d; |
|||
} |
|||
|
|||
#assinaturaModal .btn-voltar-selecao:hover { |
|||
color: #007bff; |
|||
text-decoration: none; |
|||
} |
|||
|
|||
#assinaturaModal .btn-assinar { |
|||
min-width: 160px; |
|||
} |
|||
|
|||
/* Card de Assinatura no Detail */ |
|||
.card.border-success .card-header { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 0.5rem; |
|||
} |
|||
|
|||
.card.border-success .badge-success { |
|||
background-color: #28a745; |
|||
font-size: 0.85rem; |
|||
padding: 0.35em 0.65em; |
|||
} |
|||
|
|||
/* Responsividade */ |
|||
@media (max-width: 768px) { |
|||
.tipo-certificado-card .card-body { |
|||
padding: 1.5rem 1rem; |
|||
} |
|||
|
|||
.tipo-certificado-card .fa { |
|||
font-size: 2rem !important; |
|||
} |
|||
|
|||
#assinaturaModal .modal-body { |
|||
padding: 1rem; |
|||
} |
|||
|
|||
#assinaturaModal .row.mb-4 > .col-md-6 { |
|||
margin-bottom: 1rem; |
|||
} |
|||
} |
|||
|
|||
/* Animação de entrada */ |
|||
@keyframes fadeInUp { |
|||
from { |
|||
opacity: 0; |
|||
transform: translateY(20px); |
|||
} |
|||
to { |
|||
opacity: 1; |
|||
transform: translateY(0); |
|||
} |
|||
} |
|||
|
|||
#assinaturaModal.show .modal-content { |
|||
animation: fadeInUp 0.3s ease; |
|||
} |
|||
|
|||
/* Estados de loading */ |
|||
.btn-assinar.loading { |
|||
pointer-events: none; |
|||
opacity: 0.7; |
|||
} |
|||
|
|||
.btn-assinar.loading::after { |
|||
content: ''; |
|||
display: inline-block; |
|||
width: 1rem; |
|||
height: 1rem; |
|||
margin-left: 0.5rem; |
|||
border: 2px solid transparent; |
|||
border-top-color: currentColor; |
|||
border-radius: 50%; |
|||
animation: fa-spin 0.75s linear infinite; |
|||
} |
|||
|
|||
/* Tooltip para informações adicionais */ |
|||
.info-tooltip { |
|||
display: inline-flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
width: 18px; |
|||
height: 18px; |
|||
border-radius: 50%; |
|||
background-color: #6c757d; |
|||
color: white; |
|||
font-size: 0.7rem; |
|||
cursor: help; |
|||
margin-left: 0.25rem; |
|||
} |
|||
|
|||
.info-tooltip:hover { |
|||
background-color: #007bff; |
|||
} |
|||
|
|||
/* Estilo para certificados expirados */ |
|||
.certificado-expirado { |
|||
color: #dc3545; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.certificado-valido { |
|||
color: #28a745; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
/* Estilo para lista de certificados A3 */ |
|||
#certificado-a3-select { |
|||
font-size: 0.95rem; |
|||
} |
|||
|
|||
#certificado-a3-select option { |
|||
padding: 0.5rem; |
|||
} |
|||
@ -0,0 +1,320 @@ |
|||
/** |
|||
* Módulo JavaScript para Assinatura Digital com Certificado A3 |
|||
* Gerencia a comunicação com aplicações de assinatura local (token USB/smartcard) |
|||
*/ |
|||
|
|||
(function(global) { |
|||
'use strict'; |
|||
|
|||
var AssinaturaA3 = { |
|||
/** |
|||
* Configurações das aplicações suportadas |
|||
*/ |
|||
aplicacoes: { |
|||
serpro: { |
|||
nome: 'Assinador SERPRO', |
|||
porta: 10443, |
|||
protocolo: 'https', |
|||
endpoints: { |
|||
listarCertificados: '/certificados', |
|||
assinar: '/assinar', |
|||
verificar: '/verificar' |
|||
} |
|||
}, |
|||
webpki: { |
|||
nome: 'Lacuna Web PKI', |
|||
porta: 5000, |
|||
protocolo: 'http', |
|||
endpoints: { |
|||
listarCertificados: '/api/certificates', |
|||
assinar: '/api/sign', |
|||
verificar: '/api/verify' |
|||
} |
|||
} |
|||
}, |
|||
|
|||
/** |
|||
* Aplicação detectada |
|||
*/ |
|||
appDetectada: null, |
|||
|
|||
/** |
|||
* Detecta qual aplicação de assinatura está rodando |
|||
* @returns {Promise} |
|||
*/ |
|||
detectar: function() { |
|||
var self = this; |
|||
var apps = Object.keys(this.aplicacoes); |
|||
|
|||
return new Promise(function(resolve, reject) { |
|||
var tentativas = 0; |
|||
|
|||
apps.forEach(function(appKey) { |
|||
var app = self.aplicacoes[appKey]; |
|||
var url = app.protocolo + '://localhost:' + app.porta + app.endpoints.listarCertificados; |
|||
|
|||
// Usando timeout com AbortController
|
|||
var controller = new AbortController(); |
|||
var timeoutId = setTimeout(function() { |
|||
controller.abort(); |
|||
}, 3000); |
|||
|
|||
fetch(url, { |
|||
method: 'GET', |
|||
mode: 'cors', |
|||
credentials: 'omit', |
|||
signal: controller.signal |
|||
}) |
|||
.then(function(response) { |
|||
clearTimeout(timeoutId); |
|||
if (response.ok) { |
|||
self.appDetectada = { |
|||
key: appKey, |
|||
config: app |
|||
}; |
|||
resolve({ |
|||
success: true, |
|||
app: app.nome, |
|||
key: appKey |
|||
}); |
|||
} else { |
|||
throw new Error('Resposta não OK'); |
|||
} |
|||
}) |
|||
.catch(function(error) { |
|||
clearTimeout(timeoutId); |
|||
tentativas++; |
|||
if (tentativas >= apps.length) { |
|||
reject({ |
|||
success: false, |
|||
error: 'Nenhuma aplicação de assinatura A3 detectada. Certifique-se de que a aplicação está em execução.' |
|||
}); |
|||
} |
|||
}); |
|||
}); |
|||
}); |
|||
}, |
|||
|
|||
/** |
|||
* Testa se uma aplicação específica está respondendo |
|||
* @param {string} appKey - Chave da aplicação (serpro, webpki) |
|||
* @returns {Promise<boolean>} |
|||
*/ |
|||
testarConexao: function(appKey) { |
|||
var app = this.aplicacoes[appKey]; |
|||
if (!app) { |
|||
return Promise.resolve(false); |
|||
} |
|||
|
|||
var url = app.protocolo + '://localhost:' + app.porta + '/'; |
|||
|
|||
return fetch(url, { |
|||
method: 'GET', |
|||
mode: 'no-cors' |
|||
}) |
|||
.then(function() { |
|||
return true; |
|||
}) |
|||
.catch(function() { |
|||
return false; |
|||
}); |
|||
}, |
|||
|
|||
/** |
|||
* Lista os certificados disponíveis no token/smartcard |
|||
* @returns {Promise} |
|||
*/ |
|||
listarCertificados: function() { |
|||
var self = this; |
|||
|
|||
if (!this.appDetectada) { |
|||
return Promise.reject({ |
|||
error: 'Nenhuma aplicação detectada. Execute detectar() primeiro.' |
|||
}); |
|||
} |
|||
|
|||
var app = this.appDetectada.config; |
|||
var url = app.protocolo + '://localhost:' + app.porta + app.endpoints.listarCertificados; |
|||
|
|||
return fetch(url, { |
|||
method: 'GET', |
|||
mode: 'cors', |
|||
credentials: 'omit', |
|||
headers: { |
|||
'Accept': 'application/json' |
|||
} |
|||
}) |
|||
.then(function(response) { |
|||
if (!response.ok) { |
|||
throw new Error('Erro ao listar certificados'); |
|||
} |
|||
return response.json(); |
|||
}) |
|||
.then(function(data) { |
|||
// Normaliza o formato dos certificados
|
|||
return self._normalizarCertificados(data); |
|||
}); |
|||
}, |
|||
|
|||
/** |
|||
* Normaliza o formato dos certificados para um formato comum |
|||
* @param {Object} data - Dados retornados pela API |
|||
* @returns {Array} |
|||
*/ |
|||
_normalizarCertificados: function(data) { |
|||
var certificados = []; |
|||
|
|||
if (Array.isArray(data)) { |
|||
certificados = data; |
|||
} else if (data.certificados) { |
|||
certificados = data.certificados; |
|||
} else if (data.certificates) { |
|||
certificados = data.certificates; |
|||
} |
|||
|
|||
return certificados.map(function(cert) { |
|||
return { |
|||
id: cert.id || cert.thumbprint || cert.serial, |
|||
subject: cert.subject || cert.subjectName || cert.nome, |
|||
issuer: cert.issuer || cert.issuerName || cert.emissor, |
|||
validFrom: cert.validFrom || cert.notBefore || cert.validade_inicio, |
|||
validTo: cert.validTo || cert.notAfter || cert.validade_fim, |
|||
thumbprint: cert.thumbprint || cert.hash, |
|||
raw: cert |
|||
}; |
|||
}); |
|||
}, |
|||
|
|||
/** |
|||
* Assina dados com o certificado selecionado |
|||
* @param {string} certificadoId - ID/thumbprint do certificado |
|||
* @param {string} dados - Dados a serem assinados (base64 ou hash) |
|||
* @param {string} algoritmo - Algoritmo de hash (sha256, sha384, sha512) |
|||
* @returns {Promise} |
|||
*/ |
|||
assinar: function(certificadoId, dados, algoritmo) { |
|||
var self = this; |
|||
|
|||
if (!this.appDetectada) { |
|||
return Promise.reject({ |
|||
error: 'Nenhuma aplicação detectada.' |
|||
}); |
|||
} |
|||
|
|||
var app = this.appDetectada.config; |
|||
var url = app.protocolo + '://localhost:' + app.porta + app.endpoints.assinar; |
|||
|
|||
var payload = { |
|||
certificateId: certificadoId, |
|||
data: dados, |
|||
algorithm: algoritmo || 'sha256' |
|||
}; |
|||
|
|||
// Adapta payload para formato específico de cada aplicação
|
|||
if (this.appDetectada.key === 'serpro') { |
|||
payload = { |
|||
certificado: certificadoId, |
|||
dados: dados, |
|||
algoritmo: algoritmo || 'SHA256' |
|||
}; |
|||
} |
|||
|
|||
return fetch(url, { |
|||
method: 'POST', |
|||
mode: 'cors', |
|||
credentials: 'omit', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
'Accept': 'application/json' |
|||
}, |
|||
body: JSON.stringify(payload) |
|||
}) |
|||
.then(function(response) { |
|||
if (!response.ok) { |
|||
throw new Error('Erro ao assinar'); |
|||
} |
|||
return response.json(); |
|||
}) |
|||
.then(function(data) { |
|||
return { |
|||
success: true, |
|||
signature: data.signature || data.assinatura, |
|||
certificate: data.certificate || data.certificado |
|||
}; |
|||
}); |
|||
}, |
|||
|
|||
/** |
|||
* Retorna informações sobre a aplicação detectada |
|||
* @returns {Object|null} |
|||
*/ |
|||
getAplicacaoDetectada: function() { |
|||
return this.appDetectada; |
|||
}, |
|||
|
|||
/** |
|||
* Retorna lista de aplicações suportadas |
|||
* @returns {Array} |
|||
*/ |
|||
getAplicacoesSuportadas: function() { |
|||
var self = this; |
|||
return Object.keys(this.aplicacoes).map(function(key) { |
|||
return { |
|||
key: key, |
|||
nome: self.aplicacoes[key].nome, |
|||
porta: self.aplicacoes[key].porta |
|||
}; |
|||
}); |
|||
}, |
|||
|
|||
/** |
|||
* Fluxo completo de assinatura A3 |
|||
* @param {string} hashPdf - Hash SHA256 do PDF em hexadecimal |
|||
* @param {function} onProgress - Callback de progresso |
|||
* @returns {Promise} |
|||
*/ |
|||
assinarPdf: function(hashPdf, onProgress) { |
|||
var self = this; |
|||
|
|||
return new Promise(function(resolve, reject) { |
|||
// Etapa 1: Detectar aplicação
|
|||
if (onProgress) onProgress(10, 'Detectando aplicação de assinatura...'); |
|||
|
|||
self.detectar() |
|||
.then(function(deteccao) { |
|||
if (onProgress) onProgress(30, 'Aplicação detectada: ' + deteccao.app); |
|||
|
|||
// Etapa 2: Listar certificados
|
|||
return self.listarCertificados(); |
|||
}) |
|||
.then(function(certificados) { |
|||
if (!certificados || certificados.length === 0) { |
|||
throw { error: 'Nenhum certificado encontrado no token' }; |
|||
} |
|||
|
|||
if (onProgress) onProgress(50, 'Certificados encontrados: ' + certificados.length); |
|||
|
|||
// Por enquanto, usa o primeiro certificado
|
|||
// Em produção, deveria mostrar seletor para o usuário
|
|||
var certificado = certificados[0]; |
|||
|
|||
if (onProgress) onProgress(70, 'Assinando com: ' + certificado.subject); |
|||
|
|||
// Etapa 3: Assinar
|
|||
return self.assinar(certificado.id, hashPdf, 'sha256'); |
|||
}) |
|||
.then(function(resultado) { |
|||
if (onProgress) onProgress(100, 'Assinatura concluída!'); |
|||
resolve(resultado); |
|||
}) |
|||
.catch(function(error) { |
|||
reject(error); |
|||
}); |
|||
}); |
|||
} |
|||
}; |
|||
|
|||
// Expor globalmente
|
|||
global.AssinaturaA3 = AssinaturaA3; |
|||
|
|||
})(typeof window !== 'undefined' ? window : this); |
|||
@ -0,0 +1,287 @@ |
|||
/** |
|||
* Módulo JavaScript para Assinatura Digital de Matérias Legislativas |
|||
* Gerencia o fluxo de assinatura com certificados A1 e A3 |
|||
*/ |
|||
|
|||
(function(global) { |
|||
'use strict'; |
|||
|
|||
var AssinaturaMateria = { |
|||
/** |
|||
* Configurações |
|||
*/ |
|||
config: { |
|||
materiaId: null, |
|||
csrfToken: null, |
|||
urls: { |
|||
assinarA1: '/materia/{pk}/assinar/a1/', |
|||
assinarA3Preparar: '/materia/{pk}/assinar/a3/preparar/', |
|||
assinarA3Finalizar: '/materia/{pk}/assinar/a3/finalizar/', |
|||
pdfAssinado: '/materia/{pk}/pdf-assinado/', |
|||
verificarAssinatura: '/materia/{pk}/verificar-assinatura/', |
|||
detectarA3: '/materia/assinatura/detectar-a3/' |
|||
}, |
|||
portasA3: [ |
|||
{ nome: 'Assinador SERPRO', porta: 10443, protocolo: 'https' }, |
|||
{ nome: 'Web PKI Local', porta: 5000, protocolo: 'http' } |
|||
] |
|||
}, |
|||
|
|||
/** |
|||
* Inicializa o módulo |
|||
* @param {number} materiaId - ID da matéria legislativa |
|||
* @param {string} csrfToken - Token CSRF para requisições POST |
|||
*/ |
|||
init: function(materiaId, csrfToken) { |
|||
this.config.materiaId = materiaId; |
|||
this.config.csrfToken = csrfToken || this._getCSRFToken(); |
|||
return this; |
|||
}, |
|||
|
|||
/** |
|||
* Obtém o token CSRF do cookie |
|||
*/ |
|||
_getCSRFToken: function() { |
|||
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; |
|||
}, |
|||
|
|||
/** |
|||
* Gera URL substituindo {pk} pelo ID da matéria |
|||
*/ |
|||
_getUrl: function(urlKey) { |
|||
return this.config.urls[urlKey].replace('{pk}', this.config.materiaId); |
|||
}, |
|||
|
|||
/** |
|||
* Assina com certificado A1 |
|||
* @param {File} certificadoFile - Arquivo .pfx ou .p12 |
|||
* @param {string} senha - Senha do certificado |
|||
* @param {function} onProgress - Callback de progresso (opcional) |
|||
* @returns {Promise} |
|||
*/ |
|||
assinarComA1: function(certificadoFile, senha, onProgress) { |
|||
var self = this; |
|||
|
|||
return new Promise(function(resolve, reject) { |
|||
if (!certificadoFile) { |
|||
reject({ error: 'Arquivo do certificado não informado' }); |
|||
return; |
|||
} |
|||
|
|||
if (!senha) { |
|||
reject({ error: 'Senha do certificado não informada' }); |
|||
return; |
|||
} |
|||
|
|||
var formData = new FormData(); |
|||
formData.append('certificado', certificadoFile); |
|||
formData.append('senha', senha); |
|||
|
|||
var xhr = new XMLHttpRequest(); |
|||
xhr.open('POST', self._getUrl('assinarA1'), true); |
|||
xhr.setRequestHeader('X-CSRFToken', self.config.csrfToken); |
|||
|
|||
xhr.upload.onprogress = function(e) { |
|||
if (e.lengthComputable && onProgress) { |
|||
var percentComplete = (e.loaded / e.total) * 50; |
|||
onProgress(percentComplete, 'Enviando certificado...'); |
|||
} |
|||
}; |
|||
|
|||
xhr.onload = function() { |
|||
if (xhr.status >= 200 && xhr.status < 300) { |
|||
try { |
|||
var response = JSON.parse(xhr.responseText); |
|||
if (response.success) { |
|||
resolve(response); |
|||
} else { |
|||
reject(response); |
|||
} |
|||
} catch (e) { |
|||
reject({ error: 'Erro ao processar resposta do servidor' }); |
|||
} |
|||
} else { |
|||
try { |
|||
var errorResponse = JSON.parse(xhr.responseText); |
|||
reject(errorResponse); |
|||
} catch (e) { |
|||
reject({ error: 'Erro na comunicação com o servidor' }); |
|||
} |
|||
} |
|||
}; |
|||
|
|||
xhr.onerror = function() { |
|||
reject({ error: 'Erro de rede ao comunicar com o servidor' }); |
|||
}; |
|||
|
|||
xhr.send(formData); |
|||
}); |
|||
}, |
|||
|
|||
/** |
|||
* Detecta aplicações de assinatura A3 rodando localmente |
|||
* @returns {Promise} - Resolve com informações da aplicação encontrada |
|||
*/ |
|||
detectarAplicacaoA3: function() { |
|||
var self = this; |
|||
var portas = this.config.portasA3; |
|||
|
|||
return new Promise(function(resolve, reject) { |
|||
var tentativas = 0; |
|||
var encontrado = false; |
|||
|
|||
portas.forEach(function(app) { |
|||
var url = app.protocolo + '://localhost:' + app.porta + '/'; |
|||
|
|||
// Tenta conectar usando fetch com timeout
|
|||
var controller = new AbortController(); |
|||
var timeoutId = setTimeout(function() { |
|||
controller.abort(); |
|||
}, 3000); |
|||
|
|||
fetch(url, { |
|||
method: 'GET', |
|||
mode: 'no-cors', |
|||
signal: controller.signal |
|||
}) |
|||
.then(function() { |
|||
clearTimeout(timeoutId); |
|||
if (!encontrado) { |
|||
encontrado = true; |
|||
resolve({ |
|||
success: true, |
|||
app: app, |
|||
message: 'Aplicação ' + app.nome + ' detectada na porta ' + app.porta |
|||
}); |
|||
} |
|||
}) |
|||
.catch(function() { |
|||
clearTimeout(timeoutId); |
|||
tentativas++; |
|||
if (tentativas >= portas.length && !encontrado) { |
|||
reject({ |
|||
success: false, |
|||
error: 'Nenhuma aplicação de assinatura A3 detectada' |
|||
}); |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
// Timeout global
|
|||
setTimeout(function() { |
|||
if (!encontrado) { |
|||
reject({ |
|||
success: false, |
|||
error: 'Timeout ao detectar aplicação A3' |
|||
}); |
|||
} |
|||
}, 5000); |
|||
}); |
|||
}, |
|||
|
|||
/** |
|||
* Prepara a assinatura A3 obtendo o hash do PDF |
|||
* @returns {Promise} |
|||
*/ |
|||
prepararAssinaturaA3: function() { |
|||
var self = this; |
|||
|
|||
return fetch(this._getUrl('assinarA3Preparar'), { |
|||
method: 'POST', |
|||
headers: { |
|||
'X-CSRFToken': this.config.csrfToken, |
|||
'Content-Type': 'application/json' |
|||
} |
|||
}) |
|||
.then(function(response) { |
|||
return response.json(); |
|||
}) |
|||
.then(function(data) { |
|||
if (data.success) { |
|||
return data; |
|||
} else { |
|||
throw data; |
|||
} |
|||
}); |
|||
}, |
|||
|
|||
/** |
|||
* Finaliza a assinatura A3 enviando a assinatura do token |
|||
* @param {string} signatureBase64 - Assinatura em base64 |
|||
* @param {string} certificateBase64 - Certificado em base64 |
|||
* @returns {Promise} |
|||
*/ |
|||
finalizarAssinaturaA3: function(signatureBase64, certificateBase64) { |
|||
var self = this; |
|||
|
|||
return fetch(this._getUrl('assinarA3Finalizar'), { |
|||
method: 'POST', |
|||
headers: { |
|||
'X-CSRFToken': this.config.csrfToken, |
|||
'Content-Type': 'application/json' |
|||
}, |
|||
body: JSON.stringify({ |
|||
signature: signatureBase64, |
|||
certificate: certificateBase64 |
|||
}) |
|||
}) |
|||
.then(function(response) { |
|||
return response.json(); |
|||
}) |
|||
.then(function(data) { |
|||
if (data.success) { |
|||
return data; |
|||
} else { |
|||
throw data; |
|||
} |
|||
}); |
|||
}, |
|||
|
|||
/** |
|||
* Verifica a assinatura do PDF da matéria |
|||
* @returns {Promise} |
|||
*/ |
|||
verificarAssinatura: function() { |
|||
return fetch(this._getUrl('verificarAssinatura'), { |
|||
method: 'GET', |
|||
headers: { |
|||
'X-CSRFToken': this.config.csrfToken |
|||
} |
|||
}) |
|||
.then(function(response) { |
|||
return response.json(); |
|||
}); |
|||
}, |
|||
|
|||
/** |
|||
* Retorna a URL do PDF assinado |
|||
* @returns {string} |
|||
*/ |
|||
getUrlPdfAssinado: function() { |
|||
return this._getUrl('pdfAssinado'); |
|||
}, |
|||
|
|||
/** |
|||
* Baixa o PDF assinado |
|||
*/ |
|||
baixarPdfAssinado: function() { |
|||
window.open(this.getUrlPdfAssinado(), '_blank'); |
|||
} |
|||
}; |
|||
|
|||
// Expor globalmente
|
|||
global.AssinaturaMateria = AssinaturaMateria; |
|||
|
|||
})(typeof window !== 'undefined' ? window : this); |
|||
Loading…
Reference in new issue