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
Phase B. Images on threads and comments, stored in /data/uploads —
inside the volume that already holds board.db, so there is one directory
to back up rather than two and no second mount to remember.
Not in the site repository, which is the distinction that matters: the
content editor uploads by committing, and a private photo committed
there would go through the build pipeline and out onto vienalatina.com.
The type is decided by the first bytes, not the filename. content.py
trusts the extension, which is tolerable where Hugo serves the result;
here we serve it back, so an HTML file called gato.png would be a script
running on our own origin. Five magic-number checks, no new dependency.
The uploaded name is kept only as text to show a person; the name on
disk is generated. Staging is separate from saving so a refused picture
cannot leave a half-made thread behind.
Threads and comments are soft-deleted, so the serving route checks the
parent is still live. Without it, taking a post down leaves its photo
readable by anyone who noted the URL.
And the hole this phase existed to close: backup-board.sh archived
board.db and nothing else, so the first upload would have made the
nightly backup silently incomplete while still reporting success. It now
archives the uploads directory too, verifies the archive, and names the
directory when it is empty rather than passing over it in silence.
Tested by restoring: destroyed the directory, restored from the archive,
compared bytes.
IMAGE_EXTENSIONS moves to uploads.py and content.py imports it, so the
editor and the board cannot drift apart about what counts as a picture.
46 new tests, 176 in total.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
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
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
The install script read /app/gitea/public/assets/css/theme-gitea-light.css out
of the container. The official image compiles its static assets into the gitea
binary, so that path does not exist and the script could never have worked —
it would have failed on its own "could not read Gitea's own theme" check,
which at least pointed somewhere, but at the wrong thing entirely.
Fetching the file from the running server instead works whether the assets are
embedded or on disk, and has the better property besides: what the server
serves is by definition the version that is running, which is the whole reason
the base is not kept in this repository.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
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
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
Deleting a post in Decap removes one file: the Spanish original. The German
and Portuguese versions were written by this script, so nothing else deletes
them, and they stayed on the site as posts in a language with no original —
/de/ and /pt-br/ kept listing articles that no longer exist in Spanish.
The pipeline had no concept of removal at all. changed_markdown() filters the
push diff to A and M, so a deletion never reached the translate step; a push
that only deletes posts arrived with no sources and returned early, which is
precisely the case that leaves siblings stranded.
Siblings are now reaped by scanning the content tree for a translated_from
whose source file is gone, which also clears debris from earlier runs and from
renames. A frozen sibling is reported rather than deleted: someone edited that
translation by hand, and throwing the work away on an inference is worse than
leaving one stale page until they remove it themselves. The reaper runs even
when nothing was translated, and commit_and_push stages with --all so a
vanished path commits as a deletion.
Verified: reaps both siblings of a deleted source, keeps siblings whose source
is alive, keeps a frozen orphan, never touches a human-authored original.
16/16 pipeline and 15/15 markdown checks still pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
changed_markdown() filtered the push diff to A and M. Git classifies a rename
as R, so the file renamed last night never reached the translate step: the post
published in Spanish alone, and because the language switcher only renders when
.IsTranslated is true, its page shows no ES/DE/PT links at all.
--no-renames makes git report the same change as a delete plus an add, and the
add is an ordinary translation source. Verified against a real repository: the
old filter returned nothing for a rename, the new one returns the new path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
Publishing a second "Hola mundo" through the CMS broke the first one's page:
it rendered the article, then a second copy of the entire site. Three silent
failures lined up.
Decap could not write hola-mundo.es.md twice, so it wrote hola-mundo.es-1.md.
That suffix is not a language, so Hugo stopped treating the file as a Spanish
sibling and translate.py's split_lang() skipped it — the post went live in
Spanish alone, and no German or Portuguese was ever generated. Meanwhile
permalinks used "/:slug/", and :slug falls back to the title, so both files
claimed /hola-mundo/; Hugo wrote both documents into that one index.html.
Nothing failed. The pipeline was green throughout.
Each layer now refuses its part: post permalinks carry the year and month, the
CMS prefixes new filenames with the date, and translate.py aborts on a name
ending in a clash counter rather than quietly declining to translate it. The
build also runs with --printPathWarnings --panicOnWarning, so any future pair
of pages targeting one path fails the build instead of corrupting the output.
Existing post URLs change shape (/hola-mundo/ becomes /2026/09/hola-mundo/).
That costs nothing today, with one real post and no inbound links, and gets
expensive to change later.
Verified: 16/16 pipeline checks and 15/15 markdown checks still pass, the
clash-counter name aborts with the rename instruction, and an ordinary
filename still parses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
M2M100 418M failed the quality gate on real articles. Measured on a 420-word
post: "frijoles" came back as "Beeren" (berries), "rompe la idea" as "breitet
die Idee" (spreads it — the opposite), "así sabe mi barrio" as "so weiß mein
Viertel" (knows, not tastes), and the opening sentence was not grammatical
German. Invented non-words throughout ("verforscht", "Treffenraum").
The pipeline itself was never at fault — masking held, structure survived,
zero placeholder failures across 14 files. The model was.
OPUS-MT is stronger on these specific pairs, and fits the existing CX22 with
no server upgrade, which was the constraint. Licence moves from MIT to
CC-BY-4.0, so attribution now ships with the platform.
Routes are verified against Hugging Face rather than assumed — the earlier
research could not reach HF, and half the names it guessed do not exist:
es -> de opus-mt-es-de (small, ~74M)
de -> es opus-mt-tc-big-de-es (tc-big, ~237M)
es <-> pt-br opus-mt-tc-big-itc-itc (>>pob<< / >>spa<<)
de <-> pt-br no model in either direction — pivots through Spanish
Models now live at /srv/mt-models and are mounted read-only rather than baked
into the image. Model choice has needed iteration, and a directory swap beats
a 1GB image rebuild each time. It also keeps model conversion out of the image
build, which matters on a box that OOM-killed the last conversion.
MT_PROVIDER=m2m100 still selects the old engine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
Measured against M2M100 418M on real content: the OpenNMT ⦅0⦆ convention was
dropped on every single occurrence of a protected term. SentencePiece
fragments punctuation runs, and the model then has nothing it recognises as a
unit to copy across.
The brand paid for it. "Viena Latina" came back as "Wien Latin" in one file
and "Vienna Latina" in another — the same name rendered two different wrong
ways across two pages, which is worse than being consistently wrong. "Grätzl"
survived untouched throughout, because it is genuinely unknown to the model,
whereas "Viena" reads as a city name and gets translated.
Masks are now word-shaped (Zq0Xv), which a model treats like an unknown proper
noun and carries through rather than translating. Matching is case- and
space-insensitive, since models re-case and pad these.
The tiered fallback stays as the safety net for when it is still dropped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
First real pipeline run died with:
PlaceholderError: masked span 'Viena Latina' came back 0 times
M2M100 drops placeholder tokens often enough that failing the pipeline on
mismatch would block the whole site deploy over a single proper noun. The
verification itself was right — it stopped a literal ⦅0⦆ reaching a
reader — but the policy was too blunt.
Masks are now tiered by how much they actually matter. Markup must survive;
terminology is a preference. So: try markup + terms, and on a lost term retry
guarding only markup, accepting the term may come back translated. Only if
markup itself is lost does the segment stay in the source language.
A stray placeholder or mangled URL still never reaches a reader, but one
awkward proper noun no longer blocks a publish.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
The DeepL free tier is metered (it failed in production with HTTP 456
Quota exceeded) and would require every deployment of this platform to
carry its own API account. Translation now runs on M2M100 418M (MIT) via
CTranslate2, shipped inside the pipeline image: no key, no quota, and no
content or visitor data leaving the server.
Also restores the multi-source behaviour of the original WordPress plugin,
which the Python port had narrowed to Spanish-only. Any of the three site
languages can now be the authored original.
Because any language can be a source, loop prevention is no longer
structural and is now explicit: generated siblings carry `translated_from`
and are never treated as sources, and the bot's own [skip-translate]
commits are skipped outright (that marker was already being written but
never read).
Markup protection moves in-process now that DeepL's tag_handling=html is
gone. Code blocks and raw HTML pass through untouched; link targets,
inline code and protected community terms are masked with placeholders
that are verified to survive the round trip, failing the pipeline rather
than shipping corrupted text.
Two fixes along the way:
- Generated siblings no longer inherit the source's `slug`. They did,
which meant the first retranslation of a WordPress-migrated post moved
/de/<german-slug>/ onto /de/<spanish-slug>/ and destroyed the inbound
link preservation wp-to-hugo.py exists for.
- `manual_translation` now works from the CMS. Decap only ever exposed it
on the source while the script read it on the target, so the toggle did
nothing. It now means "hands off" on both sides.
wp-to-hugo.py marks migrated Polylang siblings frozen, since those are
human translations and regenerating them would replace them with weaker
machine output.
Adds --backfill for sources missing siblings, which also fixes the
existing 404s on /de/page/acerca/ and /pt-br/page/contacto/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn