Commit Graph

4 Commits

Author SHA1 Message Date
Claude
0c4a98b0e0
Stop offering a logout button that was never a logout
"Cerrar sesión del todo" linked straight to /user/logout on the account
server. That route is POST-only — verified in Gitea 1.22's router, which
registers m.Post("/logout", auth.SignOut) and no GET at all — so the
click was a GET, the answer was 404, and the session it promised to end
carried on untouched. It shipped in 724dada, in the commit whose message
was about not overstating what Salir does.

It cannot be repaired by turning the link into a form: the POST needs a
CSRF token belonging to that other domain, unreadable from here by
design. So the page stops pretending and gives the instruction — go
there, open the profile menu, choose Cerrar sesión — which is what the
small print underneath already said.

server-setup.md contained the whole answer and contradicted itself: one
paragraph states the logout is POST-only "so a link cannot trigger it",
and two paragraphs later promises "the link that finishes the job". The
code followed the wrong half.

Worse, a test asserted "/user/logout" in page, so the suite was
enforcing the defect rather than catching it. That assertion is now
inverted, and a template guard fails the build if any template links
there again.

199 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
2026-09-28 15:43:32 +00:00
Claude
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
2026-09-25 16:28:10 +00:00
Claude
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
2026-09-25 15:07:31 +00:00
Claude
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
2026-09-22 10:57:18 +00:00