73ad25a15a
8 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5ab9e3cab5
|
Phase C: private messages, with blocking and photos
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
|
||
|
|
c9c549ed22
|
Let a member who posted a picture be erased
Deleting a member returned 500. The cause is mine and hours old: attachments.uploaded_by is NOT NULL and does not cascade, so once somebody has posted a photo SQLite refuses to delete their row. erase_member knew about threads, comments and created_by — every table that existed when it was written — and Phase B added a fourth without telling it. Reproduced before fixing: a member with a thread erases cleanly, the same member with an attachment raises FOREIGN KEY constraint failed. The picture is reassigned to the tombstone rather than deleted, the same rule the words around it already follow: the thread survives as "Miembro eliminado" and keeps its shape. Removing the picture means deleting the post it hangs off. The lists of columns to reassign and to clear are now module constants that erase_member iterates, and a test reads the live schema with PRAGMA foreign_key_list and fails if any table points at members(id), does not cascade, and is not in them. Checked against the pre-fix lists: it names attachments.uploaded_by. The next table to be added will fail a test instead of a button — and this one failed at the moment somebody exercised a right they are entitled to, which is the worst time to find out. 201 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn |
||
|
|
ea17bf3273
|
Give Gitea a deploy path, and invitations a second chance
Two findings from one screenshot of a dead forgot-password page. The page is Gitea's own, and it will always say recovery is disabled: the SMTP details are the members area's, and Gitea is a different container with no mailer and no need for one. But every member reaches Gitea's sign-in form on the way in, and that form links to it — so the broken route is the one they find first. The theme now hides the link. Ours, which works, is /comunidad/recuperar. The footer on that page still advertised the software and its version, which proves the settings added days ago never reached the server. /srv/gitea/docker-compose.yml is a copy and nothing ever synced it: deploy-board.sh syncs the board's compose file, and the Gitea directory has been hand-made since setup. CORS, the theme, OpenID, the register button, the footer — committed, documented, never applied. The file stays valid and the container stays healthy, which is why nobody noticed. scripts/deploy-gitea.sh syncs it, restarts, and then reads the settings back out of the running container and prints them, because this session has lost three separate afternoons to settings that were accepted somewhere and read by nobody. Separately: an invitation was only ever issued when the app created the account. Make the account by hand, add the member with the box unticked, and no invitation exists and none can be made — which is exactly how somebody ended up with an account nobody knew the password to. Miembros now has "Enviar invitación" on any active member with an address, for that case and for a failed send, an expired link or a corrected address. Issuing a new token voids the old one, so a forwarded link stops working. Refused before a token is issued when there is no address, since otherwise a working invitation would be spent on one that cannot be delivered. 183 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn |
||
|
|
a79e04396b
|
Stop showing members the name of the software behind the login
A member signing in has no idea what Gitea is, and for anyone this platform is ever sold to it is a competitor's name on their login page. Fourteen strings named it — a button, a logout page, an account-creation notice and eleven error messages — plus Gitea's own sign-in and authorize screens, which every member passes through. Ours are reworded: "el servidor de cuentas" where the thing has to be referred to at all, and nothing where it did not. Gitea's own screens take APP_NAME plus the two footer switches, which are supported settings rather than a patched template. APP_NAME goes in app.ini's unnamed root section, spelled DEFAULT in the environment mapping, so the docs carry a command to confirm it landed — a key written to a section that does not exist is accepted in silence. Comments, docstrings, column names and env vars keep the real name. The code has to stay honest about what it talks to, none of it reaches a browser, and renaming gitea_login would mean a migration for nothing. Two guards added, since this is the kind of thing that creeps back one error message at a time: no template renders the word outside a Jinja comment, and no string literal outside a docstring contains it. Checked against the previous commit, where they catch the one message that had already been missed by hand. Licence: MIT, no attribution-in-UI clause, and we redistribute nothing — the official image runs unmodified with its own LICENSE intact. Gitea ships the "powered by" switch itself. Reasoning recorded in §12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn |
||
|
|
ed6b568d60
|
Hand the mail settings to the container, and rebuild on deploy
Two silent failures sitting between Phase A and a working invitation. The compose file never passed MAIL_* through. Compose does not give a container the contents of .env — it only substitutes into the compose file — so the settings could be filled in correctly and read by nobody, with the app reporting mail as unconfigured and the values sitting right there on disk. test_deployment.py now fails the build whenever .env.example and docker-compose.yml drift apart, which is how this happened and how it would happen again. The app's code is baked into the image (COPY apps /srv/apps), so a pull followed by --force-recreate runs the old code and says nothing. Added scripts/deploy-board.sh: rebuild, sync the compose file, restart, print the log, and name any setting present in .env.example and missing from the live .env. It never touches .env itself. Also: "no mail server configured" is now told apart from "the mail server refused". They want different things done about them, and the first one sent an admin looking for an SMTP error that never existed. The new-member form says so before it is filled in rather than after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn |
||
|
|
e95c732c6f
|
Let members get a password of their own
Until now an admin created an account, the password appeared once on screen,
and that was the only copy. Gitea's Forgot password answers "Account recovery
is disabled because no email is set up", because this server has never been
able to send mail. Every route a member had to a password of their own was
closed, which made the members area unusable by anyone not standing next to the
owner.
Now: the admin enters a username and an email, the server creates the Gitea
account with a random password nobody ever sees — the admin included — and the
member is emailed a link to a Viena Latina page where they choose their own.
The same mechanism gives "¿Olvidaste tu contraseña?" that works, replacing
Gitea's dead page. Nobody leaves the site for either, which is possible because
PATCH /admin/users/{username} accepts a password.
A link is enough to take an account, so it is treated as a credential: single
use, short-lived, stored only as a SHA-256, and invalidated when a newer one is
issued so an older email stops working. SHA-256 rather than a password hash
because these are 32 random bytes, not something a person chose — there is no
dictionary to slow down. Recovery answers identically for a known and an
unknown address, or the form becomes a way to enumerate members one address at
a time, and stops after three tries so it cannot be used to mail-bomb somebody
using this server's reputation.
Rejecting a password deliberately does not spend the token. A typo must not
lock somebody out of an account they have never reached.
If the mail fails the admin is shown the link instead. The account exists
either way, so the difference is between a delayed invitation and a person who
simply never gets in.
Found while testing: the rate limit did not work at all. created_at is written
by SQLite as "2026-09-25 15:00:00" and I compared it against Python's
"2026-09-25T15:00:00+00:00"; a space sorts before T, so every row looked older
than any threshold and the limit silently never fired. It is now compared
inside SQLite, where the format and the clock are the same one.
Also drops two tabs from the sign-in page: OpenID, which nobody here will use,
and Register, which contradicted DISABLE_REGISTRATION and invited people to try
something the server then refused.
Verified: 126 checks. Tokens are hashed at rest, work once, expire, die when
reissued, and are refused for a suspended member; a short password and a Gitea
rejection both leave the link usable; recovery is indistinguishable for known
and unknown addresses and stops at three; creating a member emails them and
shows no password; a failed send surfaces the link.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
|
||
|
|
3bd086ab0e
|
Say accounts cannot be created before the form is filled in
Running without GITEA_ADMIN_TOKEN is the safer configuration and is documented as such, but the member form did not know: it offered "Crear también su cuenta" ticked by default and reported "Falta GITEA_ADMIN_TOKEN" only on submit, after three fields had been filled in. That is the shape of failure this project has lost the most time to — something that cannot happen, going unsaid until someone has relied on it. The form now reads the config when it renders and says so, with a link to Gitea's create-user page. The checkbox is disabled rather than hidden, because "you cannot do this here" is more use than an option that quietly is not there. The refusal itself stays in the handler: a disabled input is a courtesy, and a hand-crafted POST still meets the same error. Verified: 104 checks. The form states the limit with no token and is unchanged with one, and submitting create_account anyway still creates no member. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn |
||
|
|
6ac814475e
|
Add a members area: roles and an internal board
Everything on this site so far has been a file built from git. This is the first component that runs code to answer a request and the first whose data git does not hold, so the trade is stated in the README and the backup script is not optional. Roles are owner, admin and user. The owner is seeded once from BOARD_OWNER and cannot be seeded again, because otherwise editing a compose file would be a quieter way to take the top role than asking for it; ownership moves only by transfer, inside the app. There is exactly one owner and a partial unique index enforces it, so the invariant holds even when a handler is wrong. The owner is beyond suspension and demotion by everyone, themselves included. Only the owner makes admins; admins make users. Sign-in goes through Gitea as a confidential OAuth client — the opposite of Decap, which has to be public because it runs in the browser. The rule the whole thing rests on is that a Gitea account is not a membership: entry needs an active row in `members`, or every account on the instance is a member, starting with the translations bot. Admins can delete any post; nobody can edit anyone else's, admins included. Taking a post down is visible to its author. Quietly rewriting it is not, and an admin who could do that could leave a sentence attributed to someone who never wrote it. The plan said admins could do both; this is the one place the implementation departs from it. Markdown renders with raw HTML disabled, which is the entire XSS defence and the reason there is no sanitiser: the renderer emits only its own tags and escapes the rest. The CSP carries no 'unsafe-inline', which makes an inline onsubmit silently inert rather than broken, so the confirmation dialogs live in a static file and a test fails any template that grows an inline handler. GDPR is in scope rather than deferred: erasure removes the member row and moves their authorship to a tombstone so the conversations around them still read, and any member can download their own writing. Verified: 63 checks pass, covering the membership gate, every role predicate, a direct insert of a second owner being refused by the index, atomic ownership transfer, CSRF, an offsite login redirect, script tags rendering as text, soft deletes leaving both listings and exports, and the member screens rendering for each role. Smoke-tested live: headers, both static assets, and the bare /comunidad redirect that the Caddy matcher has to cover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn |