vienalatina/apps/board/schema.sql
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

212 lines
9.2 KiB
SQL

-- Members area schema.
--
-- Applied at every startup and written to be idempotent, so deploying a new
-- version of the app needs no migration step for as long as the schema only
-- grows. A change that alters an existing column will need a real migration;
-- there is deliberately no framework here to pretend otherwise.
CREATE TABLE IF NOT EXISTS members (
id INTEGER PRIMARY KEY,
-- COLLATE NOCASE because Gitea treats logins case-insensitively; without it
-- "Pablo" and "pablo" would be two members with one Gitea account.
gitea_login TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
password_hash TEXT,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'user', 'tombstone')),
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
created_by INTEGER REFERENCES members(id),
last_seen_at TEXT
);
-- The one-owner rule, held by the database rather than by the application, so
-- a mistake in a handler cannot produce a second owner. SQLite enforces a
-- partial unique index exactly like a full one.
CREATE UNIQUE INDEX IF NOT EXISTS members_one_owner
ON members(role) WHERE role = 'owner';
CREATE TABLE IF NOT EXISTS threads (
id INTEGER PRIMARY KEY,
author_id INTEGER NOT NULL REFERENCES members(id),
title TEXT NOT NULL,
body_md TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
edited_at TEXT,
pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)),
locked INTEGER NOT NULL DEFAULT 0 CHECK (locked IN (0, 1)),
-- Soft delete: a moderator's mistake stays recoverable, and removing one
-- comment does not tear a hole in the conversation around it.
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS threads_live
ON threads(pinned DESC, created_at DESC) WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY,
thread_id INTEGER NOT NULL REFERENCES threads(id),
author_id INTEGER NOT NULL REFERENCES members(id),
body_md TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
edited_at TEXT,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS comments_thread
ON comments(thread_id, created_at) WHERE deleted_at IS NULL;
-- Failed sign-ins, kept only long enough to slow a guesser down.
--
-- The identifier is whatever was typed in the first box, lowercased — which
-- may be a username, an address, or nonsense. It is deliberately not tied to a
-- member row: the whole point is to count attempts against names that do not
-- exist as well as ones that do.
CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY,
identifier TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS login_attempts_recent
ON login_attempts(identifier, created_at);
-- 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
-- sent, and is UNIQUE because it is also the URL.
--
-- The CHECK is the shape of the thing: an attachment hangs off exactly one of
-- the two, never both and never neither. Without it a row with both columns
-- set would be served under whichever parent was still alive, which is a
-- quiet way for a deleted thread's photo to stay readable.
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 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);
-- The index on message_id is NOT here, and that is not an oversight.
-- CREATE INDEX IF NOT EXISTS guards the index NAME, not the column: run it
-- against a database whose attachments table predates message_id and it fails
-- with "no such column", taking the whole start-up with it. Any index on a
-- column a migration introduces belongs in that migration, after the column
-- exists. See migrations.py.
-- There is no table here for the editor's credentials, and that is the point.
-- Members sign in against password_hash above; the editor commits with one
-- server-side token from the environment. The old gitea_tokens table held an
-- OAuth access and refresh token per member, and migration 2 drops it — so it
-- must not be recreated here, or every restart would put it back and the drop
-- would only have worked once.
-- Frontmatter of content files, keyed by the git blob sha.
--
-- Listing a folder through Gitea's contents API returns names and shas but no
-- bodies, so showing titles and dates means fetching every file. Caching on the
-- sha turns that from one request per post on every page load into one request
-- in total, because a sha changes only when the file does. Nothing needs
-- invalidating: a row is only ever read for a path the listing still returns.
CREATE TABLE IF NOT EXISTS content_cache (
path TEXT PRIMARY KEY,
sha TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
date TEXT NOT NULL DEFAULT '',
categories TEXT NOT NULL DEFAULT '',
-- Files carrying `translated_from` are the pipeline's output, not anyone's
-- draft. Recorded here so the listing can skip them without re-reading
-- every file to find out what it already knew.
generated INTEGER NOT NULL DEFAULT 0 CHECK (generated IN (0, 1)),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- One-time links: invitations to set a first password, and password resets.
--
-- A token here is enough to take over an account, so only its SHA-256 lives in
-- this table. A database backup that leaks is then a list of useless hashes
-- rather than a set of live keys.
--
-- SHA-256 rather than a password hash on purpose: these are 32 random bytes
-- from secrets.token_urlsafe, not something a person chose. There is no
-- dictionary to run against them, so the slow hashing that protects weak
-- passwords buys nothing and costs a round trip on every click.
CREATE TABLE IF NOT EXISTS invites (
id INTEGER PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
purpose TEXT NOT NULL CHECK (purpose IN ('invite', 'reset')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
used_at TEXT
);
CREATE INDEX IF NOT EXISTS invites_open
ON invites(member_id, purpose) WHERE used_at IS NULL;