diff --git a/apps/board/auth.py b/apps/board/auth.py index f2b76b2..17d6660 100644 --- a/apps/board/auth.py +++ b/apps/board/auth.py @@ -136,5 +136,20 @@ def logout(): if g.member is not None: tokens.forget(g.member["id"]) session.clear() - flash("Sesión cerrada.", "ok") - return redirect(url_for("auth.login")) + + # Deliberately NOT a redirect back to the login page. + # + # Clearing this session does not touch the Gitea session in the same + # browser, and Gitea remembers that this app was authorised. So the next + # click on "Entrar con Gitea" gets a code back immediately and signs the + # person straight back in without a password — which on a laptop shared + # around an association means "Salir" was telling them something untrue. + # + # Gitea cannot be signed out from here: its logout has been POST-only since + # 1.11.2, and a cross-site POST would need Gitea's CSRF token. `prompt=login` + # would be the other way round it, and is undocumented in every released + # version of Gitea's OAuth2 provider — not something to rest this on. + # + # So the honest thing is to say so and point at the one place that can + # finish the job. + return render_template("logged_out.html", gitea_url=current_app.config["GITEA_URL"].rstrip("/")) diff --git a/apps/board/templates/logged_out.html b/apps/board/templates/logged_out.html new file mode 100644 index 0000000..95b8318 --- /dev/null +++ b/apps/board/templates/logged_out.html @@ -0,0 +1,32 @@ +{# Shown instead of bouncing back to the login page, because bouncing back would + hide the fact that one click gets you straight in again. #} +{% extends "base.html" %} +{% block title %}Sesión cerrada{% endblock %} + +{% block main %} +
+

Saliste del área de la comunidad

+ +

+ Tu sesión aquí está cerrada. Pero este navegador sigue conectado a + Gitea, que es donde se guardan las cuentas — así que quien use + este ordenador después podría volver a entrar sin contraseña. +

+ +

+ Cerrar sesión también en Gitea +

+ +

+ Si esa página no te desconecta, abre el menú de tu perfil en + Gitea y elige Sign Out. + Cerrar el navegador también sirve. +

+ +

+ En tu propio ordenador no hace falta: puedes volver a entrar cuando quieras. +

+ +

Volver a entrar

+
+{% endblock %} diff --git a/apps/board/tests/test_auth.py b/apps/board/tests/test_auth.py index ae5a652..a5985d4 100644 --- a/apps/board/tests/test_auth.py +++ b/apps/board/tests/test_auth.py @@ -106,3 +106,41 @@ def test_login_redirect_cannot_be_pointed_offsite(client, monkeypatch, make_memb def test_responses_say_do_not_index(client): response = client.get("/comunidad/login") assert response.headers["X-Robots-Tag"] == "noindex, nofollow" + + +def test_logout_says_the_gitea_session_is_still_open(client, db, make_member, sign_in, post): + """Redirecting to the login page would hide the problem: one click on + "Entrar con Gitea" signs you straight back in, because Gitea's session and + its record of the authorisation both survive.""" + member_id = make_member("maria") + db.execute( + "INSERT INTO gitea_tokens (member_id, access_token) VALUES (?, 'tok')", + (member_id,), + ) + sign_in(member_id) + + response = post("/comunidad/logout") + page = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "sigue conectado" in page # the warning, not a redirect + assert "/user/logout" in page + + with client.session_transaction() as session: + assert "member_id" not in session + assert db.execute("SELECT 1 FROM gitea_tokens WHERE member_id = ?", + (member_id,)).fetchone() is None + + +def test_logout_leaves_nothing_the_server_can_act_with(client, db, make_member, sign_in, post): + """The token is what lets this server commit as the member. Clearing the + cookie without dropping it would end the browser's access but not ours.""" + member_id = make_member("maria") + db.execute( + "INSERT INTO gitea_tokens (member_id, access_token) VALUES (?, 'tok')", + (member_id,), + ) + sign_in(member_id) + post("/comunidad/logout") + + assert db.execute("SELECT COUNT(*) AS n FROM gitea_tokens").fetchone()["n"] == 0 diff --git a/docs/server-setup.md b/docs/server-setup.md index bf57b19..fae2d64 100644 --- a/docs/server-setup.md +++ b/docs/server-setup.md @@ -501,3 +501,76 @@ Not urgent — leaving it costs a folder and one `wget` in the pipeline: 1. `rm -rf static/admin/` 2. Delete the `wget … decap-cms.js` line from `.woodpecker.yml` 3. Delete the `decap-cms` OAuth application in Gitea + +## 12. Make Gitea look like the site + +Members sign in to `/comunidad/` through Gitea, so Gitea's sign-in form and its +authorize dialog are part of the journey for everyone — not just for you, and +not just for people who open a repository. Unthemed they are two dark screens in +the middle of a cream-coloured site. + +How often anyone sees them is worth knowing before judging the result: the +**authorize dialog appears once per person, ever** — Gitea remembers the grant — +and the **sign-in form only when their Gitea session has lapsed**, which +"Remember This Device" pushes out to weeks. This is a first-impression fix. + +Unlike Decap, Gitea supports this properly: a theme is a CSS file in a directory +it already reads. + +```sh +cd ~/vienalatina +git pull --no-rebase --no-edit gitea main +bash scripts/gitea-theme.sh +``` + +Then add the line the script prints to `/srv/gitea/docker-compose.yml` (it is +already in `infra/gitea/docker-compose.yml`): + +``` +- GITEA__ui__DEFAULT_THEME=vienalatina +``` + +```sh +cd /srv/gitea && sudo docker compose up -d +``` + +Check it in a private window at **https://git.vienalatina.com/user/login** — +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 +half done. + +### Re-run it after every Gitea upgrade + +The theme is Gitea's own light theme with our colours appended, and the base is +read out of the running container so it matches the installed version. A new +Gitea release can introduce variables our overrides do not mention, and a base +frozen in the repository would drift out of date in ways nobody notices until a +page looks wrong. + +```sh +bash scripts/gitea-theme.sh +cd /srv/gitea && sudo docker compose restart gitea +``` + +Nothing breaks if you forget — an unknown variable is a declaration nobody +reads, so the worst case is a corner that stays grey. + +### Signing out is two steps, and the app says so + +Clicking **Salir** in the members area closes that session and deletes the +stored Gitea token. It cannot close the **Gitea** session in the same browser, +and Gitea remembers that the app was authorised — so without saying anything, +the next click on *Entrar con Gitea* would sign the person straight back in with +no password. On a laptop shared around the association, that is a button that +lies. + +Gitea cannot be signed out from another site: its logout has been POST-only +since 1.11.2, so a link cannot trigger it and a cross-site POST would need +Gitea's CSRF token. The `prompt=login` parameter that would force +re-authentication is undocumented in every released version of Gitea's OAuth2 +provider, and a security control should not rest on that. + +So the logout page says plainly what is and is not closed, and offers the link +that finishes the job. On a shared computer, use it — or close the browser, +which also works. The members-area cookie is already a browser-session cookie, +so it does not survive that either way. diff --git a/infra/gitea/assets/logo.svg b/infra/gitea/assets/logo.svg new file mode 100644 index 0000000..ec506d3 --- /dev/null +++ b/infra/gitea/assets/logo.svg @@ -0,0 +1,24 @@ + + + Viena Latina + + Viena + Latina + + diff --git a/infra/gitea/docker-compose.yml b/infra/gitea/docker-compose.yml index 1caa404..1398553 100644 --- a/infra/gitea/docker-compose.yml +++ b/infra/gitea/docker-compose.yml @@ -23,6 +23,13 @@ services: - GITEA__cors__ALLOW_DOMAIN=https://vienalatina.com - GITEA__cors__METHODS=GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS - GITEA__cors__HEADERS=Content-Type,User-Agent,Authorization + + # Members reach /comunidad/ through Gitea's sign-in and authorize + # screens, so those are part of the site's journey even for people + # who never open a repository. DEFAULT_THEME is what anonymous + # visitors get, which is exactly those two pages. + # Install the theme first: bash scripts/gitea-theme.sh + - GITEA__ui__DEFAULT_THEME=vienalatina volumes: - ./data:/data - /etc/timezone:/etc/timezone:ro diff --git a/infra/gitea/theme-vienalatina.overrides.css b/infra/gitea/theme-vienalatina.overrides.css new file mode 100644 index 0000000..eb8b4e7 --- /dev/null +++ b/infra/gitea/theme-vienalatina.overrides.css @@ -0,0 +1,98 @@ +/* Viena Latina colours for Gitea. + * + * Deltas only. scripts/gitea-theme.sh concatenates Gitea's own light theme — + * read out of the running container, so it matches the installed version — and + * then this file. Shipping the full theme instead would mean a copy of + * somebody else's stylesheet going stale in our repository, and an upgrade + * turning into a merge. + * + * Why this exists: members sign in to /comunidad/ through Gitea, so Gitea's + * sign-in form and authorize dialog are part of the journey whether or not + * anyone ever browses a repository. Unthemed, they are two dark screens in the + * middle of a cream-coloured site, and the platform stops feeling like one + * thing. + * + * The values come from themes/vienalatina/assets/css/main.css. If the brand + * colours change there, change them here too — Hugo fingerprints its CSS and + * Gitea serves this as a static file, so there is no way to share one source. + * + * Safe failure mode: anything Gitea renames is simply a declaration nobody + * reads, and that part keeps the light theme's value. The result of getting a + * variable name wrong is a corner that stays grey, not a broken page. + */ + +:root { + /* Brand — #c0391c, with the darker and lighter steps Gitea expects for + hover, active and focus states. */ + --color-primary: #c0391c; + --color-primary-contrast: #ffffff; + --color-primary-dark-1: #ad331a; + --color-primary-dark-2: #9a2a0f; + --color-primary-dark-3: #86240d; + --color-primary-dark-4: #731f0b; + --color-primary-dark-5: #5f1a09; + --color-primary-dark-6: #4c1507; + --color-primary-dark-7: #380f05; + --color-primary-light-1: #cd5137; + --color-primary-light-2: #d66a53; + --color-primary-light-3: #de836f; + --color-primary-light-4: #e69c8b; + --color-primary-light-5: #eeb5a7; + --color-primary-light-6: #f6cec3; + --color-primary-light-7: #fbe2db; + --color-primary-alpha-10: #c0391c1a; + --color-primary-alpha-20: #c0391c33; + --color-primary-alpha-30: #c0391c4d; + --color-primary-alpha-40: #c0391c66; + --color-primary-alpha-50: #c0391c80; + --color-primary-alpha-60: #c0391c99; + --color-primary-alpha-70: #c0391cb3; + --color-primary-alpha-80: #c0391ccc; + --color-primary-alpha-90: #c0391ce6; + --color-primary-hover: #ad331a; + --color-primary-active: #9a2a0f; + + /* Page and panels — the site's cream background and white cards. */ + --color-body: #f8f3ef; + --color-box-body: #ffffff; + --color-box-body-highlight: #fdf1ed; + --color-box-header: #f2ebe6; + --color-secondary-bg: #f2ebe6; + --color-menu: #ffffff; + --color-card: #ffffff; + --color-markup-code-block: #f2ebe6; + + /* Type — the same near-black and muted brown the site uses. */ + --color-text: #1a0d09; + --color-text-dark: #1a0d09; + --color-text-light: #5c3d37; + --color-text-light-1: #6b4a43; + --color-text-light-2: #7d5a52; + --color-text-light-3: #9a7870; + + /* Borders — warm rather than the default neutral grey, which is most of + what makes an unthemed Gitea read as "a different website". */ + --color-secondary: #e0cec8; + --color-secondary-dark-1: #d3bdb6; + --color-secondary-light-1: #ebdcd7; + --color-light-border: #e0cec8; + --color-input-border: #c2a89f; + --color-input-background: #ffffff; + --color-input-text: #1a0d09; + + /* The top bar, which is the first thing anyone sees. */ + --color-nav-bg: #ffffff; + --color-nav-hover-bg: #fdf1ed; + --color-nav-text: #1a0d09; + --color-header-wrapper: #ffffff; + --color-footer: #f8f3ef; + + --color-accent: #c0391c; + --color-small-accent: #fbe2db; +} + +/* The wordmark is wide, unlike Gitea's square cup, so it needs room. */ +.logo { + height: 26px !important; + width: auto !important; +} diff --git a/scripts/gitea-theme.sh b/scripts/gitea-theme.sh new file mode 100755 index 0000000..6e7924a --- /dev/null +++ b/scripts/gitea-theme.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Install (or reinstall) the Viena Latina theme for Gitea. +# +# Members sign in to /comunidad/ through Gitea, so Gitea's sign-in form and +# authorize dialog are part of the journey whether or not anyone ever browses a +# repository. This makes those two screens look like the rest of the platform. +# +# bash scripts/gitea-theme.sh +# +# Run it again after every Gitea upgrade. The theme is built by concatenating +# Gitea's own light theme with our overrides, so the base has to come from the +# version that is actually installed — a new release can add variables, and a +# copy frozen in this repository would slowly drift out of date in ways nobody +# would notice until a page looked wrong. + +set -euo pipefail + +GITEA_DIR="${GITEA_DIR:-/srv/gitea}" +REPO_DIR="${REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +SERVICE="${SERVICE:-gitea}" + +OVERRIDES="$REPO_DIR/infra/gitea/theme-vienalatina.overrides.css" +LOGO="$REPO_DIR/infra/gitea/assets/logo.svg" + +CUSTOM="$GITEA_DIR/data/gitea" # GITEA_CUSTOM inside the container is /data/gitea +CSS_DIR="$CUSTOM/public/assets/css" +IMG_DIR="$CUSTOM/public/assets/img" + +for file in "$OVERRIDES" "$LOGO"; do + [ -f "$file" ] || { echo "Missing $file" >&2; exit 1; } +done + +echo "== reading the installed Gitea's light theme" +cd "$GITEA_DIR" +BASE="$(docker compose exec -T "$SERVICE" \ + cat /app/gitea/public/assets/css/theme-gitea-light.css)" + +if [ -z "$BASE" ]; then + echo "Could not read Gitea's own theme — is the container running?" >&2 + echo "Check with: cd $GITEA_DIR && docker compose ps" >&2 + exit 1 +fi + +sudo mkdir -p "$CSS_DIR" "$IMG_DIR" + +echo "== writing theme-vienalatina.css" +{ + printf '/* Built by scripts/gitea-theme.sh on %s.\n' "$(date -u +%FT%TZ)" + printf ' Gitea theme-gitea-light.css + infra/gitea/theme-vienalatina.overrides.css\n' + printf ' Do not edit here: rerun the script. */\n' + printf '%s\n' "$BASE" + cat "$OVERRIDES" +} | sudo tee "$CSS_DIR/theme-vienalatina.css" > /dev/null + +echo "== writing the logo and favicon" +# logo.svg is the header mark, favicon.svg the tab icon. Gitea also looks for +# PNG fallbacks; without them it falls back to its own, which is why the tab +# can still show a cup in older browsers. Converting needs a tool this box does +# not have, and an SVG favicon covers everything current. +sudo cp "$LOGO" "$IMG_DIR/logo.svg" +sudo cp "$LOGO" "$IMG_DIR/favicon.svg" + +sudo chown -R 1000:1000 "$CUSTOM/public" + +echo +echo "Installed. Now make it the default (once):" +echo " add GITEA__ui__DEFAULT_THEME=vienalatina to $GITEA_DIR/docker-compose.yml" +echo " then cd $GITEA_DIR && sudo docker compose up -d" +echo +echo "Already set? A restart is enough to pick up the new file:" +echo " cd $GITEA_DIR && sudo docker compose restart $SERVICE"