#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Voting platform - Lista M (Movimento Transparencia e Participacao)
Assembleia Estatutaria of Universidade de Lisboa.
Stack: Python STANDARD LIBRARY ONLY (no dependencies). Stored in SQLite.
Model (editable, non-identified ballot):
- Each voter has ONE ballot, identified by a code that is derived from
their email address (see the magic-link section below), never by name.
- The vote is stored against that code, NEVER against the email. See the
"respostas" table: it has no email column and no identity column.
- Submitting the form again rewrites the same ballot (upsert), so the
last version is the one that counts.
Visit counter (AGGREGATED and NON-IDENTIFYING):
- The "visitas" table stores only: which page, when, and how long.
- NO IP address, NO cookie, NO identity. There is no way to tell who
visited, or to link a visit to a vote.
Pages (and who may open them):
/ -> cast / edit the ballot. Open to all, but without a
personal link it only shows the identification box
/resultados -> counts, percentages and comments. Requires a VOTER or
management session: people who do not vote must not see
what others wrote, nor how the vote is going
/visitas -> aggregated access statistics. MANAGEMENT only
/codigo -> this source code, open for inspection
Runs on 127.0.0.1:8080 and is published to the internet by 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")
LOG_FICH = os.path.join(BASE, "registo_votos.jsonl")
HOST = "127.0.0.1"
PORT = 8080
PROPOSTAS_FICH = os.path.join(BASE, "propostas.json")
# The proposals live in a file next to this one so they can be added or
# fixed without touching the code or restarting the service: just edit
# propostas.json (a list of objects with id, autor, principio, titulo and
# descricao). The file is re-read on every page; if it ends up malformed,
# the application keeps the last good version instead of dying.
_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: # malformed file: do not break the 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
# There is NO mass mailing. The portal is open: whoever wants to vote types
# their email. If it is not on the list, we say so. If it is, we send them a
# personal link right then, and they come in through it. This way the emails
# go out drop by drop, only to those who show up.
#
# What gets stored:
# eleitores -> ONLY the SHA-256 digest of the email, never the address
# respostas -> the ballot, which is HMAC(secret, email), with no email
# pedidos -> digest of the email + day, only to rate-limit requests
# The link itself needs no storage: it is self-verifying, carrying the ballot
# and an HMAC signature made with the same secret. Asking for the link again
# returns the SAME ballot, so nobody votes twice.
#
# HONESTY: the secret lives on the server. Anyone with access to the server
# could, in theory, rebuild the link between an email and a ballot. What is
# guaranteed is that the database holds no emails, no tokens and no table
# tying votes to people: without the secret, a ballot is just a number.
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
# Who may see the reserved pages (Tailscale identity, from inside the tailnet
# only). Add another account here if management has to be shared.
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):
"""Return the ballot if the link signature checks out."""
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 # inactivity after which the session expires
def faz_sessao(ballot, horas=SESSAO_HORAS):
"""Cookie value: ballot + expiry + signature.
The ballot is NOT stored in the clear in the cookie: if it were, anyone
could make up an identifier, send the header by hand and vote without ever
having a link. Signed, only the server can forge valid sessions. The expiry
travels inside the signature, which gives the inactivity timeout: every
visit or save renews the session.
"""
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):
"""Return the ballot if the session is authentic and still valid.
Otherwise None, and the person comes back in through their own 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):
"""MANAGEMENT access, separate from the voting one: other token, other cookie.
Whoever manages is still a voter; these are two independent accesses."""
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):
"""Send the management link to whoever manages (it does not vote)."""
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):
"""Greeting written in the list for this person. Neutral when there is none."""
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):
"""Limit link requests per day, so the portal is not used as a mail cannon."""
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):
"""Send the personal link. Returns None on success, or the reason it failed."""
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
# Subject without the word "link": the Tecnico filter adds phishing points
# to subjects that mention 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")
# HTML version: makes sure the link stays clickable in every client.
# (in plain text the encoding breaks the link across two lines and some
# webmail clients then stop turning it into a hyperlink)
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 - we want the whole reason
return f"{type(e).__name__}: {e}"
def regista_no_log(evento):
"""Append-only log, one line per ballot saved (JSONL).
It exists in case the database is lost: it is a text file that is only ever
appended to, the safest format to replicate off the machine, and the whole
vote can be rebuilt from it (see reconstroi.py). Each line is the COMPLETE
ballot as it stood after the save, so replaying the lines in order is
enough: the last line for each ballot is the one that counts.
It holds exactly what the database holds, no more: the ballot code, never
the email. There is no identity in here.
If it fails, the vote is NOT lost: it is already in the database. That is
why the error is swallowed instead of spoiling the reply to the voter.
"""
try:
with open(LOG_FICH, "a", encoding="utf-8") as f:
f.write(json.dumps(evento, ensure_ascii=False) + "\n")
f.flush()
os.fsync(f.fileno())
except Exception:
pass
def conn():
"""Short-lived connection, just for the request in hand.
It does NOT create tables or touch the schema: that would be a write, on
every single request. The schema is created once, at startup (prepara_base),
so a read-only connection never needs a write lock.
"""
# isolation_level=None means autocommit. By default Python's sqlite3 opens
# an implicit transaction on the first write and only closes it on commit;
# if a request blows up halfway through, that transaction stays open and the
# database stays locked for everyone. In autocommit each statement closes.
c = sqlite3.connect(DB, timeout=30, isolation_level=None)
c.execute("PRAGMA busy_timeout=30000")
return c
def esquema(c):
"""Create whatever is missing. Runs once, at startup."""
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)""")
# columns added after the first version
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 # already there
def prepara_base():
"""Once, at startup: WAL (parallel reads and writes) + schema."""
c = sqlite3.connect(DB, timeout=30)
c.execute("PRAGMA journal_mode=WAL")
c.execute("PRAGMA synchronous=NORMAL")
esquema(c)
c.commit()
c.close()
# "Robot" markers in the User-Agent. We keep only the person/robot flag
# (0/1), never the User-Agent itself, so nothing identifying is stored.
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 # no User-Agent = almost certainly automated
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, votante=False, gestor=False):
"""The navigation only shows what the visitor can actually open.
Someone reaching the front page without a personal link sees only 'Votar':
results are for people who came in to vote, and visits are for management
only. Showing links that lead to a 404 or to a reserved page was an
invitation to go poking around.
"""
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 ""
# The voter session bar (with "Terminar sessão") shows on EVERY page while a
# personal session is active, not only on the ballot: someone who moves to
# the results with a session in hand must still find a way to end it here.
sessao = ('
') if votante else ""
navegacao = nav_link("/", "Votar", "votar")
if votante or gestor:
navegacao += nav_link("/resultados", "Resultados", "res")
if gestor:
navegacao += nav_link("/visitas", "Visitas", "vis")
return (
""
''
f"{html.escape(titulo)}"
'
Lista M · Movimento Transparência e Participação
'
"
Votação de propostas · Assembleia Estatutária da ULisboa
"
f""
f"{sessao}{corpo}"
"{beacon}'
).encode("utf-8")
def pagina_votar(bid, visit_id, gestor=False):
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('')
# Suggestion box ABOUT THE PLATFORM (not about the content of the proposals:
# those go to whoever coordinates the list, otherwise proposals get added
# mid-vote and end up with fewer votes than the rest).
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,
votante=True, gestor=gestor)
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, votante=True)
def resultados_publicos():
"""Switch that needs no code change: if the file 'resultados_fechados'
exists next to this one, the counts stay hidden until voting closes.
close: touch resultados_fechados
open : rm resultados_fechados
No need to restart the service."""
return not os.path.exists(os.path.join(BASE, "resultados_fechados"))
def pagina_resultados_reservada(visit_id):
"""Without a personal link there are no counts and no comments."""
corpo = ('
Os resultados são reservados a quem vota. As contagens e as '
'justificações só são visíveis depois de entrar com o acesso pessoal, que é enviado '
'ao endereço pelo qual recebe as mensagens da Lista M.
')
return page("Resultados - Lista M", corpo, ativo="res", visit_id=visit_id)
def pagina_resultados(visit_id, gestor=False, votante=True):
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,
votante=votante, gestor=gestor)
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,
votante=votante, gestor=gestor)
def pagina_visitas(visit_id):
"""Management only. How the vote is going is not voter information."""
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, gestor=True)
def pagina_identificar(visit_id, aviso="", gestor=False):
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,
'',
# This box replaces the old privacy note: it said the same in fewer words
# and without the AI part or the key being deleted at the end.
'
O endereço serve apenas para confirmar que consta da lista. O voto não '
'fica ligado ao seu nome nem ao seu endereço, e a lista de subscritores existe aqui '
'apenas em resumos criptográficos, e não os próprios endereços. Não se guardam endereços '
'IP nem cookies '
'de seguimento. No fim da votação é apagada a chave que gera os códigos dos boletins. O '
'programa foi escrito com apoio de inteligência artificial e revisto por quem o assina; a '
'plataforma, a funcionar, não usa inteligência artificial nenhuma, como pode confirmar no '
'código-fonte, aberto nesta página.
',
'
No entanto, é importante assinalar que isto é uma urna digital caseira, '
'feita por colegas e a correr numa máquina nossa. Não é um sistema de voto certificado '
'nem à prova de quem tenha meios e vontade de a atacar, e lamentamos não poder prometer '
'mais do que isso. Em troca, guardam-se cópias diárias fora da máquina, o que torna '
'difícil mexer em votos já dados sem deixar rasto. Preferimos uma urna digital imperfeita '
'e transparente a um formulário fechado de um serviço alheio. Se encontrar uma falha, no '
'funcionamento ou no código, agradecemos que a assinale na caixa de sugestões, no fim do '
'boletim.
',
]
return page("Votar - Lista M", "".join(x for x in corpo if x), ativo="votar",
visit_id=visit_id, gestor=gestor)
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):
"""Visible from inside the tailnet only. Never from the 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, gestor=True)
class Handler(http.server.BaseHTTPRequestHandler):
def _admin(self):
"""Only someone coming FROM INSIDE the tailnet is an administrator.
Tailscale adds the authenticated identity to requests arriving from the
private network (Tailscale-User-Login). Requests arriving from the
internet through the Funnel do not carry that header, and do carry
Tailscale-Funnel-Request instead. That is why nobody outside can reach
the reserved pages, even knowing the address.
"""
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):
"""(ballot, set_cookie). Ballot None = not identified yet."""
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 # always renew: the timeout is on INACTIVITY
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, self._admin()))
else:
self._send(pagina_votar(bid, vid, self._admin()), set_bid=faz_sessao(bid))
elif rota == "/resultados":
bid, novo = self._ballot()
gestor = self._admin()
vid = registar_visita("resultados", robo)
if bid is None and not gestor:
self._send(pagina_resultados_reservada(vid))
else:
self._send(pagina_resultados(vid, gestor, votante=bid is not None),
set_bid=faz_sessao(bid) if bid else None)
elif rota == "/visitas":
if not self._admin():
self._send(page("Não encontrado", "
'), code=404)
else:
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),
gestor=True), 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))
# keep a record of what was moderated, without the text or the author
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()
# on purpose: neither the ballot nor the exact time is stored, only the
# day, so there is no way to tie a suggestion to whoever wrote it
c.execute("INSERT INTO sugestoes (texto, dia) VALUES (?,?)",
(texto, datetime.date.today().isoformat()))
c.commit()
c.close()
self._send(page("Obrigado - Lista M",
"
',
votante=bid is not None))
return
if rota == "/beacon":
try:
vid, dur = corpo.decode("ascii", "ignore").split(":")
vid, dur = int(vid), int(dur)
if 0 <= dur <= 86400:
c = conn()
# JavaScript ran and reported the time -> this is a person (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()
# Do NOT name this variable "agora": there is a function with that name and
# Python would treat the name as local throughout the function, breaking the
# earlier calls to agora().
quando = datetime.datetime.now().isoformat(timespec="seconds")
instantaneo = {}
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:
instantaneo[p["id"]] = {"escolha": escolha, "justificacao": 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, "
# if the person rewrote the comment it stops being hidden:
# the text was changed, so it goes back for the same treatment
"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()
# only after the database confirms: the log records what was actually saved
regista_no_log({"quando": quando, "ballot": bid, "respostas": instantaneo})
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()