b90cbd2a9f
7 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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 |
||
|
|
e11016a8e9
|
Refuse before the form, not after the password
A member followed his invitation link, chose a password, typed it twice, pressed save, and was shown the name of an environment variable. The server knew from the first byte of that request that it could not save anything: without GITEA_ADMIN_TOKEN it cannot set a password in Gitea. It asked him to do the work anyway. Three places had the same shape, all now checked up front through a new gitea.admin_configured(), mirroring mail.configured(): - the invitation page answers 503 with an explanation and no password field, identically for a real and an invented token so it cannot be used to probe for live ones - /recuperar refuses instead of mailing a link to a page that could only apologise — and its deliberately identical answer would have hidden that from the admin as well as the member - the sign-in page stops offering recovery it cannot complete Also: a 404 from admin_set_password now names the real cause. A member added without "crear también su cuenta" has no Gitea account, so the password change is aimed at nothing, and "Gitea rechazó el cambio de contraseña (404)" blames Gitea for an account that was never made. deploy-board.sh warns about settings that are present but empty. The previous check looked for missing names, and GITEA_ADMIN_TOKEN= has a name — which is why the deploy that led to this said nothing. 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
|
||
|
|
724dada3f4
|
Brand Gitea's auth screens, and stop Salir overstating itself
Two halves of the same complaint: the journey into the members area does not feel like one platform. Members sign in to /comunidad/ through Gitea, so Gitea's sign-in form and authorize dialog are part of everyone's journey, not just of anyone who opens a repository. That is what the earlier "is Gitea only for admins?" question got wrong. Unthemed they are two dark screens in the middle of a cream site. Unlike Decap, Gitea supports being themed, so this needs no forking and nothing a release can silently undo. The repository holds only the colour overrides; scripts/gitea-theme.sh concatenates them onto the light theme it reads out of the running container. Keeping somebody else's stylesheet in here would go stale and turn every Gitea upgrade into a merge — the script is re-run instead, which is one line in the upgrade notes. Getting a variable name wrong leaves a corner grey rather than breaking a page, which is the failure mode worth having when the names belong to someone else's project. The logo is the site's wordmark, as live text in the same font stack rather than traced to paths: the site loads no webfont at all, deliberately, so visitors already see it in whatever sans-serif their machine substitutes. Paths would render a Montserrat most people never see on the site itself. Salir was the sharper problem. It cleared this session and the stored token, and then said "Sesión cerrada" — while the Gitea session in the same browser stayed open and Gitea still remembered the authorisation, so one click signed the next person straight back in with no password. On a laptop shared around an association, that button was lying. It cannot be fixed from here. Gitea's logout has been POST-only since 1.11.2, so a link cannot trigger it and a cross-site POST needs a CSRF token this app does not have; prompt=login is undocumented in every released version of Gitea's OAuth2 provider, and a security control should not rest on that. So the logout page now says exactly what is closed and what is not, and links to the one place that finishes it. Being honest about a limit beats a reassuring message that is false. Verified: 101 checks, two of them new — logout warns rather than redirecting, and leaves no token the server could still act with. The theme itself is visual and has to be looked at; docs/server-setup.md §12 says where. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn |
||
|
|
635372d7a2
|
Write the site from the members area instead of Decap
Decap looks like a different product because it cannot be made to look like this one: its maintainer's answer is override the CSS and accept that class names move between releases, or fork it and carry that forever. Neither is worth building on, so editing moves into the app that already has the site's design and this project's tests. Posts are files in git, so the editor is a form that commits a file through Gitea's contents API. No working copy, no queue, no new state, and no way for the pipeline to tell which editor wrote the file — which is what makes running both at once safe. Decap keeps working; nothing here removes it, and the fallback if something is missing is switching tabs. Filenames and frontmatter are not this module's to invent. They are the contract scripts/translate.py reads: `<basename>.es.md` is what split_lang() parses, the date prefix is what keeps two posts with one title off a single URL, and the absence of `translated_from` is what marks a file as something a person wrote. Several tests import translate.py and run its own parser over what the editor produced, because a file it cannot parse publishes in Spanish and is never translated, with nothing reported anywhere. Commits carry the writer's own Gitea account rather than a bot's, so history says who wrote each post and Gitea's permissions apply unchanged. That needs their access token, which lives in the database and never in a cookie: Flask signs cookies but does not encrypt them, and a token is enough to commit as its owner. Gitea expires tokens after about an hour, so they refresh ahead of expiry and retry once on rejection — without that, saving would start failing partway through an afternoon for no reason the writer could see. Publishing is admin-only. Posting to the internal board and publishing to the public site are different permissions, and the second is the larger grant. Listing caches frontmatter against the git blob sha, because the contents API returns names without bodies: that turns one request per post on every page load into one request in total, and needs no invalidation, since a sha changes only when the file does. Verified: 97 checks. The filename translate.py parses, an authored source that is_generated() rejects, the freeze toggle round-tripping, a stale sha refused with the other edit intact, six filename shapes that must 404 including a generated sibling, plain members refused, an SVG rejected as an image, uploads not colliding, the cache reading each file once and refreshing when it changes, and a preview that renders markdown, escapes script tags and publishes nothing. Smoke-tested live: routes register and gate correctly. 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 |