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
This commit is contained in:
parent
9355121b7c
commit
ea17bf3273
@ -201,6 +201,45 @@ def new():
|
|||||||
role_label=ROLE_LABELS[role])
|
role_label=ROLE_LABELS[role])
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/miembros/<int:member_id>/invitar", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def invite(member_id: int):
|
||||||
|
"""Send a fresh invitation to somebody who is already a member.
|
||||||
|
|
||||||
|
This exists because creating the account and inviting the person were one
|
||||||
|
action, and there are several ordinary ways to end up needing only the
|
||||||
|
second: the admin made the account by hand in the account server and added
|
||||||
|
the member with the box unticked, the mail failed the first time, the link
|
||||||
|
sat unopened for more than a week, or the address was wrong and has been
|
||||||
|
corrected. Before this, every one of those left a member with an account
|
||||||
|
nobody knows the password to and no way to reach it.
|
||||||
|
|
||||||
|
Issuing a new token invalidates the previous one, which `invites.issue`
|
||||||
|
already guarantees — so a link that has been forwarded, or is sitting in a
|
||||||
|
mailbox somebody else can read, stops working the moment a new one is sent.
|
||||||
|
"""
|
||||||
|
target = _load(member_id)
|
||||||
|
if not target["email"]:
|
||||||
|
flash(f"{target['display_name']} no tiene correo. Añádelo primero.", "error")
|
||||||
|
return redirect(url_for("members.index"))
|
||||||
|
if not target["active"] or target["role"] == "tombstone":
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
link = auth.invite_url(invites.issue(member_id, "invite"))
|
||||||
|
try:
|
||||||
|
mail.send_invite(target["email"], target["display_name"], link)
|
||||||
|
except (mail.MailNotConfigured, mail.MailFailed) as exc:
|
||||||
|
# The link is shown rather than withheld: it is already issued and
|
||||||
|
# valid, and the alternative is an admin who knows only that something
|
||||||
|
# did not work.
|
||||||
|
current_app.logger.warning("Invite mail to %s failed: %s", target["email"], exc)
|
||||||
|
flash(f"No se pudo enviar el correo. Pásale este enlace: {link}", "error")
|
||||||
|
return redirect(url_for("members.index"))
|
||||||
|
|
||||||
|
flash(f"Invitación enviada a {target['email']}.", "ok")
|
||||||
|
return redirect(url_for("members.index"))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/miembros/<int:member_id>/estado", methods=["POST"])
|
@bp.route("/miembros/<int:member_id>/estado", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def set_active(member_id: int):
|
def set_active(member_id: int):
|
||||||
|
|||||||
@ -35,6 +35,19 @@
|
|||||||
<button class="linkish" type="submit">{{ 'Suspender' if m.active else 'Reactivar' }}</button>
|
<button class="linkish" type="submit">{{ 'Suspender' if m.active else 'Reactivar' }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{# Any admin can re-send, not just the owner: the usual reason
|
||||||
|
somebody needs this is that the admin who added them made
|
||||||
|
the account by hand, and waiting for the owner to be
|
||||||
|
around defeats the point. Hidden without an address,
|
||||||
|
because the handler would only refuse. #}
|
||||||
|
{% if m.email and m.active %}
|
||||||
|
<form method="post" action="{{ url_for('members.invite', member_id=m.id) }}"
|
||||||
|
data-confirm="Se enviará un enlace nuevo a {{ m.email }} y el anterior dejará de servir. ¿Seguro?">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="linkish" type="submit">Enviar invitación</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if is_owner %}
|
{% if is_owner %}
|
||||||
<form method="post" action="{{ url_for('members.set_role', member_id=m.id) }}">
|
<form method="post" action="{{ url_for('members.set_role', member_id=m.id) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|||||||
@ -384,3 +384,92 @@ def test_a_member_without_a_gitea_account_is_named_as_such(
|
|||||||
assert "404" not in page
|
assert "404" not in page
|
||||||
# And the link survives, so it still works once the account exists.
|
# And the link survives, so it still works once the account exists.
|
||||||
assert db.execute("SELECT used_at FROM invites").fetchone()["used_at"] is None
|
assert db.execute("SELECT used_at FROM invites").fetchone()["used_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- inviting somebody who is already a member ----------------------------
|
||||||
|
#
|
||||||
|
# Creating the account and inviting the person used to be one action, so a
|
||||||
|
# member added any other way had no route in at all: no invitation was ever
|
||||||
|
# issued for them and nothing could issue one later.
|
||||||
|
|
||||||
|
def test_an_existing_member_can_be_invited(app, client, post, owner_id, make_member,
|
||||||
|
sign_in, outbox):
|
||||||
|
"""The case that prompted this: the admin made the account by hand, added
|
||||||
|
the member with the box unticked, and nobody could reach the account —
|
||||||
|
including the admin, who never knew the password."""
|
||||||
|
member_id = make_member("salvador")
|
||||||
|
sign_in(owner_id)
|
||||||
|
|
||||||
|
post(f"/comunidad/miembros/{member_id}/invitar")
|
||||||
|
|
||||||
|
assert len(outbox) == 1
|
||||||
|
assert outbox[0]["to"] == "salvador@example.com"
|
||||||
|
with app.test_request_context():
|
||||||
|
assert invites.lookup(token_in(outbox[0]["body"])) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_re_inviting_kills_the_previous_link(app, client, post, owner_id, make_member,
|
||||||
|
sign_in, outbox):
|
||||||
|
"""A link that was forwarded, or is sitting in a mailbox somebody else can
|
||||||
|
read, must stop working the moment a replacement is sent."""
|
||||||
|
member_id = make_member("salvador")
|
||||||
|
sign_in(owner_id)
|
||||||
|
|
||||||
|
post(f"/comunidad/miembros/{member_id}/invitar")
|
||||||
|
post(f"/comunidad/miembros/{member_id}/invitar")
|
||||||
|
|
||||||
|
with app.test_request_context():
|
||||||
|
assert invites.lookup(token_in(outbox[0]["body"])) is None
|
||||||
|
assert invites.lookup(token_in(outbox[1]["body"])) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_admin_can_invite_without_waiting_for_the_owner(
|
||||||
|
app, client, post, make_member, sign_in, outbox):
|
||||||
|
sign_in(make_member("admina", role="admin"))
|
||||||
|
post(f"/comunidad/miembros/{make_member('salvador')}/invitar")
|
||||||
|
assert len(outbox) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plain_user_cannot_invite(client, post, make_member, sign_in, outbox):
|
||||||
|
sign_in(make_member("cualquiera"))
|
||||||
|
response = post(f"/comunidad/miembros/{make_member('salvador')}/invitar")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert outbox == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_member_with_no_address_is_refused_before_a_token_is_made(
|
||||||
|
app, client, db, post, owner_id, sign_in, outbox):
|
||||||
|
"""Issuing the token first would invalidate a previous, working invitation
|
||||||
|
in exchange for one that cannot be delivered."""
|
||||||
|
member_id = db.execute(
|
||||||
|
"INSERT INTO members (gitea_login, display_name, role) VALUES ('sincorreo', 'Sin', 'user')"
|
||||||
|
).lastrowid
|
||||||
|
sign_in(owner_id)
|
||||||
|
|
||||||
|
post(f"/comunidad/miembros/{member_id}/invitar", follow_redirects=True)
|
||||||
|
|
||||||
|
assert outbox == []
|
||||||
|
assert db.execute("SELECT 1 FROM invites").fetchone() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_suspended_member_cannot_be_invited(client, post, owner_id, make_member,
|
||||||
|
sign_in, outbox):
|
||||||
|
sign_in(owner_id)
|
||||||
|
response = post(f"/comunidad/miembros/{make_member('fuera', active=0)}/invitar")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert outbox == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_when_the_mail_fails_the_admin_is_handed_the_link(
|
||||||
|
app, client, post, owner_id, make_member, sign_in, monkeypatch):
|
||||||
|
def explode(to, subject, body):
|
||||||
|
raise mail.MailFailed("connection refused")
|
||||||
|
monkeypatch.setattr(mail, "send", explode)
|
||||||
|
sign_in(owner_id)
|
||||||
|
|
||||||
|
page = post(f"/comunidad/miembros/{make_member('salvador')}/invitar",
|
||||||
|
follow_redirects=True).get_data(as_text=True)
|
||||||
|
|
||||||
|
assert "/comunidad/invitacion/" in page
|
||||||
|
|||||||
@ -639,14 +639,26 @@ GITEA__other__SHOW_FOOTER_VERSION=false
|
|||||||
```
|
```
|
||||||
|
|
||||||
`APP_NAME` lives in `app.ini`'s unnamed root section, which the environment
|
`APP_NAME` lives in `app.ini`'s unnamed root section, which the environment
|
||||||
mapping spells `DEFAULT`. **Check it took**, because a key written to a section
|
mapping spells `DEFAULT`.
|
||||||
that does not exist is accepted in silence:
|
|
||||||
|
**`/srv/gitea/docker-compose.yml` is a copy, and nothing kept it in step with
|
||||||
|
this repository.** That is worth stating plainly because it cost a week: every
|
||||||
|
Gitea setting added here — CORS, the theme, OpenID, the register button, the
|
||||||
|
footer — was committed and documented and never reached the server, because the
|
||||||
|
only thing that syncs a compose file is `deploy-board.sh`, and it syncs the
|
||||||
|
board's. The file on the server stays valid, the container stays healthy, and
|
||||||
|
the setting is simply absent.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd /srv/gitea && sudo docker compose up -d
|
cd ~/vienalatina && sudo bash scripts/deploy-gitea.sh
|
||||||
sudo docker compose exec gitea head -5 /data/gitea/conf/app.ini
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
That copies `infra/gitea/docker-compose.yml` across (keeping the old one as
|
||||||
|
`.bak` and printing the diff, since it may have been hand-edited), restarts,
|
||||||
|
and then **reads the settings back out of the running container** and prints
|
||||||
|
them. Treat that output as the only evidence: a line missing there is a setting
|
||||||
|
not in effect, whatever the compose file says.
|
||||||
|
|
||||||
That should show `APP_NAME = Viena Latina`. Hiding the version is the one with
|
That should show `APP_NAME = Viena Latina`. Hiding the version is the one with
|
||||||
a security argument as well as a cosmetic one: it tells a passer-by exactly
|
a security argument as well as a cosmetic one: it tells a passer-by exactly
|
||||||
which advisories to try.
|
which advisories to try.
|
||||||
@ -672,17 +684,20 @@ git pull --no-rebase --no-edit gitea main
|
|||||||
bash scripts/gitea-theme.sh
|
bash scripts/gitea-theme.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Then add the line the script prints to `/srv/gitea/docker-compose.yml` (it is
|
`GITEA__ui__DEFAULT_THEME=vienalatina` is already in
|
||||||
already in `infra/gitea/docker-compose.yml`):
|
`infra/gitea/docker-compose.yml`, so it arrives with the sync — no hand-editing
|
||||||
|
of the server's copy:
|
||||||
```
|
|
||||||
- GITEA__ui__DEFAULT_THEME=vienalatina
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd /srv/gitea && sudo docker compose up -d
|
cd ~/vienalatina && sudo bash scripts/deploy-gitea.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The script prints `DEFAULT_THEME` back out of the container. If it says
|
||||||
|
`vienalatina` and the screens are still grey, the setting is fine and the
|
||||||
|
*theme file* was never built — run `bash scripts/gitea-theme.sh` first. Those
|
||||||
|
are two different failures with one symptom, which is why the script names
|
||||||
|
both.
|
||||||
|
|
||||||
Check it in a private window at **https://git.vienalatina.com/user/login** —
|
Check it in a private window at **https://git.vienalatina.com/user/login** —
|
||||||
cream background, the Viena Latina wordmark, `#c0391c` buttons. Then browse a
|
cream background, the Viena Latina wordmark, `#c0391c` buttons. Then browse a
|
||||||
repository and open a commit: a theme that only looks right on the login page is
|
repository and open a commit: a theme that only looks right on the login page is
|
||||||
@ -807,12 +822,20 @@ behind it.
|
|||||||
### 13.4 The sign-in page loses two tabs
|
### 13.4 The sign-in page loses two tabs
|
||||||
|
|
||||||
`GITEA__openid__ENABLE_OPENID_SIGNIN=false` and
|
`GITEA__openid__ENABLE_OPENID_SIGNIN=false` and
|
||||||
`GITEA__service__SHOW_REGISTRATION_BUTTON=false` in
|
`GITEA__service__SHOW_REGISTRATION_BUTTON=false`, already in
|
||||||
`/srv/gitea/docker-compose.yml`. OpenID is sign-in with an external identity
|
`infra/gitea/docker-compose.yml`. OpenID is sign-in with an external identity
|
||||||
URL, which nobody here will use, and the register button contradicts
|
URL, which nobody here will use, and the register button contradicts
|
||||||
`DISABLE_REGISTRATION` — it invited people to try something the server then
|
`DISABLE_REGISTRATION` — it invited people to try something the server then
|
||||||
refused.
|
refused.
|
||||||
|
|
||||||
|
It also loses a third thing, the *Forgot password?* link, which goes to a page
|
||||||
|
that answers "Account recovery is disabled because no email is set up" and
|
||||||
|
always will: the SMTP details are the members area's, and this container has no
|
||||||
|
mailer and needs none. Recovery lives at **vienalatina.com/comunidad/recuperar**
|
||||||
|
and works. The link is hidden by a rule in the theme file rather than by
|
||||||
|
replacing the template, so a Gitea upgrade cannot quietly undo it — and if the
|
||||||
|
selector ever stops matching, the link reappears rather than the page breaking.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd /srv/gitea && sudo docker compose up -d
|
cd ~/vienalatina && sudo bash scripts/deploy-gitea.sh
|
||||||
```
|
```
|
||||||
|
|||||||
@ -96,3 +96,22 @@
|
|||||||
height: 26px !important;
|
height: 26px !important;
|
||||||
width: auto !important;
|
width: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The dead "Forgot password?" link on the sign-in form.
|
||||||
|
*
|
||||||
|
* It goes to /user/forgot_password, which answers "Account recovery is
|
||||||
|
* disabled because no email is set up" and always will: the SMTP details live
|
||||||
|
* in the members area's environment, and this is a different container that
|
||||||
|
* has no mailer and does not need one. Recovery is at
|
||||||
|
* vienalatina.com/comunidad/recuperar instead, which works.
|
||||||
|
*
|
||||||
|
* Every member who forgets a password reaches the sign-in form first, so
|
||||||
|
* leaving the link there means the broken route is the one they find. Hidden
|
||||||
|
* in CSS rather than by overriding the template, because a replaced template
|
||||||
|
* has to be re-checked against every Gitea release, whereas an attribute
|
||||||
|
* selector that stops matching leaves the link visible — the state we are in
|
||||||
|
* today, not a broken page.
|
||||||
|
*/
|
||||||
|
a[href$="/user/forgot_password"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|||||||
69
scripts/deploy-gitea.sh
Executable file
69
scripts/deploy-gitea.sh
Executable file
@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Put this repository's Gitea settings onto the server, and prove they landed.
|
||||||
|
#
|
||||||
|
# This script exists because there was no way to do that. `deploy-board.sh`
|
||||||
|
# syncs the members area; `/srv/gitea/` has been a hand-made copy since the day
|
||||||
|
# it was set up, so every Gitea setting added to `infra/gitea/` since then has
|
||||||
|
# been written, committed, documented — and never applied. The symptom is the
|
||||||
|
# quietest possible one: the file on the server is valid, the container is
|
||||||
|
# healthy, and the setting simply is not there.
|
||||||
|
#
|
||||||
|
# cd ~/vienalatina && git pull ...
|
||||||
|
# sudo bash scripts/deploy-gitea.sh
|
||||||
|
#
|
||||||
|
# It never touches /srv/gitea/data — that is the database, the repositories and
|
||||||
|
# app.ini, none of which belong to this repository.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
TARGET="${GITEA_DIR:-/srv/gitea}"
|
||||||
|
|
||||||
|
if [ ! -d "$TARGET" ]; then
|
||||||
|
echo "No $TARGET — this script updates an existing install, it does not create one." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Syncing compose settings into $TARGET"
|
||||||
|
# A backup, because unlike the board's this file may have been edited by hand on
|
||||||
|
# the server, and that edit is about to be overwritten. If anything in the diff
|
||||||
|
# below is a surprise, it was a local change nobody wrote down.
|
||||||
|
if [ -f "$TARGET/docker-compose.yml" ]; then
|
||||||
|
cp "$TARGET/docker-compose.yml" "$TARGET/docker-compose.yml.bak"
|
||||||
|
if ! diff -u "$TARGET/docker-compose.yml.bak" "$REPO/infra/gitea/docker-compose.yml"; then
|
||||||
|
echo " (differences above; previous file kept as docker-compose.yml.bak)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
cp "$REPO/infra/gitea/docker-compose.yml" "$TARGET/docker-compose.yml"
|
||||||
|
|
||||||
|
echo "==> Restarting"
|
||||||
|
cd "$TARGET"
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Settling time. environment-to-ini rewrites app.ini during start-up, so
|
||||||
|
# reading it back immediately can catch the previous file.
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==> What the container actually has (not what we sent it)"
|
||||||
|
# The whole point of this script. Every failure this session has been a setting
|
||||||
|
# that was accepted somewhere and read by nobody, so the last word belongs to
|
||||||
|
# the running container rather than to a file we just copied.
|
||||||
|
docker compose exec -T gitea sh -c '
|
||||||
|
echo "--- APP_NAME ---"
|
||||||
|
grep -m1 "^APP_NAME" /data/gitea/conf/app.ini || echo "APP_NAME: not set (Gitea will show its own name)"
|
||||||
|
echo "--- [other] ---"
|
||||||
|
sed -n "/^\[other\]/,/^\[/p" /data/gitea/conf/app.ini | grep -v "^\[" || echo "no [other] section"
|
||||||
|
echo "--- sign-in extras ---"
|
||||||
|
grep -E "^(ENABLE_OPENID_SIGNIN|SHOW_REGISTRATION_BUTTON|DEFAULT_THEME)" /data/gitea/conf/app.ini \
|
||||||
|
|| echo "none of ENABLE_OPENID_SIGNIN / SHOW_REGISTRATION_BUTTON / DEFAULT_THEME are set"
|
||||||
|
' || echo "!! Could not read app.ini — is the container up? docker compose logs gitea"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Expected: APP_NAME = Viena Latina, SHOW_FOOTER_POWERED_BY = false,"
|
||||||
|
echo "SHOW_FOOTER_VERSION = false, ENABLE_OPENID_SIGNIN = false,"
|
||||||
|
echo "SHOW_REGISTRATION_BUTTON = false, DEFAULT_THEME = vienalatina."
|
||||||
|
echo
|
||||||
|
echo "A line that is missing above is a setting that is NOT in effect, whatever"
|
||||||
|
echo "the compose file says. If DEFAULT_THEME is set but the screens are still"
|
||||||
|
echo "grey, the theme file was never built: bash scripts/gitea-theme.sh"
|
||||||
Loading…
Reference in New Issue
Block a user