diff --git a/apps/board/app.py b/apps/board/app.py index cf08581..61b8bd9 100644 --- a/apps/board/app.py +++ b/apps/board/app.py @@ -91,11 +91,12 @@ def create_app(overrides: dict | None = None) -> Flask: # restart, which is a confusing way to find out the variable is unset. raise RuntimeError("BOARD_SECRET_KEY is required (generate one with `openssl rand -hex 32`).") - from . import auth, board, content, members, uploads + from . import auth, board, content, members, messages, uploads app.register_blueprint(auth.bp, url_prefix=URL_PREFIX) app.register_blueprint(members.bp, url_prefix=URL_PREFIX) app.register_blueprint(content.bp, url_prefix=URL_PREFIX) app.register_blueprint(uploads.bp, url_prefix=URL_PREFIX) + app.register_blueprint(messages.bp, url_prefix=URL_PREFIX) app.register_blueprint(board.bp, url_prefix=URL_PREFIX) app.teardown_appcontext(close_db) @@ -106,6 +107,12 @@ def create_app(overrides: dict | None = None) -> Flask: def _year(): return {"current_year": datetime.now(timezone.utc).year} + @app.context_processor + def _unread(): + # Lazily: base.html is rendered for signed-out pages and error pages + # too, and neither should run a query to draw a badge nobody sees. + return {"unread_private": messages.unread_count} + @app.before_request def _before(): # Order matters: reject forged writes before any handler can act on diff --git a/apps/board/db.py b/apps/board/db.py index 1b81e03..a247864 100644 --- a/apps/board/db.py +++ b/apps/board/db.py @@ -13,6 +13,8 @@ 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, @@ -48,11 +50,20 @@ def close_db(_exception=None) -> None: def init_db(app) -> None: - """Apply the schema and make sure the fixed rows exist.""" + """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: diff --git a/apps/board/members.py b/apps/board/members.py index e327200..3c3994d 100644 --- a/apps/board/members.py +++ b/apps/board/members.py @@ -21,7 +21,7 @@ import sqlite3 from flask import (Blueprint, Response, abort, current_app, flash, g, redirect, render_template, request, url_for) -from . import auth, gitea, invites, mail +from . import auth, gitea, invites, mail, uploads from .db import TOMBSTONE_LOGIN, get_db from .security import admin_required, login_required, owner_required @@ -91,17 +91,62 @@ REASSIGNED_ON_ERASE = { } CLEARED_ON_ERASE = {("members", "created_by")} +# Private correspondence is not reassigned to the tombstone, it goes. A thread +# outlives its author because other people replied and the conversation would +# otherwise lose its shape; a two-party exchange has no such remainder, and +# keeping half of somebody's erased correspondence is close to the thing +# erasure exists to prevent. The other person's copy goes with it, which is the +# uncomfortable half of that choice and is meant to be. +REMOVED_ON_ERASE = { + ("conversation_members", "member_id"), + ("messages", "author_id"), + ("blocks", "blocker_id"), + ("blocks", "blocked_id"), +} -def erase_member(db: sqlite3.Connection, member_id: int) -> None: - """Remove a member and their personal data, keeping the conversation intact. + +def _conversations_of(member_id: int) -> str: + return "SELECT conversation_id FROM conversation_members WHERE member_id = ?" + + +def erase_member(db: sqlite3.Connection, member_id: int) -> list[str]: + """Remove a member and their personal data, keeping the board intact. GDPR erasure means the name, login and address go. It does not mean the - threads other people replied to should vanish, so authorship moves to the - tombstone row instead of cascading or dangling. + threads other people replied to should vanish, so public authorship moves + to the tombstone row instead of cascading or dangling. Private messages are + the exception and are deleted outright — see REMOVED_ON_ERASE. + + Returns the stored names of pictures whose rows have gone, so the caller can + take them off disk. Deleting files is not done here because this function + is a transaction: a file removed inside one cannot be put back if the + transaction rolls away underneath it. """ ghost = tombstone_id(db) db.execute("BEGIN IMMEDIATE") try: + # Read before deleting: once the conversations are gone there is + # nothing left to work out which files they carried. + orphaned = [row["stored_name"] for row in db.execute( + f"""SELECT a.stored_name FROM attachments a + JOIN messages m ON m.id = a.message_id + WHERE m.conversation_id IN ({_conversations_of(member_id)})""", + (member_id,), + )] + # Attachments first: they point at messages without cascading, so + # deleting the conversation while they exist is refused. + db.execute( + f"""DELETE FROM attachments WHERE message_id IN ( + SELECT id FROM messages + WHERE conversation_id IN ({_conversations_of(member_id)}))""", + (member_id,), + ) + db.execute( + f"DELETE FROM conversations WHERE id IN ({_conversations_of(member_id)})", + (member_id,), + ) + db.execute("DELETE FROM blocks WHERE blocker_id = ? OR blocked_id = ?", + (member_id, member_id)) # Table and column names come from the module constants above, never # from a request, so the interpolation is not a place user input can # reach. @@ -116,6 +161,7 @@ def erase_member(db: sqlite3.Connection, member_id: int) -> None: except Exception: db.execute("ROLLBACK") raise + return orphaned def _load(member_id: int): @@ -306,7 +352,10 @@ def erase(member_id: int): target = _load(member_id) if target["role"] == "owner": abort(403) - erase_member(get_db(), member_id) + # Files only after the transaction has committed: a rollback can put the + # rows back, and nothing can put the pictures back. + for stored_name in erase_member(get_db(), member_id): + uploads.remove(stored_name) flash(f"{target['display_name']} eliminado. Sus mensajes quedan como «Miembro eliminado».", "ok") return redirect(url_for("members.index")) diff --git a/apps/board/messages.py b/apps/board/messages.py new file mode 100644 index 0000000..ff3eac0 --- /dev/null +++ b/apps/board/messages.py @@ -0,0 +1,260 @@ +"""Private messages between two members. + +An inbox, not a chat. Each conversation has exactly two people, each keeps +their own unread mark, and either can block the other — after which neither can +write. That symmetry is deliberate: a block that silences only one side is a +one-way megaphone, which is worse than having no block at all. + +Every rule here is enforced in the handler, not only in the template. A hidden +button is a courtesy to somebody using the site normally; it is not a rule, and +the difference matters most for exactly the person a block exists to stop. +""" + +from __future__ import annotations + +from flask import (Blueprint, abort, flash, g, redirect, render_template, + request, url_for) + +from . import uploads +from .db import TOMBSTONE_LOGIN, get_db +from .render import to_html +from .security import login_required + +bp = Blueprint("messages", __name__) + +BODY_MAX = 20_000 + + +def _other_party(conversation_id: int): + """The member on the far side, or None if they have been erased.""" + return get_db().execute( + """SELECT m.* FROM conversation_members cm + JOIN members m ON m.id = cm.member_id + WHERE cm.conversation_id = ? AND cm.member_id != ?""", + (conversation_id, g.member["id"]), + ).fetchone() + + +def _mine_or_404(conversation_id: int): + """Membership is the entire access rule, and it is checked on every hit. + + Not "does this conversation exist" — a member who is not in it must not be + able to tell the difference between a conversation that is not theirs and + one that does not exist. + """ + row = get_db().execute( + """SELECT c.* FROM conversations c + JOIN conversation_members cm ON cm.conversation_id = c.id + WHERE c.id = ? AND cm.member_id = ?""", + (conversation_id, g.member["id"]), + ).fetchone() + if row is None: + abort(404) + return row + + +def blocked_between(a: int, b: int) -> bool: + """A block in either direction stops both directions.""" + return get_db().execute( + """SELECT 1 FROM blocks + WHERE (blocker_id = ? AND blocked_id = ?) + OR (blocker_id = ? AND blocked_id = ?)""", + (a, b, b, a), + ).fetchone() is not None + + +def conversation_with(other_id: int) -> int: + """The conversation between the signed-in member and `other_id`, made if + it does not exist yet.""" + db = get_db() + row = db.execute( + """SELECT a.conversation_id AS id + FROM conversation_members a + JOIN conversation_members b ON b.conversation_id = a.conversation_id + WHERE a.member_id = ? AND b.member_id = ?""", + (g.member["id"], other_id), + ).fetchone() + if row: + return row["id"] + + conversation_id = db.execute( + "INSERT INTO conversations DEFAULT VALUES").lastrowid + for member_id in (g.member["id"], other_id): + db.execute( + "INSERT INTO conversation_members (conversation_id, member_id) VALUES (?, ?)", + (conversation_id, member_id), + ) + return conversation_id + + +def unread_count() -> int: + """For the badge in the navigation. One query, not one per conversation.""" + if g.member is None: + return 0 + return get_db().execute( + """SELECT COUNT(*) AS n + FROM messages m + JOIN conversation_members cm + ON cm.conversation_id = m.conversation_id AND cm.member_id = ? + WHERE m.author_id != ? + AND m.deleted_at IS NULL + AND (cm.last_read_at IS NULL OR m.created_at > cm.last_read_at)""", + (g.member["id"], g.member["id"]), + ).fetchone()["n"] + + +# --- routes --------------------------------------------------------------- + +@bp.route("/privados") +@login_required +def inbox(): + rows = get_db().execute( + """SELECT c.id, + other.display_name AS other_name, + other.id AS other_id, + (SELECT body_md FROM messages + WHERE conversation_id = c.id AND deleted_at IS NULL + ORDER BY created_at DESC LIMIT 1) AS last_body, + (SELECT created_at FROM messages + WHERE conversation_id = c.id AND deleted_at IS NULL + ORDER BY created_at DESC LIMIT 1) AS last_at, + (SELECT COUNT(*) FROM messages m + WHERE m.conversation_id = c.id AND m.author_id != mine.member_id + AND m.deleted_at IS NULL + AND (mine.last_read_at IS NULL OR m.created_at > mine.last_read_at) + ) AS unread + FROM conversations c + JOIN conversation_members mine + ON mine.conversation_id = c.id AND mine.member_id = :me + JOIN conversation_members theirs + ON theirs.conversation_id = c.id AND theirs.member_id != :me + JOIN members other ON other.id = theirs.member_id + ORDER BY last_at DESC NULLS LAST""", + {"me": g.member["id"]}, + ).fetchall() + + # Anyone you could start writing to: every active member but yourself, the + # tombstone, and anyone either of you has blocked. + people = get_db().execute( + """SELECT m.id, m.display_name FROM members m + WHERE m.id != :me AND m.active = 1 + AND m.role IN ('owner', 'admin', 'user') + AND m.gitea_login != :ghost + AND NOT EXISTS (SELECT 1 FROM blocks + WHERE (blocker_id = :me AND blocked_id = m.id) + OR (blocker_id = m.id AND blocked_id = :me)) + ORDER BY m.display_name COLLATE NOCASE""", + {"me": g.member["id"], "ghost": TOMBSTONE_LOGIN}, + ).fetchall() + + return render_template("inbox.html", conversations=rows, people=people, + excerpt=lambda text: (text or "")[:120]) + + +@bp.route("/privados/nueva", methods=["POST"]) +@login_required +def start(): + """The member comes from the form, not the path, so the select can post + straight here. A path parameter would need JavaScript to rewrite the + action, and the Content-Security-Policy makes inline handlers silently + inert — a class of bug this codebase has already paid for once.""" + member_id = request.form.get("member_id", type=int) + if member_id is None or member_id == g.member["id"]: + abort(400) + target = get_db().execute( + """SELECT * FROM members + WHERE id = ? AND active = 1 AND role IN ('owner', 'admin', 'user')""", + (member_id,), + ).fetchone() + if target is None: + abort(404) + if blocked_between(g.member["id"], member_id): + abort(403) + return redirect(url_for("messages.conversation", + conversation_id=conversation_with(member_id))) + + +@bp.route("/privados/") +@login_required +def conversation(conversation_id: int): + _mine_or_404(conversation_id) + db = get_db() + rows = db.execute( + """SELECT m.*, a.display_name AS author + FROM messages m JOIN members a ON a.id = m.author_id + WHERE m.conversation_id = ? AND m.deleted_at IS NULL + ORDER BY m.created_at""", + (conversation_id,), + ).fetchall() + + # Marked read on opening, which is what the member just did. Written before + # rendering so a slow page does not leave the badge stale. + db.execute( + """UPDATE conversation_members SET last_read_at = datetime('now') + WHERE conversation_id = ? AND member_id = ?""", + (conversation_id, g.member["id"]), + ) + + other = _other_party(conversation_id) + return render_template( + "conversation.html", conversation_id=conversation_id, messages=rows, + other=other, to_html=to_html, + images=uploads.for_messages([row["id"] for row in rows]), + blocked=other is not None and blocked_between(g.member["id"], other["id"]), + ) + + +@bp.route("/privados//enviar", methods=["POST"]) +@login_required +def send(conversation_id: int): + _mine_or_404(conversation_id) + other = _other_party(conversation_id) + if other is None: + flash("Esa persona ya no está en la comunidad.", "error") + return redirect(url_for("messages.conversation", conversation_id=conversation_id)) + # Checked here and not only hidden in the template: the template is a + # courtesy, this is the rule. + if blocked_between(g.member["id"], other["id"]): + abort(403) + + body = request.form.get("body", "").strip()[:BODY_MAX] + try: + staged = uploads.stage(request.files.getlist("pictures")) + except uploads.RejectedUpload as exc: + flash(str(exc), "error") + return redirect(url_for("messages.conversation", conversation_id=conversation_id)) + if not body and not staged: + flash("Escribe algo o adjunta una imagen.", "error") + return redirect(url_for("messages.conversation", conversation_id=conversation_id)) + + cursor = get_db().execute( + "INSERT INTO messages (conversation_id, author_id, body_md) VALUES (?, ?, ?)", + (conversation_id, g.member["id"], body), + ) + uploads.save(staged, g.member["id"], message_id=cursor.lastrowid) + return redirect(url_for("messages.conversation", conversation_id=conversation_id) + "#final") + + +@bp.route("/privados/bloquear/", methods=["POST"]) +@login_required +def block(member_id: int): + if member_id == g.member["id"]: + abort(400) + get_db().execute( + """INSERT INTO blocks (blocker_id, blocked_id) VALUES (?, ?) + ON CONFLICT DO NOTHING""", + (g.member["id"], member_id), + ) + flash("Bloqueado. Ninguno de los dos puede escribir al otro.", "ok") + return redirect(url_for("messages.inbox")) + + +@bp.route("/privados/desbloquear/", methods=["POST"]) +@login_required +def unblock(member_id: int): + """Only your own block. Removing somebody else's would let the blocked + person undo the thing that was done to protect against them.""" + get_db().execute("DELETE FROM blocks WHERE blocker_id = ? AND blocked_id = ?", + (g.member["id"], member_id)) + flash("Desbloqueado.", "ok") + return redirect(url_for("messages.inbox")) diff --git a/apps/board/migrations.py b/apps/board/migrations.py new file mode 100644 index 0000000..6a951c1 --- /dev/null +++ b/apps/board/migrations.py @@ -0,0 +1,115 @@ +"""Changes to tables that already exist. + +`schema.sql` is all `CREATE TABLE IF NOT EXISTS`, which handles exactly one +kind of change: a brand-new table. It silently does nothing to a table that is +already there, so every alteration to an existing one has to happen here. + +That was fine until now because every change so far had been a new table. The +first change that is not — giving `attachments` a third possible parent — also +happens to be one SQLite cannot do in place, because the old row carries a +CHECK constraint and SQLite has no `DROP CONSTRAINT`. Verified rather than +assumed: `ALTER TABLE ... ADD COLUMN` succeeds, and the next insert is refused +by a constraint that can no longer be removed. + +**The order matters.** `init_db` applies `schema.sql` first and then these. On +an empty database the schema creates everything in its current shape and each +step below finds its work already done, so every step must be written to check +before it acts and return quietly. + +Steps are numbered, applied once, in order, each in its own transaction, and +recorded in SQLite's own `PRAGMA user_version`. Never renumber one and never +edit one that has shipped: a server that has already run it will not run it +again, so a correction is a new step. +""" + +from __future__ import annotations + +import sqlite3 + + +def _columns(db: sqlite3.Connection, table: str) -> set[str]: + return {row[1] for row in db.execute(f"PRAGMA table_info({table})")} + + +def _attachments_accept_messages(db: sqlite3.Connection) -> None: + """Let an attachment hang off a private message. + + The table is rebuilt rather than altered because of its CHECK constraint: + `(thread_id IS NULL) <> (comment_id IS NULL)` insists that exactly one of + those two is set, so a row belonging to a message — with both of them null + — is refused. Adding the column is allowed; using it is not. + + This is SQLite's documented procedure for changing a constraint: build the + new table beside the old one, copy the rows, drop the old, rename. The + foreign keys are switched off around it because dropping a table with them + on can cascade, and switched back on after, with a check that nothing was + broken in between. + """ + if "message_id" in _columns(db, "attachments"): + return # a new database: schema.sql already created it this way + + db.execute("PRAGMA foreign_keys = OFF") + try: + db.execute(""" + CREATE TABLE attachments_new ( + id INTEGER PRIMARY KEY, + thread_id INTEGER REFERENCES threads(id), + comment_id INTEGER REFERENCES comments(id), + message_id INTEGER REFERENCES messages(id), + stored_name TEXT NOT NULL UNIQUE, + original_name TEXT NOT NULL, + content_type TEXT NOT NULL, + bytes INTEGER NOT NULL, + uploaded_by INTEGER NOT NULL REFERENCES members(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + CHECK ((thread_id IS NOT NULL) + (comment_id IS NOT NULL) + + (message_id IS NOT NULL) = 1) + )""") + # Named columns, not SELECT *: the order has to survive somebody adding + # a column to one of the two tables later. + db.execute(""" + INSERT INTO attachments_new + (id, thread_id, comment_id, stored_name, original_name, + content_type, bytes, uploaded_by, created_at) + SELECT id, thread_id, comment_id, stored_name, original_name, + content_type, bytes, uploaded_by, created_at + FROM attachments""") + db.execute("DROP TABLE attachments") + db.execute("ALTER TABLE attachments_new RENAME TO attachments") + db.execute("CREATE INDEX IF NOT EXISTS attachments_thread ON attachments(thread_id)") + db.execute("CREATE INDEX IF NOT EXISTS attachments_comment ON attachments(comment_id)") + db.execute("CREATE INDEX IF NOT EXISTS attachments_message ON attachments(message_id)") + + broken = db.execute("PRAGMA foreign_key_check").fetchall() + if broken: + raise RuntimeError(f"migration left dangling references: {broken}") + finally: + db.execute("PRAGMA foreign_keys = ON") + + +# (number, description, function). The number is the value written to +# user_version once the step succeeds. +STEPS = [ + (1, "attachments can belong to a private message", _attachments_accept_messages), +] + + +def apply(db: sqlite3.Connection) -> list[str]: + """Run whatever this database has not run yet. Returns what was applied.""" + version = db.execute("PRAGMA user_version").fetchone()[0] + done = [] + for number, description, step in sorted(STEPS): + if number <= version: + continue + db.execute("BEGIN IMMEDIATE") + try: + step(db) + # Not a parameter: PRAGMA does not take them. The value is an int + # from the list above, never from anything a request can reach. + db.execute(f"PRAGMA user_version = {int(number)}") + db.execute("COMMIT") + except Exception: + db.execute("ROLLBACK") + raise + done.append(f"{number}: {description}") + return done diff --git a/apps/board/schema.sql b/apps/board/schema.sql index b46b7a6..e1964ff 100644 --- a/apps/board/schema.sql +++ b/apps/board/schema.sql @@ -55,7 +55,62 @@ CREATE TABLE IF NOT EXISTS comments ( CREATE INDEX IF NOT EXISTS comments_thread ON comments(thread_id, created_at) WHERE deleted_at IS NULL; --- Pictures attached to a thread or a comment. +-- Private messages between two members. +-- +-- An inbox, not live chat: gunicorn's sync workers cannot hold a connection +-- open per signed-in member, and that would be the first thing on this box +-- with a real scaling limit. +-- +-- Membership is its own table rather than two columns on `conversations` +-- because the unread mark is per person: each side keeps its own +-- `last_read_at`, and the badge counts messages newer than it that somebody +-- else wrote. Two columns would need two last-read fields and a rule about +-- which is which. +CREATE TABLE IF NOT EXISTS conversations ( + id INTEGER PRIMARY KEY, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS conversation_members ( + conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + member_id INTEGER NOT NULL REFERENCES members(id), + last_read_at TEXT, + PRIMARY KEY (conversation_id, member_id) +); + +CREATE INDEX IF NOT EXISTS conversation_members_member + ON conversation_members(member_id); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY, + conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + author_id INTEGER NOT NULL REFERENCES members(id), + body_md TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT +); + +CREATE INDEX IF NOT EXISTS messages_conversation + ON messages(conversation_id, created_at) WHERE deleted_at IS NULL; + +-- Blocking is symmetric: one row stops messages in both directions. +-- +-- The alternative — the blocker may still write, the blocked may not reply — +-- turns a safety feature into a one-way megaphone, which is worse than not +-- having one. Somebody who blocks a person and then wants to talk to them can +-- unblock. The CHECK is there because blocking yourself is meaningless and +-- would quietly disable your own inbox. +CREATE TABLE IF NOT EXISTS blocks ( + blocker_id INTEGER NOT NULL REFERENCES members(id), + blocked_id INTEGER NOT NULL REFERENCES members(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (blocker_id, blocked_id), + CHECK (blocker_id <> blocked_id) +); + +CREATE INDEX IF NOT EXISTS blocks_blocked ON blocks(blocked_id); + +-- Pictures attached to a thread, a comment or a private message. -- -- The file itself lives in /data/uploads; this is the record of what it is and -- what it belongs to. `stored_name` is generated, never the name the browser @@ -69,17 +124,20 @@ CREATE TABLE IF NOT EXISTS attachments ( id INTEGER PRIMARY KEY, thread_id INTEGER REFERENCES threads(id), comment_id INTEGER REFERENCES comments(id), + message_id INTEGER REFERENCES messages(id), stored_name TEXT NOT NULL UNIQUE, original_name TEXT NOT NULL, content_type TEXT NOT NULL, bytes INTEGER NOT NULL, uploaded_by INTEGER NOT NULL REFERENCES members(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), - CHECK ((thread_id IS NULL) <> (comment_id IS NULL)) + CHECK ((thread_id IS NOT NULL) + (comment_id IS NOT NULL) + + (message_id IS NOT NULL) = 1) ); CREATE INDEX IF NOT EXISTS attachments_thread ON attachments(thread_id); CREATE INDEX IF NOT EXISTS attachments_comment ON attachments(comment_id); +CREATE INDEX IF NOT EXISTS attachments_message ON attachments(message_id); -- Gitea access tokens for the editor. -- diff --git a/apps/board/templates/base.html b/apps/board/templates/base.html index 594908d..ada599c 100644 --- a/apps/board/templates/base.html +++ b/apps/board/templates/base.html @@ -31,6 +31,11 @@