From 6ac814475e10d03598ec739a071329c91cbf8444 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 10:57:18 +0000 Subject: [PATCH] Add a members area: roles and an internal board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn --- .gitignore | 9 + README.md | 65 ++++-- apps/board/__init__.py | 1 + apps/board/app.py | 132 +++++++++++ apps/board/auth.py | 132 +++++++++++ apps/board/board.py | 255 +++++++++++++++++++++ apps/board/db.py | 102 +++++++++ apps/board/gitea.py | 115 ++++++++++ apps/board/members.py | 248 ++++++++++++++++++++ apps/board/render.py | 40 ++++ apps/board/requirements.txt | 12 + apps/board/schema.sql | 56 +++++ apps/board/security.py | 64 ++++++ apps/board/static/board.css | 275 +++++++++++++++++++++++ apps/board/static/board.js | 15 ++ apps/board/templates/base.html | 58 +++++ apps/board/templates/comment_form.html | 17 ++ apps/board/templates/error.html | 10 + apps/board/templates/login.html | 17 ++ apps/board/templates/member_created.html | 27 +++ apps/board/templates/member_new.html | 41 ++++ apps/board/templates/members.html | 72 ++++++ apps/board/templates/thread.html | 79 +++++++ apps/board/templates/thread_form.html | 23 ++ apps/board/templates/threads.html | 37 +++ apps/board/tests/__init__.py | 0 apps/board/tests/conftest.py | 89 ++++++++ apps/board/tests/test_auth.py | 104 +++++++++ apps/board/tests/test_board.py | 110 +++++++++ apps/board/tests/test_members.py | 184 +++++++++++++++ apps/board/tests/test_templates.py | 38 ++++ apps/board/wsgi.py | 5 + docker/board/Dockerfile | 40 ++++ docs/server-setup.md | 127 +++++++++++ infra/board/.env.example | 20 ++ infra/board/docker-compose.yml | 41 ++++ infra/caddy/Caddyfile | 11 + scripts/backup-board.sh | 44 ++++ static/robots.txt | 29 ++- 39 files changed, 2730 insertions(+), 14 deletions(-) create mode 100644 apps/board/__init__.py create mode 100644 apps/board/app.py create mode 100644 apps/board/auth.py create mode 100644 apps/board/board.py create mode 100644 apps/board/db.py create mode 100644 apps/board/gitea.py create mode 100644 apps/board/members.py create mode 100644 apps/board/render.py create mode 100644 apps/board/requirements.txt create mode 100644 apps/board/schema.sql create mode 100644 apps/board/security.py create mode 100644 apps/board/static/board.css create mode 100644 apps/board/static/board.js create mode 100644 apps/board/templates/base.html create mode 100644 apps/board/templates/comment_form.html create mode 100644 apps/board/templates/error.html create mode 100644 apps/board/templates/login.html create mode 100644 apps/board/templates/member_created.html create mode 100644 apps/board/templates/member_new.html create mode 100644 apps/board/templates/members.html create mode 100644 apps/board/templates/thread.html create mode 100644 apps/board/templates/thread_form.html create mode 100644 apps/board/templates/threads.html create mode 100644 apps/board/tests/__init__.py create mode 100644 apps/board/tests/conftest.py create mode 100644 apps/board/tests/test_auth.py create mode 100644 apps/board/tests/test_board.py create mode 100644 apps/board/tests/test_members.py create mode 100644 apps/board/tests/test_templates.py create mode 100644 apps/board/wsgi.py create mode 100644 docker/board/Dockerfile create mode 100644 infra/board/.env.example create mode 100644 infra/board/docker-compose.yml create mode 100755 scripts/backup-board.sh diff --git a/.gitignore b/.gitignore index 97a70eb..33c892d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,12 @@ resources/ static/admin/decap-cms.js __pycache__/ +.pytest_cache/ + +# Members area: secrets and live data. The .env holds the OAuth client secret +# and, where configured, a Gitea site-admin token; board.db holds every +# member's name, address and writing. +infra/board/.env +*.db +*.db-wal +*.db-shm diff --git a/README.md b/README.md index a917f92..520bc9b 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,28 @@ # Viena Latina -Static, self-hosted, trilingual blog for the Latin American community in -Vienna — Hugo + Decap CMS + Gitea + Woodpecker CI on a single Hetzner CX22 -(Nuremberg, DE). Replaces the previous WordPress + Polylang + synchronous -DeepL stack. +Self-hosted, trilingual site for the Latin American community in Vienna — +Hugo + Decap CMS + Gitea + Woodpecker CI on a single Hetzner CX22 (Falkenstein, +DE). Replaces the previous WordPress + Polylang + synchronous DeepL stack. -Translation is self-hosted too: M2M100 418M (MIT) runs on CPU via CTranslate2 -inside the pipeline image. No API key, no quota, and no visitor or content data -leaving the server. +Translation is self-hosted too: OPUS-MT (CC-BY-4.0) runs on CPU via CTranslate2 +in the pipeline image, with the models mounted from the host. No API key, no +quota, and no visitor or content data leaving the server. ``` Pablo ──► Decap CMS (/admin) ──commit──► Gitea ──webhook──► Woodpecker CI - │ - translate (M2M100, async) ──┤ - build (hugo) ─┤ - deploy (rsync) ─┘ - ▼ - Caddy 2 serves /var/www/vienalatina.com + ▲ │ + │ translate ──────┤ + OAuth2 build (hugo) ─┤ + │ deploy (rsync) ─┘ + │ ▼ + Members ──► /comunidad/ ────────────────┘ Caddy 2 serves /var/www/vienalatina.com + Flask + SQLite …and proxies /comunidad/ to Flask ``` +Everything except `/comunidad/` is a static file built from git. The members +area is the one component that runs code to answer a request, and the one whose +data is not reproducible from the repository — see **Members area** below. + ## Languages Spanish, German (`/de/`) and Brazilian Portuguese (`/pt-br/`). **Any of the @@ -139,6 +143,41 @@ client ID in `app_id`. The Decap JS bundle is downloaded at build time into `static/admin/decap-cms.js` (gitignored) — zero third-party requests at runtime. +## Members area + +`apps/board/` — a small Flask app at `/comunidad/`, behind Caddy, holding roles +and an internal message board. Signed-in members only. + +``` +apps/board/ + app.py factory, config, the CSRF and noindex hooks + auth.py Gitea OAuth2 (confidential client) and the membership gate + members.py roles, provisioning, ownership transfer, GDPR erasure/export + board.py threads and comments + render.py markdown with raw HTML disabled + schema.sql three tables and the one-owner index + tests/ pytest, 41 checks — `python3 -m pytest apps/board/tests` +``` + +Three things about it are load-bearing and easy to undo by accident: + +- **A Gitea account is not a membership.** Login succeeds only for an active row + in `members`. Drop that check and every account on the instance gets in, + starting with the translations bot. +- **One owner, enforced by a partial unique index**, not by application code. + The owner cannot be suspended or demoted by anyone; stepping down means + transferring ownership to an admin. +- **`html=False` in `render.py`** is the entire XSS defence, and it works + because markdown-it then emits only its own tags. Turning it on means owning a + sanitiser allowlist forever. + +Admins can delete anyone's post; **nobody can edit anyone else's**, admins +included. Removing a post is visible to its author, quietly rewriting it is not. + +Its SQLite database is the only state on the server that git does not hold. +`scripts/backup-board.sh` takes a consistent snapshot nightly — see +`docs/server-setup.md` §11. + ## GEO/SEO surfaces - `hreflang` + `og:locale(:alternate)` + JSON-LD `BlogPosting`/`Blog` with diff --git a/apps/board/__init__.py b/apps/board/__init__.py new file mode 100644 index 0000000..96d8f65 --- /dev/null +++ b/apps/board/__init__.py @@ -0,0 +1 @@ +"""Members area for vienalatina.com — roles and an internal message board.""" diff --git a/apps/board/app.py b/apps/board/app.py new file mode 100644 index 0000000..d0afec0 --- /dev/null +++ b/apps/board/app.py @@ -0,0 +1,132 @@ +"""Application factory for the members area. + +Served at /comunidad/ on the main domain, behind Caddy, alongside the static +Hugo output and Decap. It is the only part of vienalatina.com that runs code to +answer a request; everything else is a file on disk. +""" + +from __future__ import annotations + +import os +from datetime import datetime, timezone + +from flask import Flask, render_template +from werkzeug.middleware.proxy_fix import ProxyFix + +from .db import close_db, init_db +from .security import check_csrf, csrf_token + +URL_PREFIX = "/comunidad" + + +def _env_flag(name: str, default: bool) -> bool: + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() in ("1", "true", "yes", "on") + + +def create_app(overrides: dict | None = None) -> Flask: + # Static files have to live under the prefix too: Caddy only forwards + # /comunidad/*, so a default /static/… would fall through to the Hugo + # file_server and 404. + app = Flask(__name__, static_url_path=f"{URL_PREFIX}/static") + + # Caddy terminates TLS and forwards plain HTTP, so without this the app + # believes every request arrived unencrypted and sends its redirects to + # http:// — an extra hop, and a moment where the session cookie could + # travel in the clear. + # + # Trusting these headers is only safe because the container binds to + # 127.0.0.1 and nothing but Caddy can reach it. Expose the port and a + # client can forge its own address and scheme. + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) + app.config.update( + SECRET_KEY=os.environ.get("BOARD_SECRET_KEY", ""), + DB_PATH=os.environ.get("BOARD_DB", "/data/board.db"), + GITEA_URL=os.environ.get("GITEA_URL", "https://git.vienalatina.com"), + OAUTH_CLIENT_ID=os.environ.get("BOARD_OAUTH_CLIENT_ID", ""), + OAUTH_CLIENT_SECRET=os.environ.get("BOARD_OAUTH_CLIENT_SECRET", ""), + ADMIN_TOKEN=os.environ.get("GITEA_ADMIN_TOKEN", ""), + OWNER_LOGIN=os.environ.get("BOARD_OWNER", ""), + BASE_URL=os.environ.get("BOARD_BASE_URL", "https://vienalatina.com"), + URL_PREFIX=URL_PREFIX, + COOLDOWN_SECONDS=int(os.environ.get("BOARD_COOLDOWN_SECONDS", "20")), + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + # Off only for tests and local http; on the server this must stay true + # or the session cookie travels in cleartext the first time someone + # types the address without https. + SESSION_COOKIE_SECURE=_env_flag("BOARD_COOKIE_SECURE", True), + SESSION_COOKIE_NAME="vl_board", + MAX_CONTENT_LENGTH=256 * 1024, + ) + if overrides: + app.config.update(overrides) + + if not app.config["SECRET_KEY"]: + # A generated key would "work" and silently log everyone out on every + # restart, which is a confusing way to find out the variable is unset. + raise RuntimeError("BOARD_SECRET_KEY is required (generate one with `openssl rand -hex 32`).") + + from . import auth, board, members + app.register_blueprint(auth.bp, url_prefix=URL_PREFIX) + app.register_blueprint(members.bp, url_prefix=URL_PREFIX) + app.register_blueprint(board.bp, url_prefix=URL_PREFIX) + + app.teardown_appcontext(close_db) + app.jinja_env.globals["csrf_token"] = csrf_token + app.jinja_env.globals["url_prefix"] = URL_PREFIX + + @app.context_processor + def _year(): + return {"current_year": datetime.now(timezone.utc).year} + + @app.before_request + def _before(): + # Order matters: reject forged writes before any handler can act on + # them, but after the member is known so errors can render the chrome. + auth.load_member() + check_csrf() + + @app.after_request + def _harden(response): + # The members area must never appear in a search result. Both halves + # are needed: robots.txt asks crawlers not to fetch, this tells the + # ones that fetched anyway not to index. + response.headers["X-Robots-Tag"] = "noindex, nofollow" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "same-origin" + # No 'unsafe-inline'. Two consequences worth knowing rather than + # rediscovering: an onsubmit="" or onclick="" attribute in a template + # is silently ignored (see static/board.js), and a markdown image + # pointing at another site will not load — which for a private board is + # the right answer anyway, since an external image is a request that + # tells someone else who read the thread and when. + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'" + ) + return response + + @app.errorhandler(400) + def _bad_request(error): + return render_template("error.html", code=400, + message=getattr(error, "description", "Solicitud inválida.")), 400 + + @app.errorhandler(403) + def _forbidden(_error): + return render_template("error.html", code=403, + message="No tienes permiso para hacer esto."), 403 + + @app.errorhandler(404) + def _not_found(_error): + return render_template("error.html", code=404, + message="No encontramos esa página."), 404 + + @app.errorhandler(413) + def _too_large(_error): + return render_template("error.html", code=413, + message="El mensaje es demasiado largo."), 413 + + init_db(app) + return app diff --git a/apps/board/auth.py b/apps/board/auth.py new file mode 100644 index 0000000..24758d1 --- /dev/null +++ b/apps/board/auth.py @@ -0,0 +1,132 @@ +"""Sign-in through Gitea. + +The rule this module exists to enforce: **a Gitea account is not a membership.** +Gitea answers "who is this person"; the members table answers "may they be +here". Conflating the two would admit every account on the instance, including +the `vienalatina-translations` bot, and would mean anyone who ever gets a Gitea +account for an unrelated reason silently gains access to the board. +""" + +from __future__ import annotations + +import secrets + +from flask import (Blueprint, current_app, flash, g, redirect, render_template, + request, session, url_for) + +from . import gitea +from .db import get_db + +bp = Blueprint("auth", __name__) + + +def redirect_uri() -> str: + return current_app.config["BASE_URL"].rstrip("/") + url_for("auth.callback") + + +def load_member() -> None: + """Attach the signed-in member to `g`, or None. Runs on every request. + + The role is read from the database each time rather than cached in the + session, so demoting or deactivating somebody takes effect on their next + click instead of whenever their cookie happens to expire. + """ + g.member = None + member_id = session.get("member_id") + if member_id is None: + return + row = get_db().execute( + """SELECT * FROM members + WHERE id = ? AND active = 1 AND role IN ('owner', 'admin', 'user')""", + (member_id,), + ).fetchone() + if row is None: + session.clear() + return + g.member = row + + +@bp.route("/login") +def login(): + if g.member is not None: + return redirect(url_for("board.threads")) + return render_template("login.html", next=request.args.get("next", "")) + + +@bp.route("/login/start") +def start(): + # State ties the callback to this browser session; without it, an attacker + # can feed you their own authorization code and log you into their account. + state = secrets.token_urlsafe(24) + session["oauth_state"] = state + session["oauth_next"] = request.args.get("next", "") + return redirect(gitea.authorize_url(state, redirect_uri())) + + +@bp.route("/auth/callback") +def callback(): + expected = session.pop("oauth_state", None) + given = request.args.get("state") + if not expected or not given or not secrets.compare_digest(expected, given): + flash("El inicio de sesión no se pudo verificar. Inténtalo de nuevo.", "error") + return redirect(url_for("auth.login")) + + code = request.args.get("code", "") + if not code: + flash("Gitea no devolvió un código de autorización.", "error") + return redirect(url_for("auth.login")) + + try: + token = gitea.exchange_code(code, redirect_uri()) + profile = gitea.fetch_user(token) + except gitea.GiteaError as exc: + current_app.logger.warning("OAuth failed: %s", exc) + flash(str(exc), "error") + return redirect(url_for("auth.login")) + except Exception: # network trouble, malformed JSON, Gitea down + current_app.logger.exception("OAuth failed unexpectedly") + flash("No se pudo contactar con Gitea. Inténtalo más tarde.", "error") + return redirect(url_for("auth.login")) + + login_name = (profile.get("login") or "").strip() + db = get_db() + member = db.execute( + """SELECT * FROM members + WHERE gitea_login = ? AND active = 1 AND role IN ('owner', 'admin', 'user')""", + (login_name,), + ).fetchone() + + if member is None: + # Says nothing about whether the account exists, is inactive, or was + # never a member: an outsider who reaches this page learns only that + # they are not in. + current_app.logger.info("Rejected sign-in for non-member %r", login_name) + flash("Tu cuenta no tiene acceso a esta área. Pide a un administrador que te dé de alta.", + "error") + return redirect(url_for("auth.login")) + + db.execute( + """UPDATE members + SET display_name = ?, email = ?, last_seen_at = datetime('now') + WHERE id = ?""", + (profile.get("full_name") or login_name, profile.get("email") or "", member["id"]), + ) + + # A fresh session id on privilege change, so a cookie captured before login + # is not still valid after it. + session.clear() + session["member_id"] = member["id"] + + target = request.args.get("next") or session.pop("oauth_next", "") or "" + # Only ever redirect within this app: an absolute URL here would make the + # login page an open redirect that phishing can point anywhere. + if not target.startswith(current_app.config["URL_PREFIX"] + "/"): + target = url_for("board.threads") + return redirect(target) + + +@bp.route("/logout", methods=["POST"]) +def logout(): + session.clear() + flash("Sesión cerrada.", "ok") + return redirect(url_for("auth.login")) diff --git a/apps/board/board.py b/apps/board/board.py new file mode 100644 index 0000000..d6e3f93 --- /dev/null +++ b/apps/board/board.py @@ -0,0 +1,255 @@ +"""Threads and comments. + +A deliberate split between two things often lumped together as "moderation": + +* **Deleting** someone else's post is an admin power. Sometimes something has + to come down, and the person who wrote it is not always around to do it. +* **Editing** someone else's post is nobody's power but the author's. An admin + who could rewrite a member's words could put a sentence in their mouth that + the member gets to see attributed to themselves. Removing is visible; + silently rewriting is not. + +The plan drafted for this feature said admins could do both. This is the one +place the implementation departs from it, on purpose. +""" + +from __future__ import annotations + +from flask import (Blueprint, abort, current_app, flash, g, redirect, + render_template, request, url_for) + +from .db import get_db +from .render import excerpt, to_html +from .security import admin_required, login_required + +bp = Blueprint("board", __name__) + +PER_PAGE = 20 +TITLE_MAX = 140 +BODY_MAX = 20_000 + + +def is_admin() -> bool: + return g.member["role"] in ("owner", "admin") + + +def may_delete(row) -> bool: + return is_admin() or row["author_id"] == g.member["id"] + + +def may_edit(row) -> bool: + return row["author_id"] == g.member["id"] + + +def cooldown_remaining() -> int: + """Seconds this member must still wait. Guards against a stuck key or a + double-submitted form, not against a determined spammer — the door is the + members table, and everyone behind it is known.""" + seconds = current_app.config["COOLDOWN_SECONDS"] + if seconds <= 0: + return 0 + row = get_db().execute( + """SELECT MAX(created_at) AS last FROM ( + SELECT created_at FROM threads WHERE author_id = :id + UNION ALL + SELECT created_at FROM comments WHERE author_id = :id + )""", + {"id": g.member["id"]}, + ).fetchone() + if not row or not row["last"]: + return 0 + elapsed = get_db().execute( + "SELECT CAST(strftime('%s','now') AS INTEGER) - CAST(strftime('%s', ?) AS INTEGER) AS s", + (row["last"],), + ).fetchone()["s"] + return max(0, seconds - int(elapsed)) + + +def _clean(title: str, body: str) -> tuple[str, str] | None: + title = title.strip()[:TITLE_MAX] + body = body.strip()[:BODY_MAX] + if not title or not body: + flash("El título y el mensaje no pueden estar vacíos.", "error") + return None + return title, body + + +def _thread_or_404(thread_id: int): + row = get_db().execute( + "SELECT * FROM threads WHERE id = ? AND deleted_at IS NULL", (thread_id,) + ).fetchone() + if row is None: + abort(404) + return row + + +def _comment_or_404(comment_id: int): + row = get_db().execute( + "SELECT * FROM comments WHERE id = ? AND deleted_at IS NULL", (comment_id,) + ).fetchone() + if row is None: + abort(404) + return row + + +@bp.route("/") +@login_required +def threads(): + page = max(1, request.args.get("page", 1, type=int)) + rows = get_db().execute( + """SELECT t.*, m.display_name AS author, + (SELECT COUNT(*) FROM comments c + WHERE c.thread_id = t.id AND c.deleted_at IS NULL) AS replies + FROM threads t JOIN members m ON m.id = t.author_id + WHERE t.deleted_at IS NULL + ORDER BY t.pinned DESC, t.created_at DESC + LIMIT ? OFFSET ?""", + (PER_PAGE + 1, (page - 1) * PER_PAGE), + ).fetchall() + # One row past the page size answers "is there a next page" without a + # second COUNT(*) over the whole table. + has_next = len(rows) > PER_PAGE + return render_template("threads.html", threads=rows[:PER_PAGE], page=page, + has_next=has_next, excerpt=excerpt) + + +@bp.route("/tema/") +@login_required +def thread(thread_id: int): + row = _thread_or_404(thread_id) + db = get_db() + author = db.execute("SELECT display_name FROM members WHERE id = ?", + (row["author_id"],)).fetchone() + comments = db.execute( + """SELECT c.*, m.display_name AS author + FROM comments c JOIN members m ON m.id = c.author_id + WHERE c.thread_id = ? AND c.deleted_at IS NULL + ORDER BY c.created_at""", + (thread_id,), + ).fetchall() + return render_template("thread.html", thread=row, author=author["display_name"], + comments=comments, body_html=to_html(row["body_md"]), + to_html=to_html, may_edit=may_edit, may_delete=may_delete, + is_admin=is_admin()) + + +@bp.route("/nuevo", methods=["GET", "POST"]) +@login_required +def new_thread(): + if request.method == "GET": + return render_template("thread_form.html", thread=None) + + cleaned = _clean(request.form.get("title", ""), request.form.get("body", "")) + if cleaned is None: + return redirect(url_for("board.new_thread")) + wait = cooldown_remaining() + if wait: + flash(f"Espera {wait} segundos antes de publicar otra vez.", "error") + return redirect(url_for("board.new_thread")) + + title, body = cleaned + cursor = get_db().execute( + "INSERT INTO threads (author_id, title, body_md) VALUES (?, ?, ?)", + (g.member["id"], title, body), + ) + return redirect(url_for("board.thread", thread_id=cursor.lastrowid)) + + +@bp.route("/tema//editar", methods=["GET", "POST"]) +@login_required +def edit_thread(thread_id: int): + row = _thread_or_404(thread_id) + if not may_edit(row): + abort(403) + if request.method == "GET": + return render_template("thread_form.html", thread=row) + + cleaned = _clean(request.form.get("title", ""), request.form.get("body", "")) + if cleaned is None: + return redirect(url_for("board.edit_thread", thread_id=thread_id)) + title, body = cleaned + get_db().execute( + "UPDATE threads SET title = ?, body_md = ?, edited_at = datetime('now') WHERE id = ?", + (title, body, thread_id), + ) + return redirect(url_for("board.thread", thread_id=thread_id)) + + +@bp.route("/tema//eliminar", methods=["POST"]) +@login_required +def delete_thread(thread_id: int): + row = _thread_or_404(thread_id) + if not may_delete(row): + abort(403) + get_db().execute("UPDATE threads SET deleted_at = datetime('now') WHERE id = ?", (thread_id,)) + flash("Tema eliminado.", "ok") + return redirect(url_for("board.threads")) + + +@bp.route("/tema//comentar", methods=["POST"]) +@login_required +def comment(thread_id: int): + row = _thread_or_404(thread_id) + if row["locked"] and not is_admin(): + flash("Este tema está cerrado.", "error") + return redirect(url_for("board.thread", thread_id=thread_id)) + + body = request.form.get("body", "").strip()[:BODY_MAX] + if not body: + flash("El comentario no puede estar vacío.", "error") + return redirect(url_for("board.thread", thread_id=thread_id)) + wait = cooldown_remaining() + if wait: + flash(f"Espera {wait} segundos antes de comentar otra vez.", "error") + return redirect(url_for("board.thread", thread_id=thread_id)) + + get_db().execute( + "INSERT INTO comments (thread_id, author_id, body_md) VALUES (?, ?, ?)", + (thread_id, g.member["id"], body), + ) + return redirect(url_for("board.thread", thread_id=thread_id) + "#final") + + +@bp.route("/comentario//editar", methods=["GET", "POST"]) +@login_required +def edit_comment(comment_id: int): + row = _comment_or_404(comment_id) + if not may_edit(row): + abort(403) + if request.method == "GET": + return render_template("comment_form.html", comment=row) + + body = request.form.get("body", "").strip()[:BODY_MAX] + if not body: + flash("El comentario no puede estar vacío.", "error") + return redirect(url_for("board.edit_comment", comment_id=comment_id)) + get_db().execute( + "UPDATE comments SET body_md = ?, edited_at = datetime('now') WHERE id = ?", + (body, comment_id), + ) + return redirect(url_for("board.thread", thread_id=row["thread_id"])) + + +@bp.route("/comentario//eliminar", methods=["POST"]) +@login_required +def delete_comment(comment_id: int): + row = _comment_or_404(comment_id) + if not may_delete(row): + abort(403) + get_db().execute("UPDATE comments SET deleted_at = datetime('now') WHERE id = ?", (comment_id,)) + flash("Comentario eliminado.", "ok") + return redirect(url_for("board.thread", thread_id=row["thread_id"])) + + +@bp.route("/tema//estado", methods=["POST"]) +@admin_required +def set_state(thread_id: int): + _thread_or_404(thread_id) + field = request.form.get("field") + if field not in ("pinned", "locked"): + abort(400, "Campo desconocido.") + value = 1 if request.form.get("value") == "1" else 0 + # `field` is checked against a fixed pair above, so it never carries + # anything a member typed into the statement. + get_db().execute(f"UPDATE threads SET {field} = ? WHERE id = ?", (value, thread_id)) + return redirect(url_for("board.thread", thread_id=thread_id)) diff --git a/apps/board/db.py b/apps/board/db.py new file mode 100644 index 0000000..1b81e03 --- /dev/null +++ b/apps/board/db.py @@ -0,0 +1,102 @@ +"""SQLite access for the members area. + +One connection per request, closed when the request ends. SQLite is enough +here by a wide margin: a trusted group of tens of people generates a handful +of writes a day, and keeping the database a single file on disk means the +backup story is `cp`, which matters more than throughput nobody will use. +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from flask import current_app, g + +SCHEMA_PATH = Path(__file__).with_name("schema.sql") + +# Authorship of a removed member is reassigned to this row rather than deleted, +# so their threads keep their shape and replies to them still make sense. It can +# never log in: the login gate requires an active member holding a real role. +TOMBSTONE_LOGIN = "__removed__" +TOMBSTONE_NAME = "Miembro eliminado" + + +def connect(path: str) -> sqlite3.Connection: + # isolation_level=None puts the driver in autocommit mode, so the only + # transactions are the ones written explicitly with BEGIN. Python's + # implicit-transaction behaviour is surprising often enough to be worth + # opting out of entirely. + conn = sqlite3.connect(path, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA busy_timeout = 5000") + return conn + + +def get_db() -> sqlite3.Connection: + if "db" not in g: + g.db = connect(current_app.config["DB_PATH"]) + return g.db + + +def close_db(_exception=None) -> None: + db = g.pop("db", None) + if db is not None: + db.close() + + +def init_db(app) -> None: + """Apply the schema and make sure the fixed rows exist.""" + Path(app.config["DB_PATH"]).parent.mkdir(parents=True, exist_ok=True) + db = connect(app.config["DB_PATH"]) + try: + db.executescript(SCHEMA_PATH.read_text(encoding="utf-8")) + _ensure_tombstone(db) + _seed_owner(db, app) + finally: + db.close() + + +def _ensure_tombstone(db: sqlite3.Connection) -> None: + db.execute( + """INSERT INTO members (gitea_login, display_name, role, active) + VALUES (?, ?, 'tombstone', 0) + ON CONFLICT(gitea_login) DO NOTHING""", + (TOMBSTONE_LOGIN, TOMBSTONE_NAME), + ) + + +def _seed_owner(db: sqlite3.Connection, app) -> None: + """Create the first owner from BOARD_OWNER, once. + + Deliberately refuses to change an existing owner. Were this to overwrite, + anyone who could edit the environment could hand themselves ownership by + restarting the container — which is a quieter privilege escalation than it + looks, since editing a compose file draws far less attention than asking + the owner for access. + """ + login = (app.config.get("OWNER_LOGIN") or "").strip() + existing = db.execute("SELECT gitea_login FROM members WHERE role = 'owner'").fetchone() + + if existing: + if login and existing["gitea_login"].lower() != login.lower(): + app.logger.warning( + "BOARD_OWNER is %r but the owner is %r; leaving it alone. " + "Transfer ownership from inside the app instead.", + login, existing["gitea_login"], + ) + return + + if not login: + app.logger.warning("No owner yet and BOARD_OWNER is unset — nobody can sign in.") + return + + db.execute( + """INSERT INTO members (gitea_login, display_name, role, active) + VALUES (?, ?, 'owner', 1) + ON CONFLICT(gitea_login) DO UPDATE SET role = 'owner', active = 1""", + (login, login), + ) + app.logger.info("Seeded %r as owner.", login) diff --git a/apps/board/gitea.py b/apps/board/gitea.py new file mode 100644 index 0000000..48f57c3 --- /dev/null +++ b/apps/board/gitea.py @@ -0,0 +1,115 @@ +"""The only place that talks to Gitea. + +Two unrelated conversations happen here and are worth keeping apart in your +head: + +* **Sign-in** uses OAuth2 on behalf of the person at the keyboard. The app is + registered as a *confidential* client with a secret, which it can hold + because it runs on the server. The Decap CMS app is the opposite — a public + client using PKCE — because that one runs in the visitor's browser and has + nowhere to keep a secret. + +* **Creating an account** uses a site-admin token belonging to the instance, + not to any member. That token can create and modify any Gitea user, so the + environment holding it is as sensitive as Gitea's own admin password. +""" + +from __future__ import annotations + +import secrets +import string +from urllib.parse import urlencode + +import requests +from flask import current_app + +TIMEOUT = 10 + + +class GiteaError(RuntimeError): + """Gitea refused a request. The message is safe to show a member.""" + + +def _base() -> str: + return current_app.config["GITEA_URL"].rstrip("/") + + +def _api(path: str) -> str: + return f"{_base()}/api/v1{path}" + + +def authorize_url(state: str, redirect_uri: str) -> str: + query = urlencode({ + "client_id": current_app.config["OAUTH_CLIENT_ID"], + "redirect_uri": redirect_uri, + "response_type": "code", + "state": state, + }) + return f"{_base()}/login/oauth/authorize?{query}" + + +def exchange_code(code: str, redirect_uri: str) -> str: + response = requests.post( + f"{_base()}/login/oauth/access_token", + json={ + "client_id": current_app.config["OAUTH_CLIENT_ID"], + "client_secret": current_app.config["OAUTH_CLIENT_SECRET"], + "code": code, + "grant_type": "authorization_code", + "redirect_uri": redirect_uri, + }, + timeout=TIMEOUT, + ) + if response.status_code != 200: + raise GiteaError("No se pudo completar el inicio de sesión.") + token = response.json().get("access_token") + if not token: + raise GiteaError("Gitea no devolvió un token de acceso.") + return token + + +def fetch_user(token: str) -> dict: + response = requests.get( + _api("/user"), + headers={"Authorization": f"Bearer {token}"}, + timeout=TIMEOUT, + ) + if response.status_code != 200: + raise GiteaError("No se pudo leer el perfil desde Gitea.") + return response.json() + + +def generate_password() -> str: + # Shown once to the admin, then changed by the member on first login. + # Punctuation is left out on purpose: this gets read aloud or copied by + # hand, and a password nobody can transcribe gets written on a note. + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(16)) + + +def admin_create_user(login: str, email: str, full_name: str, password: str) -> None: + token = current_app.config.get("ADMIN_TOKEN") + if not token: + raise GiteaError( + "Falta GITEA_ADMIN_TOKEN: el servidor no puede crear cuentas nuevas." + ) + response = requests.post( + _api("/admin/users"), + headers={"Authorization": f"token {token}"}, + json={ + "username": login, + "email": email, + "full_name": full_name, + "password": password, + "must_change_password": True, + "send_notify": False, + }, + timeout=TIMEOUT, + ) + if response.status_code in (201, 200): + return + if response.status_code == 422: + raise GiteaError("Ese usuario o correo ya existe en Gitea.") + if response.status_code in (401, 403): + raise GiteaError("El token de administración de Gitea no es válido.") + raise GiteaError(f"Gitea rechazó la creación del usuario ({response.status_code}).") diff --git a/apps/board/members.py b/apps/board/members.py new file mode 100644 index 0000000..9406a1f --- /dev/null +++ b/apps/board/members.py @@ -0,0 +1,248 @@ +"""Members and roles. + +Three roles, and the rules between them are short enough to state in full: + +* exactly one **owner**, who creates and removes admins and can hand ownership + on; nobody can deactivate or demote them, including themselves +* **admins** create and deactivate users, and moderate the board +* **users** post, comment, and edit or delete their own writing + +The predicates live as plain functions at the top of this module so they can be +tested without a request, a session or a browser — and so that reading them +does not mean reading route handlers. +""" + +from __future__ import annotations + +import json +import re +import sqlite3 + +from flask import (Blueprint, Response, abort, flash, g, redirect, + render_template, request, url_for) + +from . import gitea +from .db import TOMBSTONE_LOGIN, get_db +from .security import admin_required, login_required, owner_required + +bp = Blueprint("members", __name__) + +# Gitea's own rule, restated: letters, digits, and . - _ inside, never at the +# edges. Checked here so a bad name fails before we create anything anywhere. +LOGIN_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9._-]{0,38}[A-Za-z0-9])?$") + +ROLE_LABELS = {"owner": "Responsable", "admin": "Administrador", "user": "Usuario"} + + +def may_create(actor_role: str, target_role: str) -> bool: + """Who may bring whom in. Admins cannot mint more admins.""" + if target_role == "admin": + return actor_role == "owner" + if target_role == "user": + return actor_role in ("owner", "admin") + return False + + +def may_manage(actor_role: str, target_role: str) -> bool: + """Deactivate, reactivate, or change the role of an existing member.""" + if target_role == "owner": + return False # the owner is out of reach of everyone, themselves included + if target_role == "admin": + return actor_role == "owner" + return actor_role in ("owner", "admin") + + +def tombstone_id(db: sqlite3.Connection) -> int: + row = db.execute("SELECT id FROM members WHERE gitea_login = ?", (TOMBSTONE_LOGIN,)).fetchone() + return row["id"] + + +def transfer_ownership(db: sqlite3.Connection, owner_id: int, target_id: int) -> None: + """Hand ownership to an admin, atomically. + + The demotion has to come first. With the partial unique index in place, a + promote-then-demote order would momentarily ask for two owners and the + database would refuse — correctly, but confusingly. + """ + db.execute("BEGIN IMMEDIATE") + try: + db.execute("UPDATE members SET role = 'admin' WHERE id = ?", (owner_id,)) + db.execute("UPDATE members SET role = 'owner' WHERE id = ?", (target_id,)) + db.execute("COMMIT") + except Exception: + db.execute("ROLLBACK") + raise + + +def erase_member(db: sqlite3.Connection, member_id: int) -> None: + """Remove a member and their personal data, keeping the conversation intact. + + GDPR erasure means the name, login and address go. It does not mean the + threads other people replied to should vanish, so authorship moves to the + tombstone row instead of cascading or dangling. + """ + ghost = tombstone_id(db) + db.execute("BEGIN IMMEDIATE") + try: + db.execute("UPDATE threads SET author_id = ? WHERE author_id = ?", (ghost, member_id)) + db.execute("UPDATE comments SET author_id = ? WHERE author_id = ?", (ghost, member_id)) + db.execute("UPDATE members SET created_by = NULL WHERE created_by = ?", (member_id,)) + db.execute("DELETE FROM members WHERE id = ? AND role != 'owner'", (member_id,)) + db.execute("COMMIT") + except Exception: + db.execute("ROLLBACK") + raise + + +def _load(member_id: int): + row = get_db().execute( + "SELECT * FROM members WHERE id = ? AND role != 'tombstone'", (member_id,) + ).fetchone() + if row is None: + abort(404) + return row + + +@bp.route("/miembros") +@login_required +def index(): + rows = get_db().execute( + """SELECT m.*, c.display_name AS creator + FROM members m + LEFT JOIN members c ON c.id = m.created_by + WHERE m.role != 'tombstone' + ORDER BY CASE m.role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END, + m.display_name COLLATE NOCASE""" + ).fetchall() + return render_template("members.html", members=rows, labels=ROLE_LABELS) + + +@bp.route("/miembros/nuevo", methods=["GET", "POST"]) +@admin_required +def new(): + if request.method == "GET": + return render_template("member_new.html", can_make_admin=g.member["role"] == "owner") + + login = request.form.get("login", "").strip() + display_name = request.form.get("display_name", "").strip() + email = request.form.get("email", "").strip() + role = request.form.get("role", "user") + create_account = request.form.get("create_account") == "on" + + if not may_create(g.member["role"], role): + abort(403) + if not LOGIN_RE.match(login): + flash("El usuario solo puede tener letras, números, punto, guion y guion bajo.", "error") + return redirect(url_for("members.new")) + if create_account and "@" not in email: + flash("Hace falta un correo válido para crear la cuenta en Gitea.", "error") + return redirect(url_for("members.new")) + + db = get_db() + if db.execute("SELECT 1 FROM members WHERE gitea_login = ?", (login,)).fetchone(): + flash("Ese usuario ya es miembro.", "error") + return redirect(url_for("members.new")) + + password = None + if create_account: + password = gitea.generate_password() + try: + gitea.admin_create_user(login, email, display_name or login, password) + except gitea.GiteaError as exc: + flash(str(exc), "error") + return redirect(url_for("members.new")) + + try: + db.execute( + """INSERT INTO members (gitea_login, display_name, email, role, created_by) + VALUES (?, ?, ?, ?, ?)""", + (login, display_name or login, email, role, g.member["id"]), + ) + except sqlite3.IntegrityError: + flash("No se pudo dar de alta a ese miembro.", "error") + return redirect(url_for("members.new")) + + # Shown once and never stored: Gitea has the hash, we have nothing. + return render_template("member_created.html", login=login, password=password, + role_label=ROLE_LABELS[role]) + + +@bp.route("/miembros//estado", methods=["POST"]) +@admin_required +def set_active(member_id: int): + target = _load(member_id) + if not may_manage(g.member["role"], target["role"]): + abort(403) + active = 1 if request.form.get("active") == "1" else 0 + get_db().execute("UPDATE members SET active = ? WHERE id = ?", (active, member_id)) + flash(f"{target['display_name']}: acceso {'restaurado' if active else 'suspendido'}.", "ok") + return redirect(url_for("members.index")) + + +@bp.route("/miembros//rol", methods=["POST"]) +@owner_required +def set_role(member_id: int): + target = _load(member_id) + role = request.form.get("role", "") + if role not in ("admin", "user") or not may_manage(g.member["role"], target["role"]): + abort(403) + get_db().execute("UPDATE members SET role = ? WHERE id = ?", (role, member_id)) + flash(f"{target['display_name']} ahora es {ROLE_LABELS[role].lower()}.", "ok") + return redirect(url_for("members.index")) + + +@bp.route("/miembros//transferir", methods=["POST"]) +@owner_required +def transfer(member_id: int): + target = _load(member_id) + if target["role"] != "admin" or not target["active"]: + flash("Solo puedes transferir la titularidad a un administrador activo.", "error") + return redirect(url_for("members.index")) + transfer_ownership(get_db(), g.member["id"], target["id"]) + flash(f"{target['display_name']} es ahora el responsable. Tú eres administrador.", "ok") + return redirect(url_for("members.index")) + + +@bp.route("/miembros//eliminar", methods=["POST"]) +@owner_required +def erase(member_id: int): + target = _load(member_id) + if target["role"] == "owner": + abort(403) + erase_member(get_db(), member_id) + flash(f"{target['display_name']} eliminado. Sus mensajes quedan como «Miembro eliminado».", "ok") + return redirect(url_for("members.index")) + + +@bp.route("/mis-datos") +@login_required +def export(): + """Everything this member wrote, as JSON. Their data, on request.""" + db = get_db() + me = g.member + threads = db.execute( + """SELECT id, title, body_md, created_at, edited_at FROM threads + WHERE author_id = ? AND deleted_at IS NULL ORDER BY created_at""", + (me["id"],), + ).fetchall() + comments = db.execute( + """SELECT id, thread_id, body_md, created_at, edited_at FROM comments + WHERE author_id = ? AND deleted_at IS NULL ORDER BY created_at""", + (me["id"],), + ).fetchall() + payload = { + "member": { + "gitea_login": me["gitea_login"], + "display_name": me["display_name"], + "email": me["email"], + "role": me["role"], + "created_at": me["created_at"], + }, + "threads": [dict(row) for row in threads], + "comments": [dict(row) for row in comments], + } + return Response( + json.dumps(payload, ensure_ascii=False, indent=2), + mimetype="application/json", + headers={"Content-Disposition": 'attachment; filename="mis-datos.json"'}, + ) diff --git a/apps/board/render.py b/apps/board/render.py new file mode 100644 index 0000000..0564085 --- /dev/null +++ b/apps/board/render.py @@ -0,0 +1,40 @@ +"""Turning what members type into HTML. + +`html=False` is the whole security model, and it is worth understanding rather +than copying. With raw HTML disabled, markdown-it never passes a fragment of +the input through untouched: it emits only the tags its own rules produce, and +everything else is escaped as text. A ` + + + +
+
+ Viena Latina + {% if g.member %} + + {% endif %} +
+ + {% if g.member %} + + {% endif %} +
+ +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +

{{ message }}

+ {% endfor %} + {% endwith %} + + {% block main %}{% endblock %} +
+ + +
+ + + diff --git a/apps/board/templates/comment_form.html b/apps/board/templates/comment_form.html new file mode 100644 index 0000000..f40c9a7 --- /dev/null +++ b/apps/board/templates/comment_form.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Editar respuesta{% endblock %} + +{% block main %} +
+ +

Editar respuesta

+ + + + +
+ + Cancelar +
+
+{% endblock %} diff --git a/apps/board/templates/error.html b/apps/board/templates/error.html new file mode 100644 index 0000000..de8d0d6 --- /dev/null +++ b/apps/board/templates/error.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block title %}{{ code }}{% endblock %} + +{% block main %} + +{% endblock %} diff --git a/apps/board/templates/login.html b/apps/board/templates/login.html new file mode 100644 index 0000000..156e005 --- /dev/null +++ b/apps/board/templates/login.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Entrar{% endblock %} + +{% block main %} +
+

Área de la comunidad

+

+ Este espacio es sólo para miembros de Viena Latina. Se entra con la misma + cuenta que se usa para publicar en el sitio. +

+ Entrar con Gitea +

+ ¿No tienes cuenta? Pídesela a un administrador: las cuentas se crean a mano, + no hay registro abierto. +

+
+{% endblock %} diff --git a/apps/board/templates/member_created.html b/apps/board/templates/member_created.html new file mode 100644 index 0000000..ecb7bcb --- /dev/null +++ b/apps/board/templates/member_created.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Miembro dado de alta{% endblock %} + +{% block main %} +
+

{{ login }} ya es {{ role_label|lower }}

+ + {% if password %} +

Esta contraseña se muestra una sola vez. Cópiala ahora y + entrégasela en persona o por un canal privado.

+ +

{{ password }}

+ +

+ No se guarda en ningún sitio: el servidor sólo conserva el hash, igual que + con cualquier contraseña. Si se pierde, hay que restablecerla desde Gitea. + La persona tendrá que cambiarla la primera vez que entre. +

+ {% else %} +

No se creó ninguna cuenta nueva: ya existía. Puede entrar con la que tenía.

+ {% endif %} + + +
+{% endblock %} diff --git a/apps/board/templates/member_new.html b/apps/board/templates/member_new.html new file mode 100644 index 0000000..60e22c8 --- /dev/null +++ b/apps/board/templates/member_new.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}Dar de alta{% endblock %} + +{% block main %} +
+ +

Dar de alta a alguien

+ + + +

Letras, números, punto, guion y guion bajo.

+ + + + + + + + + + {% if can_make_admin %} + + + {% else %} + +

Los administradores sólo pueden dar de alta usuarios.

+ {% endif %} + +
+ + Cancelar +
+
+{% endblock %} diff --git a/apps/board/templates/members.html b/apps/board/templates/members.html new file mode 100644 index 0000000..880a97a --- /dev/null +++ b/apps/board/templates/members.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% block title %}Miembros{% endblock %} + +{% set me = g.member %} +{% set is_owner = me.role == 'owner' %} +{% set is_admin = me.role in ('owner', 'admin') %} + +{% block main %} +
+

Miembros

+ {% if is_admin %} + Dar de alta + {% endif %} +
+ + + + {% if is_admin %}{% endif %} + + + {% for m in members %} + + + + + + {% if is_admin %} + + {% endif %} + + {% endfor %} + +
NombreUsuarioRolEstado
{{ m.display_name }}{% if m.id == me.id %} (tú){% endif %}{{ m.gitea_login }}{{ labels[m.role] }}{{ 'Activo' if m.active else 'Suspendido' }} + {# The owner is untouchable, so the row shows nothing rather than + buttons that would only produce a 403. #} + {% if m.role != 'owner' %} +
+ + + +
+ + {% if is_owner %} +
+ + + +
+ + {% if m.role == 'admin' and m.active %} +
+ + +
+ {% endif %} + +
+ + +
+ {% endif %} + {% endif %} +
+ +

+ Hay un solo responsable. El responsable da de alta administradores; los + administradores dan de alta usuarios. Nadie puede suspender ni degradar al + responsable: para dejar el cargo hay que transferirlo a un administrador. +

+{% endblock %} diff --git a/apps/board/templates/thread.html b/apps/board/templates/thread.html new file mode 100644 index 0000000..df48f51 --- /dev/null +++ b/apps/board/templates/thread.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% block title %}{{ thread.title }}{% endblock %} + +{% block main %} +
+
+ {% if thread.pinned %}Fijado{% endif %} + {% if thread.locked %}Cerrado{% endif %} + {{ author }} · + {% if thread.edited_at %}(editado){% endif %} +
+

{{ thread.title }}

+
{{ body_html }}
+ +
+ {% if may_edit(thread) %} + Editar + {% endif %} + {% if may_delete(thread) %} +
+ + +
+ {% endif %} + {% if is_admin %} +
+ + + + +
+
+ + + + +
+ {% endif %} +
+
+ +

{{ comments|length }} respuesta{{ '' if comments|length == 1 else 's' }}

+ +{% for c in comments %} +
+
+ {{ c.author }} · + {% if c.edited_at %}(editado){% endif %} +
+
{{ to_html(c.body_md) }}
+
+ {% if may_edit(c) %} + Editar + {% endif %} + {% if may_delete(c) %} +
+ + +
+ {% endif %} +
+
+{% endfor %} + + + +{% if thread.locked and not is_admin %} +

Este tema está cerrado a nuevas respuestas.

+{% else %} +
+ + + + +
+{% endif %} +{% endblock %} diff --git a/apps/board/templates/thread_form.html b/apps/board/templates/thread_form.html new file mode 100644 index 0000000..3f3b18f --- /dev/null +++ b/apps/board/templates/thread_form.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}{{ 'Editar tema' if thread else 'Escribir' }}{% endblock %} + +{% block main %} +
+ +

{{ 'Editar tema' if thread else 'Nuevo tema' }}

+ + + + + + + +
+ + Cancelar +
+
+{% endblock %} diff --git a/apps/board/templates/threads.html b/apps/board/templates/threads.html new file mode 100644 index 0000000..39035f8 --- /dev/null +++ b/apps/board/templates/threads.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}Mensajes{% endblock %} + +{% block main %} +
+

Mensajes

+ Escribir +
+ +{% if not threads %} +
+

Todavía no hay mensajes. Escribe el primero.

+
+{% endif %} + +{% for t in threads %} +
+
+ {% if t.pinned %}Fijado{% endif %} + {% if t.locked %}Cerrado{% endif %} + {{ t.author }} · +
+

+ {{ t.title }} +

+

{{ excerpt(t.body_md) }}

+

{{ t.replies }} respuesta{{ '' if t.replies == 1 else 's' }}

+
+{% endfor %} + +{% if page > 1 or has_next %} + +{% endif %} +{% endblock %} diff --git a/apps/board/tests/__init__.py b/apps/board/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/board/tests/conftest.py b/apps/board/tests/conftest.py new file mode 100644 index 0000000..0e62710 --- /dev/null +++ b/apps/board/tests/conftest.py @@ -0,0 +1,89 @@ +"""Shared fixtures. + +Tests drive the app through Flask's test client rather than poking functions +directly, because most of what is worth checking here is a decision about who +may do what — and that decision is only real once it has survived routing, +the session and the CSRF hook. + +Sign-in is faked by writing the session cookie: the OAuth round trip belongs to +Gitea, and the tests that care about it stub the two network calls instead. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from apps.board.app import create_app # noqa: E402 +from apps.board.db import connect # noqa: E402 + + +@pytest.fixture +def app(tmp_path): + application = create_app({ + "SECRET_KEY": "test-secret", + "DB_PATH": str(tmp_path / "board.db"), + "OWNER_LOGIN": "owner", + "SESSION_COOKIE_SECURE": False, + "COOLDOWN_SECONDS": 0, + "BASE_URL": "http://localhost", + "OAUTH_CLIENT_ID": "cid", + "OAUTH_CLIENT_SECRET": "secret", + "ADMIN_TOKEN": "admintoken", + "TESTING": True, + }) + yield application + + +@pytest.fixture +def db(app): + conn = connect(app.config["DB_PATH"]) + yield conn + conn.close() + + +@pytest.fixture +def client(app): + return app.test_client() + + +@pytest.fixture +def make_member(db): + """Insert a member and return its id. The owner already exists, seeded.""" + def _make(login, role="user", active=1): + cursor = db.execute( + """INSERT INTO members (gitea_login, display_name, email, role, active) + VALUES (?, ?, ?, ?, ?)""", + (login, login.title(), f"{login}@example.com", role, active), + ) + return cursor.lastrowid + return _make + + +@pytest.fixture +def owner_id(db): + return db.execute("SELECT id FROM members WHERE role = 'owner'").fetchone()["id"] + + +@pytest.fixture +def sign_in(client): + def _sign_in(member_id): + with client.session_transaction() as session: + session["member_id"] = member_id + session["csrf"] = "token-for-tests" + return _sign_in + + +@pytest.fixture +def post(client): + """POST with a valid CSRF token, so tests exercise authorisation rather + than repeatedly rediscovering that the CSRF hook works.""" + def _post(url, data=None, **kwargs): + payload = dict(data or {}) + payload.setdefault("csrf_token", "token-for-tests") + return client.post(url, data=payload, **kwargs) + return _post diff --git a/apps/board/tests/test_auth.py b/apps/board/tests/test_auth.py new file mode 100644 index 0000000..336e39e --- /dev/null +++ b/apps/board/tests/test_auth.py @@ -0,0 +1,104 @@ +"""Who gets in, and who does not.""" + +from __future__ import annotations + +import pytest + +from apps.board import gitea + +PROTECTED = [ + "/comunidad/", + "/comunidad/nuevo", + "/comunidad/miembros", + "/comunidad/miembros/nuevo", + "/comunidad/mis-datos", +] + + +@pytest.mark.parametrize("path", PROTECTED) +def test_anonymous_is_sent_to_login(client, path): + response = client.get(path) + assert response.status_code == 302 + assert "/comunidad/login" in response.headers["Location"] + + +def _stub_gitea(monkeypatch, login): + monkeypatch.setattr(gitea, "exchange_code", lambda code, uri: "token") + monkeypatch.setattr(gitea, "fetch_user", lambda token: { + "login": login, "full_name": login.title(), "email": f"{login}@example.com", + }) + + +def _callback(client, monkeypatch, login): + _stub_gitea(monkeypatch, login) + with client.session_transaction() as session: + session["oauth_state"] = "state123" + return client.get("/comunidad/auth/callback?code=abc&state=state123") + + +def test_a_gitea_account_is_not_a_membership(client, monkeypatch): + """The single most important rule in the app: Gitea says who you are, the + members table says whether you belong. The translations bot has a perfectly + valid Gitea account and must not get in.""" + response = _callback(client, monkeypatch, "vienalatina-translations") + assert response.status_code == 302 + with client.session_transaction() as session: + assert "member_id" not in session + + +def test_member_signs_in(client, monkeypatch, make_member): + make_member("maria") + response = _callback(client, monkeypatch, "maria") + assert response.status_code == 302 + with client.session_transaction() as session: + assert "member_id" in session + + +def test_suspended_member_cannot_sign_in(client, monkeypatch, make_member): + make_member("expulsada", active=0) + _callback(client, monkeypatch, "expulsada") + with client.session_transaction() as session: + assert "member_id" not in session + + +def test_suspension_takes_effect_on_the_next_request(client, db, make_member, sign_in): + """The role is read per request, not cached in the cookie, so revoking + access does not wait for a session to expire.""" + member_id = make_member("temporal") + sign_in(member_id) + assert client.get("/comunidad/").status_code == 200 + + db.execute("UPDATE members SET active = 0 WHERE id = ?", (member_id,)) + assert client.get("/comunidad/").status_code == 302 + + +def test_callback_rejects_a_mismatched_state(client, monkeypatch, make_member): + make_member("maria") + _stub_gitea(monkeypatch, "maria") + with client.session_transaction() as session: + session["oauth_state"] = "the-real-state" + client.get("/comunidad/auth/callback?code=abc&state=attacker-state") + with client.session_transaction() as session: + assert "member_id" not in session + + +def test_post_without_csrf_is_refused(client, make_member, sign_in): + sign_in(make_member("maria")) + response = client.post("/comunidad/nuevo", data={"title": "Hola", "body": "Texto"}) + assert response.status_code == 400 + + +def test_login_redirect_cannot_be_pointed_offsite(client, monkeypatch, make_member): + make_member("maria") + _stub_gitea(monkeypatch, "maria") + with client.session_transaction() as session: + session["oauth_state"] = "state123" + response = client.get( + "/comunidad/auth/callback?code=abc&state=state123&next=https://evil.example.com/" + ) + assert "evil.example.com" not in response.headers["Location"] + + +def test_responses_say_do_not_index(client): + response = client.get("/comunidad/login") + assert response.headers["X-Robots-Tag"] == "noindex, nofollow" diff --git a/apps/board/tests/test_board.py b/apps/board/tests/test_board.py new file mode 100644 index 0000000..02b47bd --- /dev/null +++ b/apps/board/tests/test_board.py @@ -0,0 +1,110 @@ +"""Threads, comments, and who may touch them.""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def thread(db, make_member): + author = make_member("autora") + cursor = db.execute( + "INSERT INTO threads (author_id, title, body_md) VALUES (?, 'Original', 'Cuerpo')", + (author,), + ) + return {"id": cursor.lastrowid, "author": author} + + +def test_a_member_can_post_and_read_it_back(client, post, make_member, sign_in): + sign_in(make_member("maria")) + post("/comunidad/nuevo", {"title": "Reunión del jueves", "body": "A las 19h en el café."}) + body = client.get("/comunidad/").get_data(as_text=True) + assert "Reunión del jueves" in body + + +def test_markdown_is_rendered_but_html_is_not(client, post, make_member, sign_in): + sign_in(make_member("maria")) + post("/comunidad/nuevo", { + "title": "Prueba", + "body": "**fuerte** y ", + }) + body = client.get("/comunidad/tema/1").get_data(as_text=True) + assert "fuerte" in body + assert "" not in body + assert "<script>" in body + + +def test_nobody_edits_someone_elses_words(client, post, thread, make_member, sign_in): + """Not even an admin. Removing a post is visible to its author; quietly + rewriting it is not, which is why moderation here means deletion.""" + sign_in(make_member("admina", role="admin")) + response = post(f"/comunidad/tema/{thread['id']}/editar", + {"title": "Reescrito", "body": "Otra cosa"}) + assert response.status_code == 403 + + +def test_a_user_cannot_delete_someone_elses_thread(client, post, thread, make_member, sign_in): + sign_in(make_member("ajena")) + assert post(f"/comunidad/tema/{thread['id']}/eliminar").status_code == 403 + + +def test_an_admin_can_delete_any_thread(client, db, post, thread, make_member, sign_in): + sign_in(make_member("admina", role="admin")) + post(f"/comunidad/tema/{thread['id']}/eliminar") + row = db.execute("SELECT deleted_at FROM threads WHERE id = ?", (thread["id"],)).fetchone() + assert row["deleted_at"] is not None + + +def test_the_author_can_edit_their_own(client, db, post, thread, sign_in): + sign_in(thread["author"]) + post(f"/comunidad/tema/{thread['id']}/editar", {"title": "Corregido", "body": "Mejor"}) + row = db.execute("SELECT title, edited_at FROM threads WHERE id = ?", + (thread["id"],)).fetchone() + assert row["title"] == "Corregido" + assert row["edited_at"] is not None + + +def test_a_deleted_thread_disappears_from_the_list_and_the_page( + client, db, post, thread, sign_in): + sign_in(thread["author"]) + post(f"/comunidad/tema/{thread['id']}/eliminar") + assert "Original" not in client.get("/comunidad/").get_data(as_text=True) + assert client.get(f"/comunidad/tema/{thread['id']}").status_code == 404 + + +def test_a_deleted_thread_is_left_out_of_the_export(client, db, post, thread, sign_in): + sign_in(thread["author"]) + post(f"/comunidad/tema/{thread['id']}/eliminar") + assert "Original" not in client.get("/comunidad/mis-datos").get_data(as_text=True) + + +def test_a_closed_thread_refuses_replies(client, db, post, thread, make_member, sign_in): + db.execute("UPDATE threads SET locked = 1 WHERE id = ?", (thread["id"],)) + sign_in(make_member("maria")) + post(f"/comunidad/tema/{thread['id']}/comentar", {"body": "¿Hola?"}) + count = db.execute("SELECT COUNT(*) AS n FROM comments WHERE thread_id = ?", + (thread["id"],)).fetchone()["n"] + assert count == 0 + + +def test_only_admins_pin(client, post, thread, make_member, sign_in): + sign_in(make_member("maria")) + assert post(f"/comunidad/tema/{thread['id']}/estado", + {"field": "pinned", "value": "1"}).status_code == 403 + + +def test_the_state_field_is_not_a_way_into_the_query(client, post, thread, make_member, sign_in): + """`field` is interpolated into the UPDATE, so it is checked against a fixed + pair first. This asserts the check, not the interpolation.""" + sign_in(make_member("admina", role="admin")) + assert post(f"/comunidad/tema/{thread['id']}/estado", + {"field": "role", "value": "1"}).status_code == 400 + + +def test_the_cooldown_stops_a_double_submit(app, client, post, make_member, sign_in, db): + app.config["COOLDOWN_SECONDS"] = 60 + sign_in(make_member("rapida")) + post("/comunidad/nuevo", {"title": "Primero", "body": "Uno"}) + post("/comunidad/nuevo", {"title": "Segundo", "body": "Dos"}) + count = db.execute("SELECT COUNT(*) AS n FROM threads").fetchone()["n"] + assert count == 1 diff --git a/apps/board/tests/test_members.py b/apps/board/tests/test_members.py new file mode 100644 index 0000000..cb12958 --- /dev/null +++ b/apps/board/tests/test_members.py @@ -0,0 +1,184 @@ +"""The role rules, from the predicates up to the routes that enforce them.""" + +from __future__ import annotations + +import sqlite3 + +import pytest + +from apps.board import gitea +from apps.board.members import may_create, may_manage + + +# --- the rules as plain functions --------------------------------------- + +def test_only_the_owner_makes_admins(): + assert may_create("owner", "admin") + assert not may_create("admin", "admin") + assert not may_create("user", "admin") + + +def test_admins_and_the_owner_make_users(): + assert may_create("owner", "user") + assert may_create("admin", "user") + assert not may_create("user", "user") + + +def test_the_owner_is_beyond_everyone_including_themselves(): + assert not may_manage("owner", "owner") + assert not may_manage("admin", "owner") + + +def test_only_the_owner_manages_admins(): + assert may_manage("owner", "admin") + assert not may_manage("admin", "admin") + + +# --- the database holds the line ---------------------------------------- + +def test_a_second_owner_is_impossible(db): + """Not a route check — a direct insert, because the point of the partial + unique index is to survive a bug in the code above it.""" + with pytest.raises(sqlite3.IntegrityError): + db.execute( + "INSERT INTO members (gitea_login, role) VALUES ('usurpador', 'owner')" + ) + + +def test_transfer_leaves_exactly_one_owner(app, db, owner_id, make_member): + from apps.board.members import transfer_ownership + admin_id = make_member("segunda", role="admin") + transfer_ownership(db, owner_id, admin_id) + + owners = db.execute("SELECT id FROM members WHERE role = 'owner'").fetchall() + assert [row["id"] for row in owners] == [admin_id] + assert db.execute("SELECT role FROM members WHERE id = ?", + (owner_id,)).fetchone()["role"] == "admin" + + +# --- the routes ---------------------------------------------------------- + +def test_admin_cannot_create_an_admin(client, post, make_member, sign_in): + sign_in(make_member("admina", role="admin")) + response = post("/comunidad/miembros/nuevo", { + "login": "nueva", "display_name": "Nueva", "email": "n@example.com", + "role": "admin", "create_account": "", + }) + assert response.status_code == 403 + + +def test_owner_can_create_an_admin(client, db, post, owner_id, sign_in): + sign_in(owner_id) + post("/comunidad/miembros/nuevo", { + "login": "nueva", "display_name": "Nueva", "email": "n@example.com", + "role": "admin", "create_account": "", + }) + row = db.execute("SELECT role FROM members WHERE gitea_login = 'nueva'").fetchone() + assert row["role"] == "admin" + + +def test_a_plain_user_cannot_reach_the_admin_screens(client, make_member, sign_in): + sign_in(make_member("cualquiera")) + assert client.get("/comunidad/miembros/nuevo").status_code == 403 + + +def test_the_owner_cannot_be_suspended(client, post, owner_id, make_member, sign_in): + sign_in(make_member("admina", role="admin")) + response = post(f"/comunidad/miembros/{owner_id}/estado", {"active": "0"}) + assert response.status_code == 403 + + +def test_the_owner_cannot_suspend_themselves(client, post, owner_id, sign_in): + sign_in(owner_id) + response = post(f"/comunidad/miembros/{owner_id}/estado", {"active": "0"}) + assert response.status_code == 403 + + +def test_an_admin_cannot_demote_another_admin(client, post, make_member, sign_in): + other = make_member("otra", role="admin") + sign_in(make_member("admina", role="admin")) + response = post(f"/comunidad/miembros/{other}/rol", {"role": "user"}) + assert response.status_code == 403 + + +def test_creating_a_user_shows_the_password_once(client, monkeypatch, post, owner_id, sign_in): + created = {} + monkeypatch.setattr(gitea, "admin_create_user", + lambda login, email, name, password: created.update( + login=login, password=password)) + sign_in(owner_id) + response = post("/comunidad/miembros/nuevo", { + "login": "maria", "display_name": "María", "email": "m@example.com", + "role": "user", "create_account": "on", + }) + assert created["login"] == "maria" + assert created["password"].encode() in response.data + + +def test_a_rejected_gitea_call_creates_no_member(client, monkeypatch, db, post, owner_id, sign_in): + def boom(*args, **kwargs): + raise gitea.GiteaError("Ese usuario ya existe en Gitea.") + monkeypatch.setattr(gitea, "admin_create_user", boom) + sign_in(owner_id) + post("/comunidad/miembros/nuevo", { + "login": "maria", "display_name": "María", "email": "m@example.com", + "role": "user", "create_account": "on", + }) + assert db.execute("SELECT 1 FROM members WHERE gitea_login = 'maria'").fetchone() is None + + +def test_erasing_a_member_keeps_their_threads_readable(app, db, post, owner_id, make_member, sign_in): + author = make_member("saliente") + db.execute("INSERT INTO threads (author_id, title, body_md) VALUES (?, 'Hola', 'Texto')", + (author,)) + sign_in(owner_id) + post(f"/comunidad/miembros/{author}/eliminar") + + assert db.execute("SELECT 1 FROM members WHERE id = ?", (author,)).fetchone() is None + row = db.execute( + """SELECT m.display_name FROM threads t JOIN members m ON m.id = t.author_id + WHERE t.title = 'Hola'""" + ).fetchone() + assert row["display_name"] == "Miembro eliminado" + + +def test_the_member_list_renders_for_each_role(client, db, owner_id, make_member, sign_in): + """Every role takes a different branch through members.html — the owner + sees transfer and erase, an admin sees suspend, a user sees neither — so + each one is rendered here rather than trusted.""" + admin_id = make_member("admina", role="admin") + user_id = make_member("usuaria") + + for member_id, expected in ((owner_id, "Transferir titularidad"), + (admin_id, "Suspender"), + (user_id, None)): + sign_in(member_id) + body = client.get("/comunidad/miembros").get_data(as_text=True) + assert "usuaria" in body + if expected: + assert expected in body + else: + assert "Transferir titularidad" not in body + assert "Suspender" not in body + + +def test_the_new_member_form_hides_the_role_choice_from_admins( + client, owner_id, make_member, sign_in): + sign_in(owner_id) + assert "Administrador" in client.get("/comunidad/miembros/nuevo").get_data(as_text=True) + + sign_in(make_member("admina", role="admin")) + body = client.get("/comunidad/miembros/nuevo").get_data(as_text=True) + assert 'value="admin"' not in body + + +def test_the_export_is_only_your_own_writing(client, db, make_member, sign_in): + mine = make_member("mia") + theirs = make_member("suya") + db.execute("INSERT INTO threads (author_id, title, body_md) VALUES (?, 'Mío', 'A')", (mine,)) + db.execute("INSERT INTO threads (author_id, title, body_md) VALUES (?, 'Suyo', 'B')", (theirs,)) + sign_in(mine) + + body = client.get("/comunidad/mis-datos").get_data(as_text=True) + assert "Mío" in body + assert "Suyo" not in body diff --git a/apps/board/tests/test_templates.py b/apps/board/tests/test_templates.py new file mode 100644 index 0000000..2853f0c --- /dev/null +++ b/apps/board/tests/test_templates.py @@ -0,0 +1,38 @@ +"""Guards on the templates themselves. + +These check one thing that no request-level test can: the Content-Security- +Policy makes a whole category of markup silently inert rather than broken, so +nothing at runtime will ever fail to tell you about it. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +TEMPLATES = sorted((Path(__file__).resolve().parents[1] / "templates").glob("*.html")) +INLINE_HANDLER = re.compile(r"\son[a-z]+\s*=", re.IGNORECASE) + + +@pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name) +def test_no_inline_event_handlers(template): + """`default-src 'self'` with no 'unsafe-inline' means the browser ignores + an onsubmit="" attribute without complaining. A delete button written that + way loses its confirmation dialog and nobody finds out until something is + deleted by accident. Use data-confirm, handled in static/board.js.""" + found = INLINE_HANDLER.findall(template.read_text(encoding="utf-8")) + assert not found, f"{template.name} has inline handler(s): {found}" + + +@pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name) +def test_every_post_form_carries_a_csrf_token(template): + """The hook in app.py rejects a POST without one, so a form that forgets it + is a button that always fails — and fails with a 400 that reads like the + page is broken rather than like a missing field.""" + html = template.read_text(encoding="utf-8") + forms = re.findall(r"]*method=[\"']post[\"'][^>]*>(.*?)", html, + re.IGNORECASE | re.DOTALL) + for form in forms: + assert "csrf_token" in form, f"{template.name} has a POST form without a CSRF token" diff --git a/apps/board/wsgi.py b/apps/board/wsgi.py new file mode 100644 index 0000000..b8ed2f2 --- /dev/null +++ b/apps/board/wsgi.py @@ -0,0 +1,5 @@ +"""Entry point for gunicorn: `gunicorn apps.board.wsgi:application`.""" + +from .app import create_app + +application = create_app() diff --git a/docker/board/Dockerfile b/docker/board/Dockerfile new file mode 100644 index 0000000..db774cc --- /dev/null +++ b/docker/board/Dockerfile @@ -0,0 +1,40 @@ +# Runtime image for the members area. +# +# Thin, like docker/translate/Dockerfile: no build tooling, no compiler, and +# nothing in it that is not needed to answer a request. +# +# Build on the server, from the repository root so the app is in context: +# docker build -t vienalatina/board:1 -f docker/board/Dockerfile . + +FROM python:3.12-slim + +# Runs as a non-root user with the uid the host data directory is chowned to. +# Two reasons, and the second is the one that bites: root in the container +# writes root-owned files into the mounted volume, and then backups and +# manual inspection from the host need sudo for no good reason. +RUN useradd --uid 1000 --create-home --shell /usr/sbin/nologin board + +WORKDIR /srv + +COPY apps/board/requirements.txt /srv/requirements.txt +RUN pip install --no-cache-dir -r /srv/requirements.txt + +COPY apps /srv/apps + +ENV BOARD_DB=/data/board.db \ + PYTHONUNBUFFERED=1 + +USER board +EXPOSE 8080 + +# Two workers is plenty for a group this size and keeps the footprint near +# 60-80MB, which is what the CX22 can spare alongside Gitea, Woodpecker and a +# translation run. --timeout is short because every request here is a SQLite +# read or write; anything slower than this is stuck, not busy. +CMD ["gunicorn", \ + "--bind", "0.0.0.0:8080", \ + "--workers", "2", \ + "--timeout", "30", \ + "--access-logfile", "-", \ + "--error-logfile", "-", \ + "apps.board.wsgi:application"] diff --git a/docs/server-setup.md b/docs/server-setup.md index d457fd5..683f8dd 100644 --- a/docs/server-setup.md +++ b/docs/server-setup.md @@ -307,3 +307,130 @@ sudo restic -r sftp:uXXXXXX@uXXXXXX.your-storagebox.de:backups init # then a root cron entry, e.g.: # 0 3 * * * restic -r sftp:... backup /srv /var/www --password-file /root/.restic-pw ``` + +## 11. Members area (`/comunidad/`) + +The private area: roles and an internal board. It is the only part of the site +that runs code to answer a request, and the only data on the server that is not +already in git. + +### 11.1 Register the OAuth application + +Gitea → **Site Administration → Integrations → Applications** → +*Create new OAuth2 application*: + +- Name: `vienalatina-board` +- Redirect URI: `https://vienalatina.com/comunidad/auth/callback` +- **Leave "Confidential Client" TICKED.** + +That last point is the opposite of the Decap application in step 6.2, and the +difference is worth understanding rather than memorising. Decap runs in the +visitor's browser, where any secret would be readable by the visitor, so it has +to be a public client using PKCE. The board runs on the server, so it can hold +a secret and should — a confidential client is the stronger of the two. + +Save the **Client ID** and the **Client Secret**. + +### 11.2 Optional: a token for creating accounts + +Without it, admins can add people who already have a Gitea login, and nothing +else changes. With it, they can create the Gitea account from inside the members +area and hand over a one-time password. + +Log in as a Gitea **site administrator** → Settings → Applications → *Generate +New Token* → scope **admin (write)**. + +Understand what this token is before you create it: it can create and modify any +account on the instance, including administrators. Anything that can read the +board's environment — the compose file, `docker inspect`, a shell in the +container — can use it. If you would rather not have that on the box, leave +`GITEA_ADMIN_TOKEN` empty and create accounts in Gitea by hand. + +### 11.3 Build and run + +```sh +cd ~/vienalatina +docker build -t vienalatina/board:1 -f docker/board/Dockerfile . + +sudo mkdir -p /srv/board/data +sudo cp -r infra/board/. /srv/board/ +cd /srv/board +sudo cp .env.example .env +openssl rand -hex 32 # paste as BOARD_SECRET_KEY +sudo nano .env # client id, secret, BOARD_OWNER, optional admin token +sudo chown -R 1000:1000 /srv/board/data +sudo docker compose up -d +``` + +`BOARD_OWNER` is applied once, to an empty database, and ignored from then on. +It cannot be used to take ownership later: that is deliberate, because otherwise +editing a file on disk would be a quieter route to the top than asking for it. +Ownership moves only through *Transferir titularidad* inside the app. + +### 11.4 Route it through Caddy + +Add to the `vienalatina.com` block in `/etc/caddy/Caddyfile` (already present in +`infra/caddy/Caddyfile`): + +``` +@board path /comunidad /comunidad/* +reverse_proxy @board 127.0.0.1:8080 +``` + +Then `sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy`. + +Both paths are matched on purpose: Flask redirects `/comunidad` to +`/comunidad/`, and matching only the trailing-slash form lets the bare path fall +through to the static site and 404. + +### 11.5 Back it up — this part is not optional + +Everything else on this server is reproducible from the repository. The board's +threads, comments and membership exist in exactly one place. + +```sh +sudo apt install -y sqlite3 +crontab -e +# 15 4 * * * /home/pablo/vienalatina/scripts/backup-board.sh >> /home/pablo/board-backup.log 2>&1 +``` + +The script uses SQLite's `.backup` rather than copying the file, because the +database is live and in WAL mode — a plain `cp` can capture it missing its most +recent commits. Test a restore before you rely on it: stop the container, gunzip +a backup over `/srv/board/data/board.db`, start it again. + +### 11.6 Who can do what + +| | Owner | Admin | User | +|---|---|---|---| +| Post, comment, edit own | ✓ | ✓ | ✓ | +| Delete any post | ✓ | ✓ | — | +| Edit someone else's post | — | — | — | +| Pin and close threads | ✓ | ✓ | — | +| Create users | ✓ | ✓ | — | +| Create admins | ✓ | — | — | +| Suspend a user | ✓ | ✓ | — | +| Suspend an admin | ✓ | — | — | +| Transfer ownership | ✓ | — | — | + +Nobody edits anyone else's words, administrators included. Taking a post down is +visible to the person who wrote it; rewriting it is not, and an admin who could +do that could leave a sentence attributed to a member who never wrote it. + +There is exactly one owner, and the database enforces it with a unique index +rather than trusting the application to remember. The owner cannot be suspended +or demoted by anyone, themselves included — to step down, transfer ownership to +an admin. + +### 11.7 Personal data + +Members' names, emails and writing are personal data under GDPR. + +- **Erasure:** the owner's *Eliminar* removes the member row entirely and + reassigns their threads and comments to a tombstone shown as "Miembro + eliminado", so conversations other people took part in stay readable. +- **Access:** any member can download everything they have written from + *Descargar mis datos*. +- **Retention:** soft-deleted posts stay in the database until removed by hand. + If you want a real retention limit, that is a `DELETE ... WHERE deleted_at <` + in this same cron slot — and a decision to take deliberately, not by default. diff --git a/infra/board/.env.example b/infra/board/.env.example new file mode 100644 index 0000000..2c90d1d --- /dev/null +++ b/infra/board/.env.example @@ -0,0 +1,20 @@ +# Copy to .env next to docker-compose.yml and fill in. Never commit .env. +# +# openssl rand -hex 32 +BOARD_SECRET_KEY= + +# From the Gitea OAuth2 application named `vienalatina-board`. +# Redirect URI: https://vienalatina.com/comunidad/auth/callback +# Leave "Confidential Client" TICKED — this app runs on the server and can keep +# a secret, unlike the Decap application, which must stay public. +BOARD_OAUTH_CLIENT_ID= +BOARD_OAUTH_CLIENT_SECRET= + +# Gitea username of the first and only owner. Applied once, to an empty +# database, and ignored afterwards. +BOARD_OWNER=pablo + +# Optional. A site-admin token lets admins create Gitea accounts from inside +# the members area. Without it, everything works except account creation, and +# admins add people who already have a Gitea login. +GITEA_ADMIN_TOKEN= diff --git a/infra/board/docker-compose.yml b/infra/board/docker-compose.yml new file mode 100644 index 0000000..8d5b08f --- /dev/null +++ b/infra/board/docker-compose.yml @@ -0,0 +1,41 @@ +# Members area — Flask + SQLite behind Caddy. +# +# Copy this directory to /srv/board/ on the server, fill in .env, then: +# mkdir -p /srv/board/data && sudo chown 1000:1000 /srv/board/data +# docker compose up -d +# +# Bound to localhost like Gitea and Woodpecker: the only way in from outside is +# through Caddy, which terminates TLS and forwards /comunidad/. + +services: + board: + image: vienalatina/board:1 + restart: unless-stopped + environment: + # Session signing key. Generate once with `openssl rand -hex 32`. + # Changing it signs everyone out; losing it means nothing worse. + - BOARD_SECRET_KEY=${BOARD_SECRET_KEY} + + # Gitea OAuth2 application — a CONFIDENTIAL client, unlike the Decap one. + - GITEA_URL=https://git.vienalatina.com + - BOARD_OAUTH_CLIENT_ID=${BOARD_OAUTH_CLIENT_ID} + - BOARD_OAUTH_CLIENT_SECRET=${BOARD_OAUTH_CLIENT_SECRET} + - BOARD_BASE_URL=https://vienalatina.com + + # The Gitea username that becomes the one owner, applied once on an empty + # database. Changing it later does nothing: ownership moves from inside + # the app, so nobody can take it by editing this file. + - BOARD_OWNER=${BOARD_OWNER} + + # Site-admin token, used only to create Gitea accounts for new members. + # This is the most privileged secret on the box after Gitea's own + # database: anything that can read this environment can create accounts. + # Leave it empty to run without account creation — admins then add people + # who already have a Gitea login, and everything else still works. + - GITEA_ADMIN_TOKEN=${GITEA_ADMIN_TOKEN:-} + + - BOARD_DB=/data/board.db + volumes: + - ./data:/data + ports: + - "127.0.0.1:8080:8080" diff --git a/infra/caddy/Caddyfile b/infra/caddy/Caddyfile index b71d78d..5927d17 100644 --- a/infra/caddy/Caddyfile +++ b/infra/caddy/Caddyfile @@ -26,6 +26,17 @@ vienalatina.com { encode zstd gzip file_server + # Members area — the only part of this site that runs code. Both paths are + # matched: Flask redirects /comunidad to /comunidad/, and a matcher of + # /comunidad/* alone would let the bare path fall through to file_server + # and 404 before the app ever sees it. + # + # No `handle` wrapper is needed. Caddy runs reverse_proxy ahead of + # file_server in its directive order, and reverse_proxy is terminal, so a + # matched request never reaches the static tree. + @board path /comunidad /comunidad/* + reverse_proxy @board 127.0.0.1:8080 + # llms.txt is markdown (matches the old WP behaviour) header /llms.txt Content-Type "text/markdown; charset=utf-8" diff --git a/scripts/backup-board.sh b/scripts/backup-board.sh new file mode 100755 index 0000000..cc50b3d --- /dev/null +++ b/scripts/backup-board.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Back up the members-area database. +# +# This is the only data on the server that git does not already hold. The site, +# the content, the translations and every config file can be rebuilt from the +# repository; the board's threads, comments and membership cannot. Losing the +# file loses the lot. +# +# Run nightly from cron, as the user owning /srv/board/data: +# 15 4 * * * /home/pablo/vienalatina/scripts/backup-board.sh >> /var/log/board-backup.log 2>&1 +# +# bash scripts/backup-board.sh # -> /srv/board/backups +# bash scripts/backup-board.sh /mnt/elsewhere # -> somewhere else + +set -euo pipefail + +DB="${BOARD_DB:-/srv/board/data/board.db}" +DEST="${1:-/srv/board/backups}" +KEEP_DAYS="${KEEP_DAYS:-30}" +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" + +if [ ! -f "$DB" ]; then + echo "No database at $DB — nothing to back up." >&2 + exit 1 +fi + +mkdir -p "$DEST" + +# `.backup` rather than `cp`: the app is running, and SQLite in WAL mode keeps +# recent writes in a side file. Copying board.db on its own can capture a +# database missing its most recent commits, or mid-checkpoint and unreadable. +# The backup API takes a consistent snapshot of a live database. +sqlite3 "$DB" ".backup '$DEST/board-$STAMP.db'" +gzip -f "$DEST/board-$STAMP.db" + +# Prove it: a corrupt backup discovered during a restore is not a backup. +if ! gzip -t "$DEST/board-$STAMP.db.gz"; then + echo "Backup failed its own integrity check — keeping it for inspection." >&2 + exit 1 +fi + +find "$DEST" -name 'board-*.db.gz' -mtime "+$KEEP_DAYS" -delete + +echo "$(date -u +%FT%TZ) wrote $DEST/board-$STAMP.db.gz ($(du -h "$DEST/board-$STAMP.db.gz" | cut -f1))" diff --git a/static/robots.txt b/static/robots.txt index fa90123..fa666a8 100644 --- a/static/robots.txt +++ b/static/robots.txt @@ -1,39 +1,66 @@ # Viena Latina — robots.txt # Ported from theme/inc/robots-and-llms.php (PR #37): explicit allow for AI crawlers. +# +# The two Disallow lines are repeated in every group on purpose. A crawler obeys +# exactly one group — the most specific one matching its name — and ignores the +# rest, so a Disallow that appears only under `User-agent: *` does not apply to +# GPTBot or ClaudeBot at all. Listing an agent to allow it would otherwise +# quietly hand it the private areas as well. User-agent: * Allow: / Disallow: /admin/ +Disallow: /comunidad/ -# AI crawlers — explicit allow +# AI crawlers — explicit allow for the public site, never the private areas User-agent: GPTBot Allow: / +Disallow: /admin/ +Disallow: /comunidad/ User-agent: ClaudeBot Allow: / +Disallow: /admin/ +Disallow: /comunidad/ User-agent: anthropic-ai Allow: / +Disallow: /admin/ +Disallow: /comunidad/ User-agent: PerplexityBot Allow: / +Disallow: /admin/ +Disallow: /comunidad/ User-agent: Google-Extended Allow: / +Disallow: /admin/ +Disallow: /comunidad/ User-agent: CCBot Allow: / +Disallow: /admin/ +Disallow: /comunidad/ User-agent: Amazonbot Allow: / +Disallow: /admin/ +Disallow: /comunidad/ User-agent: Bytespider Allow: / +Disallow: /admin/ +Disallow: /comunidad/ User-agent: Applebot-Extended Allow: / +Disallow: /admin/ +Disallow: /comunidad/ User-agent: meta-externalagent Allow: / +Disallow: /admin/ +Disallow: /comunidad/ Sitemap: https://vienalatina.com/sitemap.xml