An inbox between two members — conversations, per-person unread marks,
photos, blocking. Not live chat: that needs a connection held open per
signed-in member, which the sync workers cannot do.
Membership of the conversation is the whole access rule and is checked on
every hit, answering 404 rather than 403 so a member cannot tell a
conversation that is not theirs from one that does not exist. A picture
in a private message is checked the same way: on the board being signed
in is enough, here it is nowhere near.
Blocking is symmetric. One row stops both directions, and you can only
lift your own. A block that silenced only the blocked person would leave
the blocker writing freely, which is a megaphone rather than a safety
feature. Enforced in the handlers, with a test that posts from a page
held open from before the block.
Erasing a member deletes their private messages, both sides, and their
pictures off disk. A thread outlives its author because other people
replied; a two-party exchange has no remainder, and keeping half of
erased correspondence is what erasure exists to prevent. The guard added
in c9c549e did its job: it failed the moment the new tables landed and
named all four columns.
The part that needed care: schema.sql is all CREATE TABLE IF NOT EXISTS,
so it can add a table and nothing else. Every change so far happened to
be a new table. Letting an attachment belong to a message is not — and
SQLite cannot do it in place, because the table carries a CHECK
constraint and there is no DROP CONSTRAINT. Verified before building on
it: ALTER TABLE ADD COLUMN succeeds and the next insert is refused.
So migrations.py, numbered steps recorded in PRAGMA user_version, run
after the schema so a fresh database finds its work already done. Step 1
rebuilds attachments the documented way. Tested against a database built
in the old shape with rows in it, because a migration tested only on a
fresh database is tested against the one case it was never needed for —
including that the rebuilt CHECK is as strict as the one it replaced.
229 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
114 lines
4.1 KiB
Python
114 lines
4.1 KiB
Python
"""SQLite access for the members area.
|
|
|
|
One connection per request, closed when the request ends. SQLite is enough
|
|
here by a wide margin: a trusted group of tens of people generates a handful
|
|
of writes a day, and keeping the database a single file on disk means the
|
|
backup story is `cp`, which matters more than throughput nobody will use.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
from flask import current_app, g
|
|
|
|
from . import migrations
|
|
|
|
SCHEMA_PATH = Path(__file__).with_name("schema.sql")
|
|
|
|
# Authorship of a removed member is reassigned to this row rather than deleted,
|
|
# so their threads keep their shape and replies to them still make sense. It can
|
|
# never log in: the login gate requires an active member holding a real role.
|
|
TOMBSTONE_LOGIN = "__removed__"
|
|
TOMBSTONE_NAME = "Miembro eliminado"
|
|
|
|
|
|
def connect(path: str) -> sqlite3.Connection:
|
|
# isolation_level=None puts the driver in autocommit mode, so the only
|
|
# transactions are the ones written explicitly with BEGIN. Python's
|
|
# implicit-transaction behaviour is surprising often enough to be worth
|
|
# opting out of entirely.
|
|
conn = sqlite3.connect(path, isolation_level=None)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
conn.execute("PRAGMA journal_mode = WAL")
|
|
conn.execute("PRAGMA busy_timeout = 5000")
|
|
return conn
|
|
|
|
|
|
def get_db() -> sqlite3.Connection:
|
|
if "db" not in g:
|
|
g.db = connect(current_app.config["DB_PATH"])
|
|
return g.db
|
|
|
|
|
|
def close_db(_exception=None) -> None:
|
|
db = g.pop("db", None)
|
|
if db is not None:
|
|
db.close()
|
|
|
|
|
|
def init_db(app) -> None:
|
|
"""Apply the schema, bring old databases up to date, seed the fixed rows.
|
|
|
|
The order is deliberate. `schema.sql` is all CREATE TABLE IF NOT EXISTS, so
|
|
on an empty database it builds everything in its current shape and the
|
|
migrations below find nothing to do; on a database that has run before it
|
|
adds only what is new and silently leaves existing tables alone — which is
|
|
precisely why migrations have to come second and clean up after it.
|
|
"""
|
|
Path(app.config["DB_PATH"]).parent.mkdir(parents=True, exist_ok=True)
|
|
db = connect(app.config["DB_PATH"])
|
|
try:
|
|
db.executescript(SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
for step in migrations.apply(db):
|
|
app.logger.info("Applied migration %s", step)
|
|
_ensure_tombstone(db)
|
|
_seed_owner(db, app)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _ensure_tombstone(db: sqlite3.Connection) -> None:
|
|
db.execute(
|
|
"""INSERT INTO members (gitea_login, display_name, role, active)
|
|
VALUES (?, ?, 'tombstone', 0)
|
|
ON CONFLICT(gitea_login) DO NOTHING""",
|
|
(TOMBSTONE_LOGIN, TOMBSTONE_NAME),
|
|
)
|
|
|
|
|
|
def _seed_owner(db: sqlite3.Connection, app) -> None:
|
|
"""Create the first owner from BOARD_OWNER, once.
|
|
|
|
Deliberately refuses to change an existing owner. Were this to overwrite,
|
|
anyone who could edit the environment could hand themselves ownership by
|
|
restarting the container — which is a quieter privilege escalation than it
|
|
looks, since editing a compose file draws far less attention than asking
|
|
the owner for access.
|
|
"""
|
|
login = (app.config.get("OWNER_LOGIN") or "").strip()
|
|
existing = db.execute("SELECT gitea_login FROM members WHERE role = 'owner'").fetchone()
|
|
|
|
if existing:
|
|
if login and existing["gitea_login"].lower() != login.lower():
|
|
app.logger.warning(
|
|
"BOARD_OWNER is %r but the owner is %r; leaving it alone. "
|
|
"Transfer ownership from inside the app instead.",
|
|
login, existing["gitea_login"],
|
|
)
|
|
return
|
|
|
|
if not login:
|
|
app.logger.warning("No owner yet and BOARD_OWNER is unset — nobody can sign in.")
|
|
return
|
|
|
|
db.execute(
|
|
"""INSERT INTO members (gitea_login, display_name, role, active)
|
|
VALUES (?, ?, 'owner', 1)
|
|
ON CONFLICT(gitea_login) DO UPDATE SET role = 'owner', active = 1""",
|
|
(login, login),
|
|
)
|
|
app.logger.info("Seeded %r as owner.", login)
|