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
261 lines
10 KiB
Python
261 lines
10 KiB
Python
"""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/<int:conversation_id>")
|
|
@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/<int:conversation_id>/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/<int:member_id>", 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/<int:member_id>", 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"))
|