#!/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:
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 = ['
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('')
# 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.
')
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'')
else:
extra = (f'')
itens += (f'
'
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'
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
{linhas_pg}
'
f'
Últimos acessos de pessoas
{linhas_u}
'
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.
')
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",
'
'), 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ã.
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",
"
'))
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_s)", (dur, vid, dur))
c.commit()
c.close()
except Exception:
pass
self._send(b"", code=204)
return
if rota != "/votar":
self._send(b"", code=404)
return
bid, novo = self._ballot()
dados = parse_qs(corpo.decode("utf-8"))
c = conn()
# NAO chamar a variavel "agora": ha uma funcao com esse nome e o Python
# passaria a tratar o nome como local em toda a funcao, rebentando as
# chamadas anteriores a agora().
quando = datetime.datetime.now().isoformat(timespec="seconds")
for p in propostas():
escolha = (dados.get(f"vote_{p['id']}", [""])[0] or "").strip()
just = (dados.get(f"just_{p['id']}", [""])[0] or "").strip()
if escolha not in ESCOLHAS:
escolha = ""
if escolha or just:
c.execute(
"INSERT INTO respostas (ballot, proposta, escolha, justificacao, atualizado_em) "
"VALUES (?,?,?,?,?) ON CONFLICT(ballot, proposta) DO UPDATE SET "
"escolha=excluded.escolha, justificacao=excluded.justificacao, "
"atualizado_em=excluded.atualizado_em, "
# se a pessoa reescreveu a justificacao, deixa de estar oculta:
# corrigiu-se o texto, volta a concorrer ao mesmo tratamento
"oculta=CASE WHEN excluded.justificacao<>respostas.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()