#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Plataforma de votacao - Lista M (Movimento Transparencia e Participacao) Assembleia Estatutaria da ULisboa. TESTE. Stack: Python SO-STDLIB (sem dependencias). Guardado em SQLite. Modelo (boletim editavel e anonimo): - Cada eleitor tem UM boletim, identificado por um codigo aleatorio. No teste, esse codigo e um cookie do navegador. Na versao real vem no link magico enviado por email (o email so serve para autenticar). - O voto e guardado contra o codigo aleatorio, NUNCA contra o email. Por isso as respostas sao anonimas (ver a tabela 'respostas': nao ha coluna de email nem de identidade). - Reenviar o formulario reescreve o mesmo boletim (upsert). Contador de visitas (AGREGADO e ANONIMO): - A tabela 'visitas' guarda so: que pagina, quando, e quanto tempo durou. - NAO guarda IP, NAO guarda cookie, NAO guarda identidade. E impossivel saber quem visitou ou ligar uma visita a um voto. Portais: / -> votar / editar o boletim /resultados -> contagens, % e justificacoes /visitas -> estatisticas agregadas de acesso /codigo -> este codigo-fonte, aberto para verificacao Corre em 127.0.0.1:8080 e e exposto ao mundo pelo Tailscale Funnel. """ import http.server import socketserver import json import sqlite3 import os import html import datetime import time import secrets import hmac import hashlib import ssl import smtplib from email.message import EmailMessage from http.cookies import SimpleCookie from urllib.parse import parse_qs BASE = os.path.dirname(os.path.abspath(__file__)) DB = os.path.join(BASE, "votos.db") HOST = "127.0.0.1" PORT = 8080 PROPOSTAS_FICH = os.path.join(BASE, "propostas.json") # As propostas vivem num ficheiro ao lado, para se poderem acrescentar ou # corrigir sem mexer no codigo nem reiniciar o servico: e so editar o # propostas.json (lista de objectos com id, autor, principio, titulo e # descricao). O ficheiro e relido a cada pagina; se ficar mal formado, a # aplicacao continua com a ultima versao boa em vez de ir abaixo. _PROPOSTAS_CACHE = {"mtime": None, "lista": []} def propostas(): try: mt = os.path.getmtime(PROPOSTAS_FICH) except OSError: return _PROPOSTAS_CACHE["lista"] if _PROPOSTAS_CACHE["mtime"] != mt: try: with open(PROPOSTAS_FICH, encoding="utf-8") as f: lista = json.load(f) assert isinstance(lista, list) and all("id" in x for x in lista) _PROPOSTAS_CACHE.update(mtime=mt, lista=lista) except Exception as e: # ficheiro mal formado: nao partir o site print("propostas.json invalido, mantenho as anteriores:", e) return _PROPOSTAS_CACHE["lista"] ESCOLHAS = ["Sim", "Não", "Indeciso"] OPCOES_UI = [("", "Sem resposta")] + [(e, e) for e in ESCOLHAS] COR = {"Sim": "#1f9d55", "Não": "#d64545", "Indeciso": "#8a7a54"} # ---------------------------------------------------------------- magic-link # NAO ha envio em massa. O portal esta aberto: quem quer votar escreve o seu # email. Se nao estiver na lista da MTP, dizemos isso mesmo. Se estiver, # mandamos-lhe nesse momento um link pessoal e ele entra por ai. Assim os # emails saem a conta-gotas, so para quem aparece. # # O que fica guardado: # eleitores -> SO o resumo SHA-256 do email (a lista nao existe em claro) # respostas -> o boletim, que e HMAC(segredo, email), sem qualquer email # pedidos -> resumo do email + dia, so para limitar pedidos repetidos # O link nao precisa de ser guardado: e auto-verificavel, leva o boletim e uma # assinatura HMAC do mesmo segredo. Pedir o link outra vez devolve o mesmo # boletim, portanto ninguem vota duas vezes. # # HONESTIDADE: o segredo vive no servidor. Quem tiver acesso ao servidor pode, # em teoria, refazer a ligacao entre um email e um boletim. O que se garante e # que na base de dados nao ha emails, nem tokens, nem tabela que ligue votos a # pessoas: sem o segredo, um boletim e apenas um numero. SEGREDO_FICH = os.path.join(BASE, "segredo.txt") SMTP_FICH = os.path.join(BASE, "smtp.conf") URL_BASE = os.environ.get("VOTACAO_URL", "https://raspberrypi-ist.tail44fb5a.ts.net") MAX_PEDIDOS_DIA = 5 # Quem pode ver as paginas reservadas (identidade Tailscale, so por dentro do # tailnet). Acrescentar aqui outra conta se for preciso partilhar a gestao. ADMINS = {"carlos.baptista.cardeira@gmail.com", "paulo.jcr.oliveira@gmail.com"} def segredo(): if not os.path.exists(SEGREDO_FICH): with open(SEGREDO_FICH, "w", encoding="ascii") as f: f.write(secrets.token_urlsafe(48)) try: os.chmod(SEGREDO_FICH, 0o600) except OSError: pass with open(SEGREDO_FICH, encoding="ascii") as f: return f.read().strip().encode("ascii") SEGREDO = segredo() def agora(): return datetime.datetime.now().isoformat(timespec="seconds") def norm_email(e): return (e or "").strip().lower() def hash_email(e): return hashlib.sha256(norm_email(e).encode("utf-8")).hexdigest() def ballot_de_email(e): return hmac.new(SEGREDO, ("boletim:" + norm_email(e)).encode("utf-8"), hashlib.sha256).hexdigest()[:32] def assina(b): return hmac.new(SEGREDO, ("link:" + b).encode("utf-8"), hashlib.sha256).hexdigest()[:24] def faz_token(e): b = ballot_de_email(e) return b + "." + assina(b) def valida_token(t): """Devolve o boletim se a assinatura do link bater certo.""" if not t or "." not in t: return None b, sig = t.rsplit(".", 1) return b if hmac.compare_digest(sig, assina(b)) else None SESSAO_HORAS = 2 # inatividade ao fim da qual a sessao caduca def faz_sessao(ballot, horas=SESSAO_HORAS): """Valor do cookie: boletim + validade + assinatura. NAO se guarda o boletim em claro no cookie: se assim fosse, qualquer pessoa podia inventar um identificador, mandar o cabecalho a mao e votar sem ter link nenhum. Com assinatura, so o servidor consegue fabricar sessoes validas. A validade vai dentro da assinatura, o que da o timeout por inatividade: cada visita ou gravacao renova a sessao. """ ate = str(int(time.time() + horas * 3600)) corpo = ballot + "." + ate sig = hmac.new(SEGREDO, ("sessao:" + corpo).encode("utf-8"), hashlib.sha256).hexdigest()[:24] return corpo + "." + sig def valida_sessao(v): """Devolve o boletim se a sessao for autentica e ainda estiver dentro da validade. Caso contrario None, e a pessoa volta a entrar pelo seu link.""" if not v or v.count(".") != 2: return None ballot, ate, sig = v.split(".") bom = hmac.new(SEGREDO, ("sessao:" + ballot + "." + ate).encode("utf-8"), hashlib.sha256).hexdigest()[:24] if not hmac.compare_digest(sig, bom): return None try: if int(ate) < time.time(): return None except ValueError: return None return ballot def token_gestao(e): """Acesso de GESTAO, separado do de voto: outro token, outro cookie. Quem gere nao deixa de ser votante; sao dois acessos independentes.""" g = hmac.new(SEGREDO, ("gestor:" + norm_email(e)).encode("utf-8"), hashlib.sha256).hexdigest()[:32] return g + "." + hmac.new(SEGREDO, ("gestao:" + g).encode("utf-8"), hashlib.sha256).hexdigest()[:24] def valida_gestao(t): if not t or "." not in t: return None g, sig = t.rsplit(".", 1) bom = hmac.new(SEGREDO, ("gestao:" + g).encode("utf-8"), hashlib.sha256).hexdigest()[:24] return g if hmac.compare_digest(sig, bom) else None def envia_gestao(email): """Manda a quem gere o seu link de gestao (nao serve para votar).""" if not os.path.exists(SMTP_FICH): return "o servidor ainda não tem o envio configurado" linhas = [l.strip() for l in open(SMTP_FICH, encoding="utf-8") if l.strip()] utilizador, palavra = linhas[0], linhas[1].replace(" ", "") link = f"{URL_BASE}/gestao?g={token_gestao(email)}" m = EmailMessage() m["From"] = f"Lista M - Votações <{utilizador}>" m["To"] = email m["Subject"] = "Acesso de gestão da plataforma da Lista M" saud = saudacao_de(email) if esta_na_lista(email) else "Caro(a) Colega" m.set_content( saud + ",\n\n" "Segue o acesso de gestão da plataforma de votação:\n\n" + link + "\n\n" "Este acesso serve para ver as sugestões recebidas e para ocultar uma " "justificação que se revele ofensiva. Não serve para votar: o voto faz-se " "pelo acesso pessoal de votante, que é outro e continua igual ao de todos.\n\n" "Guarde esta mensagem. Se a perder, pode pedir o acesso outra vez.\n") try: with smtplib.SMTP_SSL("smtp.gmail.com", 465, context=ssl.create_default_context(), timeout=30) as s_: s_.login(utilizador, palavra) s_.send_message(m) return None except Exception as e: # noqa: BLE001 return f"{type(e).__name__}: {e}" def esta_na_lista(e): c = conn() r = c.execute("SELECT 1 FROM eleitores WHERE email_hash=?", (hash_email(e),)).fetchone() c.close() return r is not None def saudacao_de(e): """Saudacao escrita na lista para esta pessoa. Neutra quando nao ha.""" c = conn() r = c.execute("SELECT saudacao FROM eleitores WHERE email_hash=?", (hash_email(e),)).fetchone() c.close() return (r[0] if r and r[0] else "Caro(a) Colega") def pode_pedir(e): """Limita os pedidos de link por dia, para o portal nao servir de metralhadora.""" dia = datetime.date.today().isoformat() eh = hash_email(e) c = conn() r = c.execute("SELECT n FROM pedidos WHERE email_hash=? AND dia=?", (eh, dia)).fetchone() n = (r[0] if r else 0) + 1 c.execute("INSERT INTO pedidos (email_hash, dia, n) VALUES (?,?,?) " "ON CONFLICT(email_hash, dia) DO UPDATE SET n=?", (eh, dia, n, n)) c.commit() c.close() return n <= MAX_PEDIDOS_DIA def envia_link(email): """Manda o link pessoal. Devolve None se correu bem, ou a razao da falha.""" if not os.path.exists(SMTP_FICH): return "o servidor ainda não tem o envio configurado" linhas = [l.strip() for l in open(SMTP_FICH, encoding="utf-8") if l.strip()] if len(linhas) < 2: return "o servidor ainda não tem o envio configurado" utilizador, palavra = linhas[0], linhas[1].replace(" ", "") link = f"{URL_BASE}/?t={faz_token(email)}" m = EmailMessage() m["From"] = f"Lista M - Votações <{utilizador}>" m["To"] = email # Assunto sem a palavra "link": o filtro do Tecnico soma pontos de phishing # a assuntos que falem em links. m["Subject"] = "Votação das propostas da Lista M" saud = saudacao_de(email) m.set_content( f"{saud},\n\n" "Recebemos um pedido de acesso à plataforma de votação das propostas da " "Lista M, feito com este endereço.\n\n" "Se não foi o próprio a fazê-lo, ignore esta mensagem: nada acontece e o " "seu boletim mantém-se intacto.\n\n" "Se foi, o acesso faz-se por aqui:\n\n" f"{link}\n\n" "Este endereço é pessoal e conduz sempre ao mesmo boletim, pelo que pode " "votar, rever e alterar as respostas as vezes que entender até ao fecho da " "votação. Conta sempre a última versão que deixar registada.\n\n" "O voto é anónimo. O endereço de correio serve apenas para confirmar que " "consta da lista de subscritores e não fica guardado junto das respostas.\n\n" "Com os melhores cumprimentos,\n" "A plataforma de votações da Lista M\n\n" "Mensagem enviada automaticamente a partir da conta de Carlos Cardeira, " "que montou a plataforma para a lista.\n") # Versao em HTML: garante que o link fica clicavel em todos os clientes. # (em texto simples, a codificacao parte o link em duas linhas e alguns # webmails deixam de o transformar em hiperligacao) lk = html.escape(link, quote=True) m.add_alternative(f"""\

{html.escape(saud)},

Recebemos um pedido de acesso à plataforma de votação das propostas da Lista M, feito com este endereço.

Se não foi o próprio a fazê-lo, ignore esta mensagem: nada acontece e o seu boletim mantém-se intacto. Se foi, o acesso faz-se aqui:

Ir votar

Se o botão não funcionar, copie este endereço para o navegador:
{lk}

Este endereço é pessoal e conduz sempre ao mesmo boletim, pelo que pode votar, rever e alterar as respostas as vezes que entender até ao fecho da votação. Conta sempre a última versão que deixar registada.

O voto é anónimo. O endereço de correio serve apenas para confirmar que consta da lista de subscritores e não fica guardado junto das respostas.

Mensagem enviada automaticamente pela plataforma de votações da Lista M, a partir da conta de Carlos Cardeira, que a montou para a lista.

""", subtype="html") try: with smtplib.SMTP_SSL("smtp.gmail.com", 465, context=ssl.create_default_context(), timeout=30) as s: s.login(utilizador, palavra) s.send_message(m) return None except Exception as e: # noqa: BLE001 - queremos a razao toda return f"{type(e).__name__}: {e}" def conn(): """Ligacao curta, so para o pedido em maos. NAO cria tabelas nem mexe no esquema: isso e escrita, e fazia-se a cada pedido. O esquema e criado uma vez, no arranque (prepara_base). Assim uma ligacao de leitura nunca precisa de tranca de escrita. """ # isolation_level=None: autocommit. O modulo sqlite3 do Python, por # omissao, abre uma transacao implicita na primeira escrita e so a fecha no # commit; se um pedido rebentar a meio, essa transacao fica aberta e a base # fica trancada para todos. Em autocommit cada instrucao fecha-se sozinha. c = sqlite3.connect(DB, timeout=30, isolation_level=None) c.execute("PRAGMA busy_timeout=30000") return c def esquema(c): """Cria o que falta. Corre uma vez, no arranque.""" c.execute("""CREATE TABLE IF NOT EXISTS respostas ( ballot TEXT NOT NULL, proposta TEXT NOT NULL, escolha TEXT, justificacao TEXT, atualizado_em TEXT NOT NULL, PRIMARY KEY (ballot, proposta))""") c.execute("""CREATE TABLE IF NOT EXISTS eleitores ( email_hash TEXT PRIMARY KEY, nota TEXT)""") c.execute("""CREATE TABLE IF NOT EXISTS moderacao ( id INTEGER PRIMARY KEY AUTOINCREMENT, proposta TEXT NOT NULL, accao TEXT NOT NULL, quando TEXT NOT NULL)""") c.execute("""CREATE TABLE IF NOT EXISTS sugestoes ( id INTEGER PRIMARY KEY AUTOINCREMENT, texto TEXT NOT NULL, dia TEXT NOT NULL)""") c.execute("""CREATE TABLE IF NOT EXISTS pedidos ( email_hash TEXT NOT NULL, dia TEXT NOT NULL, n INTEGER NOT NULL DEFAULT 1, PRIMARY KEY (email_hash, dia))""") c.execute("""CREATE TABLE IF NOT EXISTS visitas ( id INTEGER PRIMARY KEY AUTOINCREMENT, pagina TEXT NOT NULL, aberto_em TEXT NOT NULL, duracao_s INTEGER, robo INTEGER NOT NULL DEFAULT 0)""") # colunas acrescentadas depois da primeira versao for tabela, coluna in (("eleitores", "saudacao TEXT"), ("respostas", "oculta INTEGER NOT NULL DEFAULT 0"), ("visitas", "robo INTEGER NOT NULL DEFAULT 0")): try: c.execute(f"ALTER TABLE {tabela} ADD COLUMN {coluna}") except sqlite3.OperationalError: pass # ja existe def prepara_base(): """Uma vez, no arranque: WAL (leituras e escritas em paralelo) + esquema.""" c = sqlite3.connect(DB, timeout=30) c.execute("PRAGMA journal_mode=WAL") c.execute("PRAGMA synchronous=NORMAL") esquema(c) c.commit() c.close() # Marcas de "robo" no User-Agent. Guardamos so a marca pessoa/robo (0/1), # nunca o User-Agent em si, para nao guardar nada identificavel. ROBO_MARCAS = ("bot", "crawl", "spider", "slurp", "crawler", "facebookexternalhit", "embedly", "preview", "monitor", "uptime", "pingdom", "scan", "curl", "wget", "python-requests", "httpx", "go-http", "okhttp", "java/", "headless", "phantom", "semrush", "ahrefs", "bingpreview", "dataprovider") def eh_robo(ua): ua = (ua or "").lower() if not ua: return True # sem User-Agent = quase de certeza automatico return any(m in ua for m in ROBO_MARCAS) def registar_visita(pagina, robo): c = conn() vid = c.execute("INSERT INTO visitas (pagina, aberto_em, duracao_s, robo) VALUES (?,?,NULL,?)", (pagina, datetime.datetime.now().isoformat(timespec="seconds"), 1 if robo else 0)).lastrowid c.commit() c.close() return vid def fmt_dur(seg): if seg is None: return "-" seg = int(round(seg)) if seg < 60: return f"{seg} s" return f"{seg // 60} min {seg % 60:02d} s" CSS = """ :root{--azul:#0a6ebd;--tinta:#1f2933;--cinza:#616e7c;--linha:#e4e7eb;--fundo:#f7f9fb;} *{box-sizing:border-box} body{margin:0;background:var(--fundo);color:var(--tinta); font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;line-height:1.5} header{background:var(--azul);color:#fff;padding:18px 20px} header .wrap,main{max-width:820px;margin:0 auto} header h1{margin:0;font-size:20px;font-weight:700} header p{margin:4px 0 0;opacity:.9;font-size:14px} nav{max-width:820px;margin:0 auto;padding:10px 20px;display:flex;gap:16px;flex-wrap:wrap} nav a{color:var(--azul);text-decoration:none;font-weight:600} nav a:hover{text-decoration:underline} main{padding:8px 20px 60px} .aviso{background:#fff7e6;border:1px solid #f0d58c;color:#7a5c00; padding:10px 14px;border-radius:8px;font-size:14px;margin:14px 0} .info{background:#eef4fb;border:1px solid #bcd4ee;color:#274b6d; padding:10px 14px;border-radius:8px;font-size:14px;margin:14px 0} .info a,.aviso a,footer a{color:inherit;font-weight:700} .proposta{background:#fff;border:1px solid var(--linha);border-radius:12px; padding:18px 20px;margin:16px 0;box-shadow:0 1px 2px rgba(0,0,0,.04)} .tag{display:inline-block;font-size:12px;font-weight:700;letter-spacing:.03em; text-transform:uppercase;color:var(--azul);background:#eaf3fb;border-radius:20px; padding:3px 10px;margin-bottom:8px} .autor{color:var(--cinza);font-size:13px;margin-left:8px} .proposta h2{font-size:18px;margin:.2em 0 .4em} .proposta .desc{color:#3e4c59;font-size:15px;margin:0 0 14px} .opcoes{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:12px} .opcoes label{flex:1;min-width:110px;border:1.5px solid var(--linha);border-radius:10px; padding:10px 14px;cursor:pointer;font-weight:600;text-align:center;user-select:none} .opcoes input{margin-right:8px} .opcoes label:hover{border-color:var(--azul)} .opcoes label:has(input:checked){border-color:var(--azul);background:#eaf3fb} textarea{width:100%;border:1.5px solid var(--linha);border-radius:10px;padding:10px 12px; font:inherit;resize:vertical;min-height:96px} textarea:focus,.opcoes label:focus-within{outline:none;border-color:var(--azul)} .barra-botao{position:sticky;bottom:0;background:linear-gradient(#f7f9fbaa,#f7f9fb); padding:16px 0;margin-top:8px} button{background:var(--azul);color:#fff;border:0;border-radius:10px;padding:14px 22px; font-size:16px;font-weight:700;cursor:pointer} button:hover{filter:brightness(1.05)} .res-num{display:flex;justify-content:space-between;font-size:14px;color:var(--cinza);margin:2px 0} .bar{display:flex;height:16px;border-radius:8px;overflow:hidden;background:#eef1f4;margin:6px 0 4px} .bar span{display:block} .legenda{font-size:13px;color:var(--cinza);margin-bottom:10px} .just{border-top:1px solid var(--linha);margin-top:10px;padding-top:10px} .just h4{margin:0 0 6px;font-size:13px;color:var(--cinza);text-transform:uppercase;letter-spacing:.03em} .just li{list-style:none;margin:6px 0;padding:8px 10px;background:#fafbfc;border-radius:8px} .chip{display:inline-block;font-size:12px;font-weight:700;color:#fff;border-radius:20px; padding:1px 9px;margin-right:8px} .vazio{color:var(--cinza);font-size:14px;font-style:italic} .cartoes{display:flex;gap:14px;flex-wrap:wrap;margin:16px 0} .cartao{flex:1;min-width:150px;background:#fff;border:1px solid var(--linha);border-radius:12px; padding:16px 18px;box-shadow:0 1px 2px rgba(0,0,0,.04)} .cartao .n{font-size:28px;font-weight:800;color:var(--azul)} .cartao .r{font-size:13px;color:var(--cinza)} .hist{display:flex;align-items:flex-end;gap:5px;height:150px;margin:12px 0 4px;padding-top:10px} .hist .col{flex:1;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;height:100%} .hist .barra{width:72%;background:var(--azul);border-radius:5px 5px 0 0;min-height:2px} .hist .num{font-size:11px;color:var(--cinza);margin-top:3px} .hist .dia{font-size:10px;color:var(--cinza);white-space:nowrap} .num{color:#00588f;font-weight:700} .sessao{display:flex;justify-content:space-between;align-items:center;gap:12px; font-size:13px;color:#5f6b76;border:1px solid #dfe3e7;border-radius:8px; padding:7px 12px;margin:0 0 14px;background:#fff} .sessao a{color:#00588f;text-decoration:none;font-weight:600} .sessao a:hover{text-decoration:underline} footer{max-width:820px;margin:0 auto;padding:20px;color:var(--cinza);font-size:13px} .total{font-weight:700;color:var(--tinta)} .ok{background:#e8f6ee;border:1px solid #9fd8b6;color:#1a6b3c;padding:16px 18px;border-radius:12px;margin:18px 0} """ BEACON = ('') def page(titulo, corpo, ativo="", visit_id=None): def nav_link(href, texto, chave): marca = ' style="text-decoration:underline"' if chave == ativo else "" return f'{texto}' beacon = (BEACON % visit_id) if visit_id else "" return ( "" '' f"{html.escape(titulo)}" '

Lista M · Movimento Transparência e Participação

' "

Votação de propostas · Assembleia Estatutária da ULisboa

" f'' f"
{corpo}
" "{beacon}' ).encode("utf-8") def pagina_votar(bid, visit_id): c = conn() atuais = {prop: (esc or "", just or "") for prop, esc, just in c.execute("SELECT proposta, escolha, justificacao FROM respostas WHERE ballot=?", (bid,))} c.close() ja_votou = len(atuais) > 0 linhas = ['
Sessão iniciada pelo seu acesso pessoal. ' 'Terminar sessão
', '
Este é o seu boletim. Pode alterar as respostas e reenviá-lo as ' 'vezes que entender até ao encerramento da votação, contando sempre a última versão, ' 'e pode deixar em branco o que não quiser responder. O mesmo acesso conduz sempre a ' 'este boletim.
', '
O endereço de correio serviu apenas para confirmar que consta da ' 'lista. O voto fica guardado contra um código, nunca contra o endereço de quem vota, ' 'e não existe aqui qualquer tabela que ligue votos a pessoas. Quem quiser pode ' 'assinar a justificação, mas isso não constitui garantia. Tudo isto pode ser ' 'verificado no código-fonte.
'] if ja_votou: linhas.append('
Já tens um boletim guardado neste navegador. Em baixo estão ' 'as suas respostas atuais; altere o que entender e reenvie.
') linhas.append('
') for n, p in enumerate(propostas(), 1): esc_atual, just_atual = atuais.get(p["id"], ("", "")) opcoes = [] for valor, rotulo in OPCOES_UI: checked = " checked" if valor == esc_atual else "" opcoes.append( f'') linhas.append( '
' f'{html.escape(p["principio"])}' f'proposto por {html.escape(p["autor"])}' f'

{n}. {html.escape(p["titulo"])}

' f'

{html.escape(p["descricao"])}

' f'
{"".join(opcoes)}
' f'' '
') rotulo_botao = "Guardar alterações" if ja_votou else "Submeter boletim" linhas.append(f'
') # Caixa de sugestoes SOBRE A PLATAFORMA (nao sobre o conteudo das propostas: # essas mandam-se a quem coordena a lista, senao acrescentam-se propostas a # meio da votacao e ficam com menos votos do que as outras). linhas.append( '

Sugestões sobre a plataforma

' '

Se algo não funcionar bem, estiver pouco claro ou puder melhorar, ' 'escreva aqui. É anónimo, tal como o voto: guarda-se apenas o texto e o dia, sem ' 'qualquer ligação ao seu boletim. Propostas de conteúdo não devem seguir por aqui, ' 'mas sim para quem coordena a lista.

' '
' '' '
' '
') return page("Votar - Lista M", "".join(linhas), ativo="votar", visit_id=visit_id) def pagina_obrigado(): corpo = ('
Boletim guardado. Pode voltar a alterá-lo quando entender; ' 'conta sempre só a última versão.
' '

Ver resultados · Voltar ao boletim

') return page("Guardado - Lista M", corpo) def resultados_publicos(): """Interruptor sem mexer no codigo: existir o ficheiro 'resultados_fechados' ao lado deste, esconde as contagens ate ao fim da votacao. fechar: touch resultados_fechados abrir : rm resultados_fechados Nao e preciso reiniciar o servico.""" return not os.path.exists(os.path.join(BASE, "resultados_fechados")) def pagina_resultados(visit_id, gestor=False): if not resultados_publicos(): return page("Resultados - Lista M", '
As contagens só são divulgadas depois de encerrada a ' 'votação, para que os resultados parciais não influenciem quem ainda não ' 'votou. O boletim de cada pessoa continua acessível pelo seu endereço ' 'pessoal.
', ativo="res", visit_id=visit_id) c = conn() corpo = ['

Cada eleitor conta uma vez por proposta (a sua última resposta). ' 'A regra da lista é escolher as propostas com mais de 50% de votos Sim.

'] for n, p in enumerate(propostas(), 1): cont = {e: 0 for e in ESCOLHAS} for esc, n in c.execute("SELECT escolha, COUNT(*) FROM respostas WHERE proposta=? " "AND escolha IN ('Sim','Não','Indeciso') GROUP BY escolha", (p["id"],)): cont[esc] = n total = sum(cont.values()) segs = "" nums = "" for e in ESCOLHAS: pct = (100.0 * cont[e] / total) if total else 0 segs += f'' nums += f'{html.escape(e)}: {cont[e]} ({pct:.0f}%)  ' pct_sim = (100.0 * cont["Sim"] / total) if total else 0 veredicto = "" if total: veredicto = (' · %s' % (COR["Sim"] if pct_sim > 50 else COR["Não"], "passa (>50% Sim)" if pct_sim > 50 else "não passa")) just_rows = c.execute( "SELECT escolha, justificacao, oculta, ballot FROM respostas WHERE proposta=? " "AND justificacao<>'' ORDER BY atualizado_em DESC", (p["id"],)).fetchall() if not gestor: just_rows = [r for r in just_rows if not r[2]] if just_rows: itens = "" for esc, txt, oculta, bal in just_rows: etiqueta = esc if esc else "Sem resposta" extra = "" if gestor: if oculta: extra = ('oculta' f'
' f'' f'' f'' '
') else: extra = (f'
' f'' f'' f'' '
') itens += (f'
  • ' f'{html.escape(etiqueta)}{html.escape(txt)} {extra}
  • ') just_html = f'

    Justificações ({len(just_rows)})

    ' else: just_html = '

    Sem justificações ainda.

    ' corpo.append( '
    ' f'{html.escape(p["principio"])}' f'proposto por {html.escape(p["autor"])}' f'

    {n}. {html.escape(p["titulo"])}

    ' f'
    {segs}
    ' f'
    {nums}Total: {total}{veredicto}
    ' f'{just_html}' '
    ') c.close() return page("Resultados - Lista M", "".join(corpo), ativo="res", visit_id=visit_id) def pagina_visitas(visit_id): c = conn() hoje = datetime.date.today().isoformat() pessoas = c.execute("SELECT COUNT(*) FROM visitas WHERE robo=0").fetchone()[0] robos = c.execute("SELECT COUNT(*) FROM visitas WHERE robo=1").fetchone()[0] pessoas_hoje = c.execute("SELECT COUNT(*) FROM visitas WHERE robo=0 AND substr(aberto_em,1,10)=?", (hoje,)).fetchone()[0] n_dur, media, maximo = c.execute("SELECT COUNT(*), AVG(duracao_s), MAX(duracao_s) " "FROM visitas WHERE robo=0 AND duracao_s IS NOT NULL").fetchone() por_pagina = c.execute("SELECT pagina, COUNT(*) FROM visitas WHERE robo=0 " "GROUP BY pagina ORDER BY COUNT(*) DESC").fetchall() ultimas = c.execute("SELECT pagina, aberto_em, duracao_s FROM visitas WHERE robo=0 " "ORDER BY id DESC LIMIT 12").fetchall() por_dia = dict(c.execute("SELECT substr(aberto_em,1,10), COUNT(*) FROM visitas " "WHERE robo=0 GROUP BY substr(aberto_em,1,10)").fetchall()) c.close() serie = [] for i in range(13, -1, -1): d = datetime.date.today() - datetime.timedelta(days=i) serie.append((d, por_dia.get(d.isoformat(), 0))) maxv = max((n for _, n in serie), default=0) cols = "" for d, n in serie: h = int(round(120 * n / maxv)) if maxv else 0 cols += (f'
    ' f'
    {n}
    {d.strftime("%d/%m")}
    ') hist_html = ('

    Acessos de pessoas por dia (últimos 14 dias)

    ' f'
    {cols}
    ') cartoes = ( '
    ' f'
    {pessoas}
    acessos de pessoas
    ' f'
    {pessoas_hoje}
    pessoas hoje
    ' f'
    {fmt_dur(media)}
    tempo médio por visita
    ' f'
    {fmt_dur(maximo)}
    visita mais longa
    ' f'
    {robos}
    acessos de robôs (à parte)
    ' '
    ') linhas_pg = "".join(f'
  • {html.escape(pg)} · {n}
  • ' for pg, n in por_pagina) or "
  • -
  • " linhas_u = "".join( f'
  • {html.escape(pg)} · {html.escape(q)} · {fmt_dur(d)}
  • ' for pg, q, d in ultimas) or "
  • -
  • " corpo = ( '
    Contagem agregada e anónima. Separo os acessos de pessoas dos de ' 'robôs. Um acesso conta como pessoa quando o navegador corre o JavaScript e envia o tempo de ' 'visita (os robôs quase nunca o fazem), e como robô quando o identificador é de um robô ' 'conhecido. Não é perfeito, mas dá uma boa ideia. Não guardo IP, nem cookies, nem o ' 'identificador; só a página, a hora, a duração e a marca pessoa/robô. Vê o ' 'código para confirmar.
    ' f'{cartoes}' f'{hist_html}' f'

    Acessos de pessoas, por página

    ' f'

    Últimos acessos de pessoas

    ' f'

    O tempo por visita é medido no navegador (aproximado). {n_dur} visitas de ' f'pessoas reportaram tempo. Os {robos} acessos de robôs ficam contados à parte e não entram ' f'nestas listas.

    ') return page("Visitas - Lista M", corpo, ativo="vis", visit_id=visit_id) def pagina_identificar(visit_id, aviso=""): corpo = [ '
    Bem-vindo à plataforma de votação das propostas da Lista M.
    ' 'A votação é reservada aos subscritores da lista. Indique o endereço de correio pelo ' 'qual costuma receber as mensagens da Lista M e receberá aí o seu acesso pessoal. ' 'Se o endereço não constar da lista, dir-lho-emos nesta página.
    ', aviso, '
    ' '

    ' '

    ', '
    O endereço serve apenas para confirmar que consta da lista. O voto fica ' 'guardado contra um código, nunca contra o endereço de quem vota, e a lista de ' 'subscritores está guardada aqui apenas em resumos criptográficos, não em claro. Tudo ' 'isto pode ser verificado no código-fonte.
    ', ] return page("Votar - Lista M", "".join(x for x in corpo if x), ativo="votar", visit_id=visit_id) def pagina_pedido_feito(email, visit_id): corpo = (f'
    Enviámos o acesso para {html.escape(email)}. Abra a ' 'mensagem e entre por aí. Se não chegar dentro de alguns minutos, verifique também ' 'a pasta de correio não solicitado.
    ' '

    O acesso é pessoal e conduz sempre ao mesmo boletim, que pode alterar as vezes ' 'que entender até ao fecho da votação.

    Voltar

    ') return page("Link enviado - Lista M", corpo, ativo="votar", visit_id=visit_id) def pagina_sugestoes(visit_id): """So visivel de dentro do tailnet. Nunca pela internet.""" c = conn() linhas = list(c.execute("SELECT dia, texto FROM sugestoes ORDER BY id DESC")) c.close() if not linhas: corpo = "

    Ainda não há sugestões.

    " else: corpo = ('

    %d sugestões, da mais recente para a mais antiga. Não há ' 'qualquer ligação entre uma sugestão e um boletim: guarda-se apenas o texto e ' 'o dia.

    ' % len(linhas)) for dia, texto in linhas: corpo += ('
    %s
    %s
    ' % (html.escape(dia), html.escape(texto).replace(chr(10), "
    "))) return page("Sugestões - Lista M", corpo, visit_id=visit_id) class Handler(http.server.BaseHTTPRequestHandler): def _admin(self): """So e administrador quem entra POR DENTRO do tailnet. O Tailscale acrescenta a identidade autenticada aos pedidos que vem da rede privada (Tailscale-User-Login). Os pedidos que chegam da internet, pela Funnel, nao trazem esse cabecalho e trazem Tailscale-Funnel-Request. Por isso ninguem de fora consegue chegar as paginas reservadas, mesmo conhecendo o endereco. """ cookie = SimpleCookie(self.headers.get("Cookie", "")) if "adm" in cookie and valida_gestao(cookie["adm"].value): return True if self.headers.get("Tailscale-Funnel-Request") is not None: return False quem = (self.headers.get("Tailscale-User-Login") or "").strip().lower() return quem in ADMINS def _ballot(self): """(boletim, poe_cookie). Boletim None = ainda nao se identificou.""" if "?" in self.path: t = (parse_qs(self.path.split("?", 1)[1]).get("t") or [""])[0].strip() if t: b = valida_token(t) return (b, True) if b else (None, False) cookie = SimpleCookie(self.headers.get("Cookie", "")) if "bid" in cookie: b = valida_sessao(cookie["bid"].value) if b: return b, True # renova sempre: timeout por INATIVIDADE return None, False def _send(self, corpo, code=200, ctype="text/html; charset=utf-8", set_bid=None, set_adm=None): self.send_response(code) self.send_header("Content-Type", ctype) if set_bid: self.send_header("Set-Cookie", f"bid={set_bid}; Path=/; Max-Age={SESSAO_HORAS * 3600}; HttpOnly; SameSite=Lax") if set_adm: self.send_header("Set-Cookie", f"adm={set_adm}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax") self.send_header("Content-Length", str(len(corpo))) self.end_headers() self.wfile.write(corpo) def do_GET(self): rota = self.path.split("?")[0].rstrip("/") or "/" robo = eh_robo(self.headers.get("User-Agent", "")) if rota == "/": bid, novo = self._ballot() vid = registar_visita("votar", robo) if bid is None: aviso = ('
    Esse link não é válido ou está incompleto. ' 'Pede outro em baixo.
    ') if "?t=" in self.path else "" self._send(pagina_identificar(vid, aviso)) else: self._send(pagina_votar(bid, vid), set_bid=faz_sessao(bid)) elif rota == "/resultados": self._send(pagina_resultados(registar_visita("resultados", robo), self._admin())) elif rota == "/visitas": self._send(pagina_visitas(registar_visita("visitas", robo))) elif rota == "/sair": self.send_response(303) self.send_header("Location", "/") self.send_header("Set-Cookie", "bid=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax") self.send_header("Content-Length", "0") self.end_headers() elif rota == "/gestao": g = (parse_qs(self.path.split("?", 1)[1]).get("g") or [""])[0] if "?" in self.path else "" if g and valida_gestao(g): corpo = ('
    Entrou como gestor. Este acesso não vota: serve ' 'para ver as sugestões e para ocultar ' 'justificações ofensivas na página de resultados' '. Para votar, use o seu acesso pessoal de votante.
    ') self._send(page("Gestão - Lista M", corpo, visit_id=registar_visita("gestao", robo)), set_adm=g) elif self._admin(): self._send(page("Gestão - Lista M", '
    Já tem acesso de gestão. Ver as ' 'sugestões ou os ' 'resultados.
    ', visit_id=registar_visita("gestao", robo))) else: self._send(page("Não encontrado", "

    Página não encontrada. " 'Ir votar.

    '), code=404) elif rota == "/sugestoes": if not self._admin(): self._send(page("Não encontrado", "

    Página não encontrada. " 'Ir votar.

    '), code=404) else: self._send(pagina_sugestoes(registar_visita("sugestoes", robo))) elif rota == "/codigo": with open(os.path.abspath(__file__), "rb") as f: self._send(f.read(), ctype="text/plain; charset=utf-8") else: self._send(page("Não encontrado", "

    Página não encontrada. " 'Ir votar.

    '), code=404) def do_POST(self): rota = self.path.rstrip("/") n = int(self.headers.get("Content-Length", 0)) corpo = self.rfile.read(n) if n else b"" if rota == "/pedir": dados = parse_qs(corpo.decode("utf-8", "replace")) email = norm_email((dados.get("email") or [""])[0]) vid = registar_visita("votar", eh_robo(self.headers.get("User-Agent", ""))) if not esta_na_lista(email): aviso = ('
    O endereço ' + html.escape(email) + ' não ' 'consta da lista de subscritores da Lista M, pelo que não é possível ' 'enviar-lhe o acesso. Se receber as mensagens da lista noutro endereço, ' 'indique esse; se entender que devia constar, fale com quem trata da ' 'lista.
    ') self._send(pagina_identificar(vid, aviso)) elif not pode_pedir(email): aviso = ('
    Este endereço já pediu o acesso várias vezes hoje. ' 'Procure a mensagem na sua caixa de correio, incluindo o correio não ' 'solicitado, ou tente novamente amanhã.
    ') self._send(pagina_identificar(vid, aviso)) else: erro = envia_link(email) if erro: self._send(pagina_identificar(vid, '
    Não foi possível ' 'enviar o email: ' + html.escape(erro) + '
    ')) else: self._send(pagina_pedido_feito(email, vid)) return if rota == "/ocultar": if not self._admin(): self._send(page("Não encontrado", "

    Página não encontrada.

    "), code=404) return d = parse_qs(corpo.decode("utf-8", "replace")) bal = (d.get("b") or [""])[0] prop = (d.get("p") or [""])[0] v = 1 if (d.get("v") or ["1"])[0] == "1" else 0 c = conn() c.execute("UPDATE respostas SET oculta=? WHERE ballot=? AND proposta=?", (v, bal, prop)) # fica registo do que se moderou, sem guardar o texto nem quem escreveu c.execute("INSERT INTO moderacao (proposta, accao, quando) VALUES (?,?,?)", (prop, "ocultar" if v else "repor", agora())) c.commit() c.close() self.send_response(303) self.send_header("Location", "/resultados") self.send_header("Content-Length", "0") self.end_headers() return if rota == "/sugerir": bid, _ = self._ballot() texto = (parse_qs(corpo.decode("utf-8", "replace")).get("texto") or [""])[0].strip() if bid and 3 <= len(texto) <= 4000: c = conn() # de proposito: NAO se guarda o boletim nem a hora exacta, so o dia, # para nao haver forma de ligar a sugestao a quem a escreveu c.execute("INSERT INTO sugestoes (texto, dia) VALUES (?,?)", (texto, datetime.date.today().isoformat())) c.commit() c.close() self._send(page("Obrigado - Lista M", "
    Sugestão registada. Obrigado.
    " '

    Voltar ao boletim

    ')) return if rota == "/beacon": try: vid, dur = corpo.decode("ascii", "ignore").split(":") vid, dur = int(vid), int(dur) if 0 <= dur <= 86400: c = conn() # correu JavaScript e enviou o tempo -> e uma pessoa (robo=0) c.execute("UPDATE visitas SET duracao_s=?, robo=0 WHERE id=? AND " "(duracao_s IS NULL OR duracao_srespostas.justificacao THEN 0 " "ELSE respostas.oculta END", (bid, p["id"], escolha, just, quando)) else: c.execute("DELETE FROM respostas WHERE ballot=? AND proposta=?", (bid, p["id"])) c.commit() c.close() self._send(pagina_obrigado(), set_bid=faz_sessao(bid)) def log_message(self, *a): pass class Servidor(socketserver.ThreadingMixIn, http.server.HTTPServer): daemon_threads = True allow_reuse_address = True if __name__ == "__main__": prepara_base() print(f"Plataforma de votacao Lista M a ouvir em http://{HOST}:{PORT}") Servidor((HOST, PORT), Handler).serve_forever()