vienalatina/apps/board/migrations.py
Claude b90cbd2a9f
One front door: the members area owns its own logins
Three complaints, one cause. Logging out could not finish because the
session belonged to another server, whose logout is POST-only and
unreachable from here. "Ese usuario ya está en uso" for somebody absent
from Miembros, because erase_member deleted our row and left the git
account standing — two stores of users, one of them showing. And the
hand-off to a differently-designed domain, with a Forgot password that
could never work. All three followed from delegating identity, so it is
no longer delegated.

Members now sign in at /comunidad/login against a scrypt hash in our own
database, via werkzeug.security, which arrives with Flask. They have no
account on the git server at all, which makes the collision impossible
rather than fixed. Logout is one click.

This removes more than it adds: the OAuth round trip, the client
registration, tokens.py with its refresh-before-expiry logic, the
gitea_tokens table, and the logged-out page that existed to apologise
for a logout that did not log you out.

The editor keeps per-writer attribution without per-writer tokens: one
CONTENT_TOKEN commits, and each commit names its author, which Gitea's
contents API supports and a test now asserts. CONTENT_TOKEN falls back
to GITEA_ADMIN_TOKEN so nothing breaks on deploy, but it only needs
write access to one repository, while the admin token can modify every
account on the instance — and now has no remaining job.

The refusals are the interesting part. Unknown name, wrong password,
suspended member and invited-but-never-arrived all answer identically,
asserted by comparing the rendered bytes. The hash check runs against a
decoy even when there is no such member, so an unknown name does not
answer faster. Attempts are rate limited, counted inside SQLite for the
reason invites.py documents. A NULL hash never matches anything.

Migration 2 adds password_hash and drops the dead OAuth tokens. Everyone
starts NULL, including the owner, so scripts/set-password.sh exists and
was tested before this could ship: it prompts without echo, never takes
the password as an argument where ps would show it, and refuses a short
one or an unknown member.

Rehearsed against a rebuilt copy of the server's database: starts, keeps
the photo, drops the tokens table, serves a login form with no redirect,
signs in, signs out, stays out.

223 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
2026-09-29 08:25:25 +00:00

141 lines
6.6 KiB
Python

"""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" not in _columns(db, "attachments"):
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}")
# Outside the branch above: a database created fresh by schema.sql has the
# column but not this index, because schema.sql cannot carry it — see the
# note there. Both paths end up with the same table and the same indexes.
db.execute("CREATE INDEX IF NOT EXISTS attachments_message ON attachments(message_id)")
def _members_own_their_passwords(db: sqlite3.Connection) -> None:
"""Give members somewhere to keep a password, and drop the OAuth tokens.
A plain ADD COLUMN, with nothing like step 1's difficulty: `members` has no
CHECK constraint to collide with. Everyone's hash starts NULL, including
the owner's, and a NULL hash cannot be signed in with — so the way back in
is the invitation and reset machinery, which already works, or
scripts/set-password.sh when mail is having a bad day.
`gitea_tokens` holds OAuth access tokens for a flow that no longer exists.
They are not merely unused, they are credentials, and keeping credentials
that nothing can spend is a liability with no upside.
"""
if "password_hash" not in _columns(db, "members"):
db.execute("ALTER TABLE members ADD COLUMN password_hash TEXT")
db.execute("DROP TABLE IF EXISTS gitea_tokens")
# (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),
(2, "members keep their own password", _members_own_their_passwords),
]
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
# Outside the transaction, deliberately: "PRAGMA foreign_keys is a
# no-op within a transaction". Setting it inside BEGIN looks like it
# worked and changes nothing, which is how a table rebuild ends up
# running with enforcement still on.
db.execute("PRAGMA foreign_keys = OFF")
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
finally:
db.execute("PRAGMA foreign_keys = ON")
done.append(f"{number}: {description}")
return done