-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.py
More file actions
2539 lines (2334 loc) · 134 KB
/
Copy pathserver.py
File metadata and controls
2539 lines (2334 loc) · 134 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# SGDP v1.33.0 — Servidor local: SQLite, autenticação, REST API, uploads de PDF
import http.server
import socketserver
import socket
import sys
import os
import json
import sqlite3
import hashlib
import secrets
import threading
import time
import subprocess
import re
import html as html_mod
import logging
import mimetypes
from urllib.parse import urlparse, parse_qs
import sgx_base
# Windows: console pode usar cp1252/cp850 em vez de UTF-8, quebrando prints
# com caracteres especiais (╔═╗, emojis). Força UTF-8 para evitar UnicodeEncodeError.
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, 'reconfigure'):
try:
_stream.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
PORT = int(os.environ.get('SGDP_PORT', 3001))
_BASE = os.path.dirname(os.path.abspath(__file__))
# SGDP_DATA_DIR: usado pelos testes E2E para isolar banco/uploads/backups do
# sgdp.db real sem precisar rodar o servidor a partir de outra pasta (os
# arquivos estáticos como SGDP.html continuam servidos a partir de _BASE).
_DATA_DIR = os.environ.get('SGDP_DATA_DIR', _BASE)
DB_PATH = os.path.join(_DATA_DIR, 'sgdp.db')
UPLOADS_DIR = os.path.join(_DATA_DIR, 'uploads')
BACKUP_DIR = os.path.join(_DATA_DIR, 'backups')
LOG_PATH = os.path.join(_DATA_DIR, 'sgdp_errors.log')
BACKUP_KEEP = 7
SESSION_TTL = 60 # renovado pelo ping a cada 5s (ver comentário em _watchdog mais abaixo)
MAX_UPLOAD_SIZE = 50 * 1024 * 1024
os.makedirs(_DATA_DIR, exist_ok=True)
logging.basicConfig(
filename=LOG_PATH, level=logging.ERROR,
format='%(asctime)s %(levelname)s %(message)s',
datefmt='%Y-%m-%dT%H:%M:%S'
)
_log = logging.getLogger('sgdp')
os.chdir(_BASE)
os.makedirs(UPLOADS_DIR, exist_ok=True)
os.makedirs(BACKUP_DIR, exist_ok=True)
_had_session = False # True após primeiro login; controla quando o backup pós-sessão pode disparar
_backup_pos_sess = False # True = backup pós-sessão já executado; aguarda nova sessão para resetar
FTS_AVAILABLE = False # True se o SQLite tem FTS5 compilado (setado em init_db)
_watchdog_paused = False # pausa o watchdog durante diálogos bloqueantes (ex: FolderBrowser)
TIPOS = ('lei', 'decreto', 'portaria', 'parecer', 'oficio')
DEPARTAMENTOS = ('Procuradoria-Geral', 'Gabinete')
TIPOS_LABELS_CSV = {'lei': 'Lei', 'decreto': 'Decreto', 'portaria': 'Portaria', 'parecer': 'Parecer', 'oficio': 'Ofício'}
# tipo de vínculo -> (label no sentido origem->destino, label no sentido inverso)
TIPOS_VINCULO = {
'revoga': ('Revoga', 'Revogado por'),
'altera': ('Altera', 'Alterado por'),
'complementa': ('Complementa', 'Complementado por'),
'referencia': ('Referencia', 'Referenciado por'),
}
# ── Banco de dados ────────────────────────────────────────────────────────────
# Alias de compatibilidade: _ConnAutoClose é referenciada diretamente em vários
# pontos além de get_db() (restore, backup) — manter o nome em vez de caçar
# cada call site.
_ConnAutoClose = sgx_base.ConnAutoClose
def get_db():
return sgx_base.connect_db(DB_PATH)
def init_db():
with get_db() as conn:
conn.executescript('''
CREATE TABLE IF NOT EXISTS usuarios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
nome TEXT NOT NULL,
senha_hash TEXT NOT NULL,
admin INTEGER DEFAULT 0,
ativo INTEGER DEFAULT 1,
criado_em TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now','localtime'))
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE,
expires REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS arquivos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome_original TEXT NOT NULL,
nome_disco TEXT NOT NULL,
tamanho INTEGER,
enviado_por INTEGER REFERENCES usuarios(id),
enviado_em TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now','localtime'))
);
CREATE TABLE IF NOT EXISTS documentos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tipo TEXT NOT NULL CHECK(tipo IN ('lei','decreto','portaria','parecer','oficio')),
numero INTEGER NOT NULL,
ano INTEGER NOT NULL,
data TEXT NOT NULL,
ementa TEXT NOT NULL,
partes TEXT,
observacoes TEXT,
arquivo_id INTEGER REFERENCES arquivos(id) ON DELETE SET NULL,
criado_por INTEGER REFERENCES usuarios(id),
atualizado_por INTEGER REFERENCES usuarios(id),
criado_em TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now','localtime')),
atualizado_em TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now','localtime')),
UNIQUE(tipo, numero, ano)
);
CREATE TABLE IF NOT EXISTS contadores (
tipo TEXT NOT NULL,
ano INTEGER NOT NULL,
ultimo INTEGER DEFAULT 0,
PRIMARY KEY (tipo, ano)
);
CREATE TABLE IF NOT EXISTS auditoria (
id INTEGER PRIMARY KEY AUTOINCREMENT,
usuario_id INTEGER,
usuario_nome TEXT,
acao TEXT NOT NULL,
documento_id INTEGER,
detalhes TEXT,
em TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now','localtime'))
);
CREATE TABLE IF NOT EXISTS sys_settings (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS lembretes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT NOT NULL,
data_prazo TEXT NOT NULL,
documento_id INTEGER REFERENCES documentos(id) ON DELETE SET NULL,
concluido INTEGER DEFAULT 0,
criado_por INTEGER REFERENCES usuarios(id),
criado_em TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now','localtime')),
notificado_em TEXT
);
CREATE TABLE IF NOT EXISTS documento_revisoes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
documento_id INTEGER NOT NULL REFERENCES documentos(id) ON DELETE CASCADE,
dados_json TEXT NOT NULL,
editado_por INTEGER REFERENCES usuarios(id),
editado_em TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now','localtime'))
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL UNIQUE COLLATE NOCASE
);
CREATE TABLE IF NOT EXISTS documento_tags (
documento_id INTEGER NOT NULL REFERENCES documentos(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (documento_id, tag_id)
);
CREATE TABLE IF NOT EXISTS documento_vinculos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
origem_id INTEGER NOT NULL REFERENCES documentos(id) ON DELETE CASCADE,
destino_id INTEGER NOT NULL REFERENCES documentos(id) ON DELETE CASCADE,
tipo TEXT NOT NULL CHECK(tipo IN ('revoga','altera','complementa','referencia')),
criado_por INTEGER REFERENCES usuarios(id),
criado_em TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now','localtime')),
UNIQUE(origem_id, destino_id, tipo)
);
CREATE TABLE IF NOT EXISTS signatures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cod TEXT NOT NULL UNIQUE,
documento_id INTEGER REFERENCES documentos(id) ON DELETE SET NULL,
doc_tipo TEXT, doc_numero INTEGER, doc_ano INTEGER, doc_ementa TEXT,
signer_user_id INTEGER REFERENCES usuarios(id),
signer_name TEXT,
method TEXT DEFAULT 'icp-brasil',
cert_subject TEXT,
hash_sha256 TEXT,
signed_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_docs_tipo ON documentos(tipo);
CREATE INDEX IF NOT EXISTS idx_docs_ano ON documentos(ano);
CREATE INDEX IF NOT EXISTS idx_audit_em ON auditoria(em);
CREATE INDEX IF NOT EXISTS idx_lembretes_prazo ON lembretes(data_prazo);
CREATE INDEX IF NOT EXISTS idx_revisoes_doc ON documento_revisoes(documento_id);
CREATE INDEX IF NOT EXISTS idx_doc_tags_tag ON documento_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_vinculos_origem ON documento_vinculos(origem_id);
CREATE INDEX IF NOT EXISTS idx_vinculos_destino ON documento_vinculos(destino_id);
CREATE INDEX IF NOT EXISTS idx_signatures_doc ON signatures(documento_id);
''')
global FTS_AVAILABLE
try:
conn.executescript('''
CREATE VIRTUAL TABLE IF NOT EXISTS documentos_fts USING fts5(
ementa, partes, observacoes, content='documentos', content_rowid='id'
);
CREATE TRIGGER IF NOT EXISTS documentos_fts_ai AFTER INSERT ON documentos BEGIN
INSERT INTO documentos_fts(rowid, ementa, partes, observacoes)
VALUES (new.id, new.ementa, new.partes, new.observacoes);
END;
CREATE TRIGGER IF NOT EXISTS documentos_fts_ad AFTER DELETE ON documentos BEGIN
INSERT INTO documentos_fts(documentos_fts, rowid, ementa, partes, observacoes)
VALUES ('delete', old.id, old.ementa, old.partes, old.observacoes);
END;
CREATE TRIGGER IF NOT EXISTS documentos_fts_au AFTER UPDATE ON documentos BEGIN
INSERT INTO documentos_fts(documentos_fts, rowid, ementa, partes, observacoes)
VALUES ('delete', old.id, old.ementa, old.partes, old.observacoes);
INSERT INTO documentos_fts(rowid, ementa, partes, observacoes)
VALUES (new.id, new.ementa, new.partes, new.observacoes);
END;
''')
if conn.execute('SELECT COUNT(*) FROM documentos_fts').fetchone()[0] == 0:
conn.execute('''INSERT INTO documentos_fts(rowid, ementa, partes, observacoes)
SELECT id, ementa, partes, observacoes FROM documentos''')
FTS_AVAILABLE = True
except sqlite3.OperationalError as e:
# ponytail: builds do SQLite sem FTS5 (raro) caem para busca com LIKE
_log.error('FTS5 indisponível, busca usará LIKE: %s', e)
FTS_AVAILABLE = False
conn.executemany('INSERT OR IGNORE INTO sys_settings VALUES (?,?)', [
('orgao_nome', 'Procuradoria-Geral'),
('municipio', ''),
('aut_nome', ''), ('aut_cargo', ''), ('diario_url', ''),
('backup_path', BACKUP_DIR),
('auto_backup_enabled', '1'),
('auto_backup_keep', str(BACKUP_KEEP)),
('smtp_host', ''), ('smtp_port', '587'), ('smtp_user', ''), ('smtp_pass', ''),
('smtp_secure', '0'), ('smtp_require_tls', '1'), ('smtp_ignore_ssl', '0'),
('smtp_from_name', ''), ('smtp_to', ''),
])
# Migrações de colunas
cols = [r[1] for r in conn.execute('PRAGMA table_info(documentos)').fetchall()]
if 'assunto' not in cols: conn.execute("ALTER TABLE documentos ADD COLUMN assunto TEXT DEFAULT 'Outros'")
if 'processo_pa' not in cols: conn.execute("ALTER TABLE documentos ADD COLUMN processo_pa TEXT DEFAULT ''")
if 'processo_tipo' not in cols: conn.execute("ALTER TABLE documentos ADD COLUMN processo_tipo TEXT DEFAULT ''")
if 'processo_ref' not in cols: conn.execute("ALTER TABLE documentos ADD COLUMN processo_ref TEXT DEFAULT ''")
if 'ato_tipo' not in cols: conn.execute("ALTER TABLE documentos ADD COLUMN ato_tipo TEXT DEFAULT ''")
if 'cargo' not in cols: conn.execute("ALTER TABLE documentos ADD COLUMN cargo TEXT DEFAULT ''")
if 'excluido_em' not in cols: conn.execute("ALTER TABLE documentos ADD COLUMN excluido_em TEXT DEFAULT NULL")
if 'assinado_por' not in cols: conn.execute("ALTER TABLE documentos ADD COLUMN assinado_por INTEGER REFERENCES usuarios(id)")
if 'assinado_em' not in cols: conn.execute("ALTER TABLE documentos ADD COLUMN assinado_em TEXT DEFAULT NULL")
if 'assinatura_cert' not in cols: conn.execute("ALTER TABLE documentos ADD COLUMN assinatura_cert TEXT DEFAULT ''")
if 'sigiloso' not in cols: conn.execute("ALTER TABLE documentos ADD COLUMN sigiloso INTEGER NOT NULL DEFAULT 0")
cols_usu = [r[1] for r in conn.execute('PRAGMA table_info(usuarios)').fetchall()]
if 'email' not in cols_usu: conn.execute("ALTER TABLE usuarios ADD COLUMN email TEXT DEFAULT ''")
if 'cpf' not in cols_usu: conn.execute("ALTER TABLE usuarios ADD COLUMN cpf TEXT DEFAULT ''")
if 'cargo' not in cols_usu: conn.execute("ALTER TABLE usuarios ADD COLUMN cargo TEXT DEFAULT ''")
if 'matricula' not in cols_usu: conn.execute("ALTER TABLE usuarios ADD COLUMN matricula TEXT DEFAULT ''")
if 'must_change_password' not in cols_usu: conn.execute("ALTER TABLE usuarios ADD COLUMN must_change_password INTEGER DEFAULT 0")
if 'departamento' not in cols_usu: conn.execute(f"ALTER TABLE usuarios ADD COLUMN departamento TEXT NOT NULL DEFAULT '{DEPARTAMENTOS[0]}'")
cols_lem = [r[1] for r in conn.execute('PRAGMA table_info(lembretes)').fetchall()]
if 'notificado_em' not in cols_lem: conn.execute("ALTER TABLE lembretes ADD COLUMN notificado_em TEXT DEFAULT NULL")
# Migração: alinha as chaves de SMTP com o padrão do SGCD (smtp_from -> smtp_from_name,
# smtp_tls -> smtp_require_tls, notificacao_email -> smtp_to)
old = {r['key']: r['value'] for r in conn.execute(
"SELECT key,value FROM sys_settings WHERE key IN ('smtp_from','smtp_tls','notificacao_email')")}
if old:
if 'smtp_from' in old:
conn.execute("INSERT OR REPLACE INTO sys_settings VALUES ('smtp_from_name',?)", (old['smtp_from'],))
conn.execute("DELETE FROM sys_settings WHERE key='smtp_from'")
if 'smtp_tls' in old:
conn.execute("INSERT OR REPLACE INTO sys_settings VALUES ('smtp_require_tls',?)", (old['smtp_tls'],))
conn.execute("DELETE FROM sys_settings WHERE key='smtp_tls'")
if 'notificacao_email' in old:
conn.execute("INSERT OR REPLACE INTO sys_settings VALUES ('smtp_to',?)", (old['notificacao_email'],))
conn.execute("DELETE FROM sys_settings WHERE key='notificacao_email'")
# Sessões são descartadas a cada início do servidor (evita sessões órfãs)
conn.execute('DELETE FROM sessions')
conn.commit()
if conn.execute('SELECT COUNT(*) FROM usuarios').fetchone()[0] == 0:
conn.execute(
'INSERT INTO usuarios (username,nome,senha_hash,admin,must_change_password) VALUES (?,?,?,1,1)',
('admin', 'Administrador', _hash_password('admin123'))
)
conn.commit()
print('Usuário padrão criado: admin / admin123 — troque a senha no primeiro acesso.')
def _fts_match_query(text):
"""Converte texto livre em uma query FTS5 (AND de prefixos por palavra)."""
tokens = re.findall(r'\w+', text, re.UNICODE)
if not tokens: return None
return ' '.join(f'"{t}"*' for t in tokens)
def _parse_multipart_all(body, boundary):
"""Extrai todos os campos de um multipart/form-data em um dict:
{field_name: {'text': str, 'data': bytes, 'filename': str}} (portado do SGCD)."""
parts = {}
for part in body.split(b'--' + boundary):
if b'Content-Disposition' not in part: continue
sep = part.find(b'\r\n\r\n')
if sep < 0: continue
header = part[:sep].decode('utf-8', errors='replace')
content = part[sep+4:]
if content.endswith(b'\r\n'): content = content[:-2]
m_name = re.search(r'name="([^"]*)"', header)
if not m_name: continue
name = m_name.group(1)
m_file = re.search(r'filename="([^"]*)"', header)
if m_file:
parts[name] = {'data': content, 'filename': m_file.group(1), 'text': None}
else:
parts[name] = {'data': content, 'filename': None, 'text': content.decode('utf-8', errors='replace').strip()}
return parts
def _assinar_pdf_icp(pdf_bytes, cert_bytes, senha):
"""Assina um PDF com certificado ICP-Brasil A1 (.pfx), nível qualificado.
Import tardio de pyHanko: o servidor sobe normalmente mesmo sem a lib
instalada — só este módulo fica indisponível, com erro claro. Portado do SGCD.
Retorna (pdf_assinado_bytes, subject_do_certificado)."""
import tempfile, io
from pyhanko.sign import signers
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
with tempfile.NamedTemporaryFile(suffix='.pfx', delete=False) as tf:
tf.write(cert_bytes)
pfx_path = tf.name
try:
signer = signers.SimpleSigner.load_pkcs12(pfx_path, passphrase=senha.encode('utf-8'))
if signer is None:
raise ValueError('Senha do certificado incorreta ou arquivo .pfx inválido/corrompido')
cert_subject = str(signer.signing_cert.subject.human_friendly)
writer = IncrementalPdfFileWriter(io.BytesIO(pdf_bytes))
out = io.BytesIO()
signers.sign_pdf(writer, signers.PdfSignatureMetadata(field_name='Signature1'), signer=signer, output=out)
return out.getvalue(), cert_subject
finally:
os.remove(pfx_path)
# ── Segurança ─────────────────────────────────────────────────────────────────
def get_config():
with get_db() as conn:
return {r['key']: r['value'] for r in conn.execute('SELECT key,value FROM sys_settings').fetchall()}
_hash_password = sgx_base.hash_password
_verify_password = sgx_base.verify_password
# ── Rate limit de login ─────────────────────────────────────────────────────
LOGIN_MAX_ATTEMPTS = 5
LOGIN_LOCKOUT_WINDOW = 300 # 5 min — janela deslizante de tentativas falhas
_rate_limiter = sgx_base.LoginRateLimiter(LOGIN_MAX_ATTEMPTS, LOGIN_LOCKOUT_WINDOW)
_login_rate_limited = _rate_limiter.is_locked
_record_login_failure = _rate_limiter.record_failure
_clear_login_failures = _rate_limiter.clear
# create_session/delete_session/renew_session/active_sessions delegam pro
# sgx_base (mecânica idêntica nos 4 sistemas). get_session() fica local: faz
# um SELECT de colunas explícito (não u.*) por segurança — nunca deve devolver
# a coluna de hash de senha junto com os dados da sessão — e as colunas
# selecionadas divergem por sistema (schema de usuarios não é idêntico).
def create_session(user_id):
return sgx_base.create_session(get_db, user_id, SESSION_TTL)
def get_session(token):
if not token:
return None
with get_db() as conn:
row = conn.execute(
'''SELECT s.token, s.user_id, s.expires,
u.nome, u.username, u.cpf, u.email, u.cargo, u.matricula, u.admin, u.ativo, u.departamento
FROM sessions s JOIN usuarios u ON u.id=s.user_id
WHERE s.token=? AND s.expires>? AND u.ativo=1''',
(token, time.time())
).fetchone()
return dict(row) if row else None
def delete_session(token):
sgx_base.delete_session(get_db, token)
def renew_session(token):
sgx_base.renew_session(get_db, token, SESSION_TTL)
def active_sessions():
return sgx_base.active_sessions(get_db)
def _check_shutdown():
"""O servidor nunca encerra sozinho por contagem de sessões — só via Ctrl+C
no terminal (ver bloco principal). Aqui só dispara um backup automático,
uma única vez, depois que a última sessão ativa termina.
ponytail: existia um modo "Pessoal" que fazia os._exit(0) nesta função
quando a última sessão caía — a ideia era encerrar sozinho ao fechar a
janela do navegador. Removido — se o encerramento automático por
inatividade real for necessário de novo, a forma correta é um timeout bem
mais longo (minutos, não segundos), não a contagem de sessões do ping."""
global _backup_pos_sess
if _had_session and active_sessions() == 0 and not _backup_pos_sess:
_backup_pos_sess = True
cfg = _get_backup_cfg()
if cfg['enabled']:
print('\nÚltima sessão encerrada. Executando backup automático...')
_do_json_backup(cfg)
_do_db_backup(cfg)
# ── Helpers de domínio ────────────────────────────────────────────────────────
def proximo_numero(conn, tipo, ano):
row = conn.execute('SELECT ultimo FROM contadores WHERE tipo=? AND ano=?', (tipo, ano)).fetchone()
return (row['ultimo'] + 1) if row else 1
def bump_contador(conn, tipo, ano, numero):
conn.execute(
'INSERT INTO contadores (tipo,ano,ultimo) VALUES (?,?,?) '
'ON CONFLICT(tipo,ano) DO UPDATE SET ultimo=MAX(ultimo,excluded.ultimo)',
(tipo, ano, numero)
)
def audit(conn, uid, nome, acao, doc_id=None, detalhes=None):
conn.execute(
'INSERT INTO auditoria (usuario_id,usuario_nome,acao,documento_id,detalhes) VALUES (?,?,?,?,?)',
(uid, nome, acao, doc_id, detalhes)
)
def pode_ver_doc(doc, s):
"""Documentos sigilosos só são visíveis para o criador ou admin."""
return not doc['sigiloso'] or s['admin'] or doc['criado_por'] == s['user_id']
def pode_editar_doc(doc, s):
"""Sigiloso: só criador ou admin. Não-sigiloso: mesmo departamento do criador ou admin."""
if s['admin']:
return True
if doc['sigiloso']:
return doc['criado_por'] == s['user_id']
return doc['criado_por_departamento'] == s['departamento']
def _sync_tags(conn, did, tag_names):
"""Substitui as tags do documento pela lista informada (cria as que não existem)."""
nomes = sorted({t.strip() for t in (tag_names or []) if t.strip()})
conn.execute('DELETE FROM documento_tags WHERE documento_id=?', (did,))
for nome in nomes:
conn.execute('INSERT OR IGNORE INTO tags (nome) VALUES (?)', (nome,))
tag_id = conn.execute('SELECT id FROM tags WHERE nome=? COLLATE NOCASE', (nome,)).fetchone()['id']
conn.execute('INSERT OR IGNORE INTO documento_tags (documento_id,tag_id) VALUES (?,?)', (did, tag_id))
def _tags_map(conn, doc_ids):
"""Retorna {documento_id: [nomes de tag]} para os ids informados."""
if not doc_ids: return {}
qs = ','.join('?' * len(doc_ids))
rows = conn.execute(
f'''SELECT dt.documento_id, t.nome FROM documento_tags dt
JOIN tags t ON dt.tag_id=t.id WHERE dt.documento_id IN ({qs})
ORDER BY t.nome''', doc_ids
).fetchall()
out = {}
for r in rows:
out.setdefault(r['documento_id'], []).append(r['nome'])
return out
def _sig_cod_map(conn, doc_ids):
"""Retorna {documento_id: cod} do registro de assinatura mais recente de cada id."""
if not doc_ids: return {}
qs = ','.join('?' * len(doc_ids))
rows = conn.execute(
f'''SELECT documento_id, cod FROM signatures WHERE id IN (
SELECT MAX(id) FROM signatures WHERE documento_id IN ({qs}) GROUP BY documento_id
)''', doc_ids
).fetchall()
return {r['documento_id']: r['cod'] for r in rows}
def _gerar_cod_assinatura(conn):
"""Código curto de verificação (ex: A1B2-C3D4), único na tabela signatures."""
for _ in range(10):
raw = secrets.token_hex(4).upper()
cod = raw[:4] + '-' + raw[4:]
if not conn.execute('SELECT 1 FROM signatures WHERE cod=?', (cod,)).fetchone():
return cod
raise RuntimeError('Não foi possível gerar código de verificação único')
# ── HTTP Handler ──────────────────────────────────────────────────────────────
class SGDPHandler(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
# SGDP.html/JS mudam com frequência entre versões; sem isso o navegador
# pode servir do cache sem revalidar com o servidor (heurística por Last-Modified).
if self.command == 'GET' and urlparse(self.path).path.rstrip('/').endswith(('.html', '.js', '.css')):
self.send_header('Cache-Control', 'no-cache, must-revalidate')
super().end_headers()
def do_OPTIONS(self):
self.send_response(200)
self._cors()
self.end_headers()
def _safe_dispatch(self, inner):
# handle_error (mais abaixo) nunca era chamado de verdade — é método de
# socketserver.BaseServer, não do request handler, então exceções não
# tratadas em qualquer do_GET/POST/PUT/DELETE só apareciam no console
# (nada no log, cliente só via a conexão cair). Isso escondia bugs reais.
try:
inner()
except Exception as e:
_log.error('Erro não tratado em %s %s: %s', self.command, self.path, e)
try:
self._json(500, {'error': 'Erro interno no servidor.'})
except Exception:
pass # resposta já pode ter começado a ser enviada
def do_GET(self):
self._safe_dispatch(self._do_GET)
def _do_GET(self):
parsed = urlparse(self.path)
p = parsed.path.rstrip('/')
qs = parse_qs(parsed.query)
if p == '/health':
self._json(200, {'ok': True})
elif p == '/api/auth/logout':
# Aceita token via query string para suportar sendBeacon
tok = qs.get('token', [None])[0] or self._token()
delete_session(tok)
self._json(200, {'ok': True})
threading.Thread(target=_check_shutdown, daemon=True).start()
elif p == '/api/config/public':
cfg = get_config()
self._json(200, {'orgao_nome': cfg.get('orgao_nome',''), 'municipio': cfg.get('municipio','')})
elif p == '/api/public/org-info':
try:
with get_db() as conn:
rows = conn.execute(
"SELECT key,value FROM sys_settings WHERE key IN ('orgao_nome','municipio')"
).fetchall()
info = {r['key']: r['value'] for r in rows}
if 'orgao_nome' in info: info['orgao'] = info.pop('orgao_nome')
self._json(200, info)
except Exception:
self._json(200, {})
elif p == '/api/public/last-backup':
try:
with get_db() as conn:
row = conn.execute("SELECT value FROM sys_settings WHERE key='auto_backup_last'").fetchone()
self._json(200, {'ts': row['value'] if row else None})
except Exception:
self._json(200, {'ts': None})
elif p.startswith('/verificar/'):
self._serve_verificar(p[len('/verificar/'):].strip('/').upper())
elif p.startswith('/api/'):
s = self._auth()
if s: self._route_get(p, qs, s)
else:
if p in ('', '/'):
self.path = '/SGDP.html'
super().do_GET()
def do_POST(self):
self._safe_dispatch(self._do_POST)
def _do_POST(self):
parsed = urlparse(self.path)
p = parsed.path.rstrip('/')
if p == '/api/auth/login':
self._login(self._body())
return
# Logout via beacon (sem Authorization header — lê token do query string)
if p == '/api/auth/logout':
qs_tok = parse_qs(parsed.query).get('token', [None])[0]
delete_session(qs_tok or self._token())
self._json(200, {'ok': True})
threading.Thread(target=_check_shutdown, daemon=True).start()
return
s = self._auth()
if not s: return
self._route_post(p, s)
def do_PUT(self):
self._safe_dispatch(self._do_PUT)
def _do_PUT(self):
p = urlparse(self.path).path.rstrip('/')
s = self._auth()
if not s: return
self._route_put(p, self._body(), s)
def do_DELETE(self):
self._safe_dispatch(self._do_DELETE)
def _do_DELETE(self):
p = urlparse(self.path).path.rstrip('/')
s = self._auth()
if not s: return
self._route_delete(p, s)
# ── Roteamento ────────────────────────────────────────────────────────────
def _route_get(self, p, qs, s):
def qp(k, d=None): v = qs.get(k); return v[0] if v else d
if p == '/api/auth/logout':
tok = qs.get('token', [None])[0] or self._token()
delete_session(tok)
self._json(200, {'ok': True})
threading.Thread(target=_check_shutdown, daemon=True).start()
elif p == '/api/auth/ping':
renew_session(self._token())
self._json(200, {'ok': True})
elif p == '/api/auth/me':
self._json(200, {'id': s['user_id'], 'username': s['username'], 'nome': s['nome'],
'cpf': s.get('cpf'), 'email': s.get('email'),
'cargo': s.get('cargo'), 'matricula': s.get('matricula'), 'admin': bool(s['admin']),
'departamento': s.get('departamento')})
elif p == '/api/departamentos':
self._json(200, list(DEPARTAMENTOS))
elif p == '/api/documentos':
self._list_docs(qs, s)
elif re.fullmatch(r'/api/documentos/\d+', p):
self._get_doc(int(p.split('/')[-1]), s)
elif re.fullmatch(r'/api/documentos/\d+/revisoes', p):
self._list_revisoes(int(p.split('/')[3]))
elif re.fullmatch(r'/api/documentos/\d+/vinculos', p):
self._list_vinculos(int(p.split('/')[3]))
elif re.fullmatch(r'/api/documentos/\d+/cadeia', p):
self._cadeia_normativa(int(p.split('/')[3]))
elif p == '/api/lixeira':
self._list_lixeira(qs, s)
elif p == '/api/lembretes':
self._list_lembretes(qs, s)
elif p == '/api/config/smtp':
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
cfg = get_config()
self._json(200, {k: cfg.get(k, '') for k in
('smtp_host','smtp_port','smtp_user','smtp_secure','smtp_require_tls',
'smtp_ignore_ssl','smtp_from_name','smtp_to')})
elif re.fullmatch(r'/api/arquivos/\d+', p):
self._download_arquivo(int(p.split('/')[-1]), qs, s)
elif p == '/api/contadores':
self._get_contadores(qs)
elif p == '/api/tags':
self._list_tags()
elif p == '/api/dashboard':
self._dashboard(s)
elif p == '/api/relatorio':
self._relatorio(qs, s)
elif p == '/api/relatorio/export.csv':
self._relatorio_export_csv(qs, s)
elif p == '/api/relatorio/produtividade':
self._relatorio_produtividade(qs)
elif p == '/api/relatorio/integridade':
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
self._relatorio_integridade()
elif p == '/api/relatorio/etiquetas':
self._relatorio_etiquetas()
elif p == '/api/usuarios':
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
with get_db() as conn:
rows = conn.execute('SELECT id,username,nome,cpf,email,cargo,matricula,admin,ativo,departamento,criado_em FROM usuarios ORDER BY nome').fetchall()
self._json(200, [dict(r) for r in rows])
elif p == '/api/diagnostico':
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
with get_db() as conn:
total_docs = conn.execute('SELECT COUNT(*) FROM documentos').fetchone()[0]
por_tipo = [dict(r) for r in conn.execute(
'SELECT tipo, COUNT(*) n FROM documentos GROUP BY tipo').fetchall()]
docs_sem_pdf = conn.execute(
'SELECT COUNT(*) FROM documentos WHERE arquivo_id IS NULL').fetchone()[0]
arquivos_db = conn.execute('SELECT COUNT(*) FROM arquivos').fetchone()[0]
total_usuarios = conn.execute('SELECT COUNT(*) FROM usuarios').fetchone()[0]
usuarios_ativos = conn.execute('SELECT COUNT(*) FROM usuarios WHERE ativo=1').fetchone()[0]
total_audit = conn.execute('SELECT COUNT(*) FROM auditoria').fetchone()[0]
ultimo_backup = conn.execute(
"SELECT value FROM sys_settings WHERE key='auto_backup_last'").fetchone()
# contadores vs max real
contadores_ok = True
for tipo in TIPOS:
max_real = conn.execute(
'SELECT MAX(numero) FROM documentos WHERE tipo=?', (tipo,)).fetchone()[0] or 0
cont = conn.execute(
'SELECT ultimo FROM contadores WHERE tipo=?', (tipo,)).fetchone()
cont_val = cont['ultimo'] if cont else 0
if cont_val < max_real:
contadores_ok = False; break
# arquivos no banco sem arquivo no disco
arqs_banco = conn.execute('SELECT nome_disco FROM arquivos').fetchall()
orfaos_banco = sum(1 for a in arqs_banco if not os.path.isfile(os.path.join(UPLOADS_DIR, a['nome_disco'])))
# arquivos no disco sem registro no banco
discos = set(os.listdir(UPLOADS_DIR)) if os.path.isdir(UPLOADS_DIR) else set()
nomes_banco = {a['nome_disco'] for a in arqs_banco}
orfaos_disco = len(discos - nomes_banco)
db_size_kb = os.path.getsize(DB_PATH) // 1024 if os.path.isfile(DB_PATH) else 0
self._json(200, {
'total_docs': total_docs, 'por_tipo': por_tipo,
'docs_sem_pdf': docs_sem_pdf,
'arquivos_db': arquivos_db, 'arquivos_disco': len(discos),
'orfaos_banco': orfaos_banco, 'orfaos_disco': orfaos_disco,
'total_usuarios': total_usuarios, 'usuarios_ativos': usuarios_ativos,
'contadores_ok': contadores_ok,
'total_auditoria': total_audit,
'ultimo_backup': ultimo_backup['value'] if ultimo_backup else None,
'db_size_kb': db_size_kb,
})
elif p == '/api/auditoria':
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
page = int(qp('page', 1)); per = int(qp('per', 50))
q = (qp('q') or '').strip()
acao = qp('acao') or ''
de = qp('de') or ''
ate = qp('ate') or ''
where, params = [], []
if q: where.append('(usuario_nome LIKE ? OR detalhes LIKE ?)'); params += [f'%{q}%', f'%{q}%']
if acao: where.append('acao=?'); params.append(acao)
if de: where.append('em >= ?'); params.append(de)
if ate: where.append('em <= ?'); params.append(ate + 'T23:59:59')
w = ('WHERE ' + ' AND '.join(where)) if where else ''
with get_db() as conn:
total = conn.execute(f'SELECT COUNT(*) FROM auditoria {w}', params).fetchone()[0]
rows = conn.execute(f'SELECT * FROM auditoria {w} ORDER BY id DESC LIMIT ? OFFSET ?',
params + [per, (page-1)*per]).fetchall()
self._json(200, {'total': total, 'page': page, 'per': per, 'items': [dict(r) for r in rows]})
elif p == '/api/backup':
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
self._export_backup()
elif p == '/api/backups/db':
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
cfg = _get_backup_cfg()
bdir = cfg['path']
files = sorted(
(f for f in os.listdir(bdir) if f.startswith('DB_SGDP_BACKUP_') and f.endswith('.db')),
reverse=True
) if os.path.isdir(bdir) else []
def _parse_ts(f):
d = f[15:25]; t = f[26:34].replace('-', ':')
return f'{d}T{t}'
items = [{'name': f, 'size': os.path.getsize(os.path.join(bdir, f)), 'ts': _parse_ts(f)} for f in files]
with get_db() as conn:
last_row = conn.execute("SELECT value FROM sys_settings WHERE key='auto_backup_last'").fetchone()
self._json(200, {'items': items, 'path': bdir, 'cfg': cfg,
'last_backup': last_row['value'] if last_row else None})
elif p.startswith('/api/backups/db/download'):
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
name = qs.get('name', [None])[0]
if not name or not name.startswith('DB_SGDP_BACKUP_') or not name.endswith('.db') or '/' in name or '\\' in name:
self._json(400, {'error': 'Nome inválido'}); return
fp = os.path.join(_get_backup_cfg()['path'], name)
if not os.path.exists(fp): self._json(404, {'error': 'Não encontrado'}); return
with open(fp, 'rb') as f: data_bytes = f.read()
self.send_response(200); self._cors()
self.send_header('Content-Type', 'application/octet-stream')
self.send_header('Content-Disposition', f'attachment; filename="{name}"')
self.send_header('Content-Length', str(len(data_bytes)))
self.end_headers(); self.wfile.write(data_bytes)
elif p == '/api/dialog/folder':
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
global _watchdog_paused
_watchdog_paused = True
try:
ps_cmd = (
'Add-Type -AssemblyName System.Windows.Forms;'
'$d=New-Object System.Windows.Forms.FolderBrowserDialog;'
'$d.Description="Selecione a pasta de backup do SGDP";'
'$d.ShowNewFolderButton=$true;'
'if($d.ShowDialog()-eq"OK"){Write-Output $d.SelectedPath}'
)
r = subprocess.run(['powershell', '-Sta', '-WindowStyle', 'Hidden', '-Command', ps_cmd],
capture_output=True, text=True, timeout=120)
self._json(200, {'path': r.stdout.strip() or None})
except Exception as e:
self._json(500, {'error': str(e)})
finally:
_watchdog_paused = False
elif p == '/api/config':
cfg = get_config()
self._json(200, {'orgao_nome': cfg.get('orgao_nome',''), 'municipio': cfg.get('municipio',''),
'auto_backup_enabled': cfg.get('auto_backup_enabled','1'),
'auto_backup_keep': cfg.get('auto_backup_keep', str(BACKUP_KEEP)),
'backup_path': cfg.get('backup_path', BACKUP_DIR),
'aut_nome': cfg.get('aut_nome',''), 'aut_cargo': cfg.get('aut_cargo',''),
'diario_url': cfg.get('diario_url','')})
elif p in ('/api/settings/brasao', '/api/settings/brasao/'):
cfg = get_config()
self._json(200, {'brasao_dataurl': cfg.get('brasao_dataurl', '')})
else:
self._json(404, {'error': 'Rota não encontrada'})
def _route_post(self, p, s):
if p == '/api/auth/logout':
delete_session(self._token())
with get_db() as conn:
audit(conn, s['user_id'], s['nome'], 'logout')
conn.commit()
self._json(200, {'ok': True})
threading.Thread(target=_check_shutdown, daemon=True).start()
elif p == '/api/documentos':
self._create_doc(self._body(), s)
elif re.fullmatch(r'/api/documentos/\d+/arquivo', p):
self._upload_arquivo(int(p.split('/')[3]), s)
elif re.fullmatch(r'/api/documentos/\d+/assinar', p):
self._assinar_doc(int(p.split('/')[3]), s)
elif re.fullmatch(r'/api/documentos/\d+/email', p):
self._enviar_email(int(p.split('/')[3]), self._body(), s)
elif re.fullmatch(r'/api/documentos/\d+/vinculos', p):
self._create_vinculo(int(p.split('/')[3]), self._body(), s)
elif re.fullmatch(r'/api/lixeira/\d+/restaurar', p):
self._restaurar_doc(int(p.split('/')[3]), s)
elif p == '/api/lembretes':
self._create_lembrete(self._body(), s)
elif p == '/api/import/csv':
self._import_csv(self._body(), s)
elif p == '/api/usuarios':
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
self._create_usuario(self._body(), s)
elif p == '/api/config/smtp/test':
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
self._test_smtp(s)
elif p == '/api/backup/restore':
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
self._import_backup(s)
elif p == '/api/backup/sync-preview':
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
self._sync_preview()
elif p == '/api/backup/sync-apply':
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
self._sync_apply(s)
elif p == '/api/backups/db/now':
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
name = _do_db_backup()
_rotate_backups()
self._json(200, {'ok': bool(name), 'name': name})
elif p == '/api/backups/db/restore':
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
self._restore_db_backup(s)
elif p == '/api/factory-reset':
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
self._factory_reset(s)
else:
self._json(404, {'error': 'Rota não encontrada'})
def _route_put(self, p, body, s):
if re.fullmatch(r'/api/documentos/\d+', p):
self._update_doc(int(p.split('/')[-1]), body, s)
elif re.fullmatch(r'/api/usuarios/\d+', p):
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
self._update_usuario(int(p.split('/')[-1]), body, s)
elif p == '/api/auth/senha':
self._change_senha(body, s)
elif p == '/api/config':
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
self._update_config(body, s)
elif p == '/api/config/smtp':
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
self._update_smtp(body, s)
elif p in ('/api/settings/brasao', '/api/settings/brasao/'):
self._update_brasao(body, s)
elif re.fullmatch(r'/api/lembretes/\d+', p):
self._update_lembrete(int(p.split('/')[-1]), body, s)
else:
self._json(404, {'error': 'Rota não encontrada'})
def _route_delete(self, p, s):
if re.fullmatch(r'/api/documentos/\d+', p):
self._delete_doc(int(p.split('/')[-1]), s)
elif re.fullmatch(r'/api/documentos/\d+/arquivo', p):
self._remove_arquivo(int(p.split('/')[3]), s)
elif re.fullmatch(r'/api/usuarios/\d+', p):
if not s['admin']: self._json(403, {'error': 'Acesso restrito'}); return
self._delete_usuario(int(p.split('/')[-1]), s)
elif re.fullmatch(r'/api/lixeira/\d+', p):
self._purgar_doc_endpoint(int(p.split('/')[-1]), s)
elif re.fullmatch(r'/api/lembretes/\d+', p):
self._delete_lembrete(int(p.split('/')[-1]), s)
elif re.fullmatch(r'/api/vinculos/\d+', p):
self._delete_vinculo(int(p.split('/')[-1]), s)
else:
self._json(404, {'error': 'Rota não encontrada'})
# ── Auth ──────────────────────────────────────────────────────────────────
def _login(self, body):
data = json.loads(body) if body else {}
username = data.get('username', '').strip()
password = data.get('password', '')
if not username or not password:
self._json(400, {'error': 'Usuário e senha são obrigatórios'}); return
if _login_rate_limited(username):
self._json(429, {'error': 'Muitas tentativas de login. Aguarde alguns minutos e tente novamente.'}); return
with get_db() as conn:
row = conn.execute('SELECT * FROM usuarios WHERE username=? COLLATE NOCASE AND ativo=1', (username,)).fetchone()
if not row or not _verify_password(password, row['senha_hash']):
_record_login_failure(username)
self._json(401, {'error': 'Usuário ou senha incorretos'}); return
_clear_login_failures(username)
global _had_session, _backup_pos_sess
_had_session = True
_backup_pos_sess = False # nova sessão — permite backup ao próximo logout
token = create_session(row['id'])
with get_db() as conn:
audit(conn, row['id'], row['nome'], 'login', detalhes=f"Acesso de {self.client_address[0]}")
conn.commit()
self._json(200, {
'token': token,
'user': {'id': row['id'], 'username': row['username'], 'nome': row['nome'],
'cpf': row['cpf'], 'email': row['email'],
'cargo': row['cargo'], 'matricula': row['matricula'], 'admin': bool(row['admin']),
'departamento': row['departamento'],
'mustChangePassword': bool(row['must_change_password'])}
})
# ── Documentos ────────────────────────────────────────────────────────────
def _list_docs(self, qs, s):
def qp(k, d=None): v = qs.get(k); return v[0] if v else d
tipo = qp('tipo')
search = (qp('q') or '').strip()
ano = qp('ano')
page = int(qp('page', 1))
per = int(qp('per', 50))
where, params = ['d.excluido_em IS NULL'], []
if tipo: where.append('d.tipo=?'); params.append(tipo)
if search:
fts_q = _fts_match_query(search) if FTS_AVAILABLE else None
if fts_q:
where.append('''(d.id IN (SELECT rowid FROM documentos_fts WHERE documentos_fts MATCH ?)
OR d.partes LIKE ? OR CAST(d.numero AS TEXT) LIKE ?)''')
params += [fts_q, f'%{search}%', f'%{search}%']
else:
where.append('(d.ementa LIKE ? OR d.partes LIKE ? OR CAST(d.numero AS TEXT) LIKE ?)')
params += [f'%{search}%'] * 3
if ano: where.append('d.ano=?'); params.append(int(ano))
if qp('sem_pdf'): where.append('d.arquivo_id IS NULL')
tag = qp('tag')
if tag:
where.append('d.id IN (SELECT dt.documento_id FROM documento_tags dt JOIN tags t ON dt.tag_id=t.id WHERE t.nome=? COLLATE NOCASE)')
params.append(tag)
if not s['admin']:
where.append('(d.sigiloso=0 OR d.criado_por=?)'); params.append(s['user_id'])
w = 'WHERE ' + ' AND '.join(where)
with get_db() as conn:
total = conn.execute(f'SELECT COUNT(*) FROM documentos d {w}', params).fetchone()[0]
rows = conn.execute(
f'''SELECT d.*, u1.nome criado_por_nome, u1.departamento criado_por_departamento, u2.nome atualizado_por_nome,
a.nome_original arquivo_nome, a.tamanho arquivo_tamanho
FROM documentos d
LEFT JOIN usuarios u1 ON d.criado_por=u1.id
LEFT JOIN usuarios u2 ON d.atualizado_por=u2.id
LEFT JOIN arquivos a ON d.arquivo_id=a.id
{w} ORDER BY d.ano DESC, d.numero DESC LIMIT ? OFFSET ?''',
params + [per, (page-1)*per]
).fetchall()
ids = [r['id'] for r in rows]
tags_map = _tags_map(conn, ids)
cod_map = _sig_cod_map(conn, ids)
items = []
for r in rows:
item = dict(r)
item['tags'] = tags_map.get(r['id'], [])
item['cod_verificacao'] = cod_map.get(r['id'])
items.append(item)