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