Add a members area: roles and an internal board

Everything on this site so far has been a file built from git. This is the
first component that runs code to answer a request and the first whose data
git does not hold, so the trade is stated in the README and the backup script
is not optional.

Roles are owner, admin and user. The owner is seeded once from BOARD_OWNER and
cannot be seeded again, because otherwise editing a compose file would be a
quieter way to take the top role than asking for it; ownership moves only by
transfer, inside the app. There is exactly one owner and a partial unique index
enforces it, so the invariant holds even when a handler is wrong. The owner is
beyond suspension and demotion by everyone, themselves included. Only the owner
makes admins; admins make users.

Sign-in goes through Gitea as a confidential OAuth client — the opposite of
Decap, which has to be public because it runs in the browser. The rule the
whole thing rests on is that a Gitea account is not a membership: entry needs
an active row in `members`, or every account on the instance is a member,
starting with the translations bot.

Admins can delete any post; nobody can edit anyone else's, admins included.
Taking a post down is visible to its author. Quietly rewriting it is not, and
an admin who could do that could leave a sentence attributed to someone who
never wrote it. The plan said admins could do both; this is the one place the
implementation departs from it.

Markdown renders with raw HTML disabled, which is the entire XSS defence and
the reason there is no sanitiser: the renderer emits only its own tags and
escapes the rest. The CSP carries no 'unsafe-inline', which makes an inline
onsubmit silently inert rather than broken, so the confirmation dialogs live in
a static file and a test fails any template that grows an inline handler.

GDPR is in scope rather than deferred: erasure removes the member row and moves
their authorship to a tombstone so the conversations around them still read,
and any member can download their own writing.

Verified: 63 checks pass, covering the membership gate, every role predicate, a
direct insert of a second owner being refused by the index, atomic ownership
transfer, CSRF, an offsite login redirect, script tags rendering as text, soft
deletes leaving both listings and exports, and the member screens rendering for
each role. Smoke-tested live: headers, both static assets, and the bare
/comunidad redirect that the Caddy matcher has to cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
This commit is contained in:
Claude 2026-09-22 10:57:18 +00:00
parent da8cc784be
commit 6ac814475e
No known key found for this signature in database
39 changed files with 2730 additions and 14 deletions

9
.gitignore vendored
View File

@ -7,3 +7,12 @@ resources/
static/admin/decap-cms.js static/admin/decap-cms.js
__pycache__/ __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

View File

@ -1,24 +1,28 @@
# Viena Latina # Viena Latina
Static, self-hosted, trilingual blog for the Latin American community in Self-hosted, trilingual site for the Latin American community in Vienna —
Vienna — Hugo + Decap CMS + Gitea + Woodpecker CI on a single Hetzner CX22 Hugo + Decap CMS + Gitea + Woodpecker CI on a single Hetzner CX22 (Falkenstein,
(Nuremberg, DE). Replaces the previous WordPress + Polylang + synchronous DE). Replaces the previous WordPress + Polylang + synchronous DeepL stack.
DeepL stack.
Translation is self-hosted too: M2M100 418M (MIT) runs on CPU via CTranslate2 Translation is self-hosted too: OPUS-MT (CC-BY-4.0) runs on CPU via CTranslate2
inside the pipeline image. No API key, no quota, and no visitor or content data in the pipeline image, with the models mounted from the host. No API key, no
leaving the server. quota, and no visitor or content data leaving the server.
``` ```
Pablo ──► Decap CMS (/admin) ──commit──► Gitea ──webhook──► Woodpecker CI Pablo ──► Decap CMS (/admin) ──commit──► Gitea ──webhook──► Woodpecker CI
│ ▲ │
translate (M2M100, async) ──┤ │ translate ──────┤
build (hugo) ─┤ OAuth2 build (hugo) ─┤
deploy (rsync) ─┘ │ deploy (rsync) ─┘
▼ │ ▼
Caddy 2 serves /var/www/vienalatina.com 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 ## Languages
Spanish, German (`/de/`) and Brazilian Portuguese (`/pt-br/`). **Any of the 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 `static/admin/decap-cms.js` (gitignored) — zero third-party requests at
runtime. 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 ## GEO/SEO surfaces
- `hreflang` + `og:locale(:alternate)` + JSON-LD `BlogPosting`/`Blog` with - `hreflang` + `og:locale(:alternate)` + JSON-LD `BlogPosting`/`Blog` with

1
apps/board/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Members area for vienalatina.com — roles and an internal message board."""

132
apps/board/app.py Normal file
View File

@ -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

132
apps/board/auth.py Normal file
View File

@ -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"))

255
apps/board/board.py Normal file
View File

@ -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/<int:thread_id>")
@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/<int:thread_id>/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/<int:thread_id>/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/<int:thread_id>/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/<int:comment_id>/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/<int:comment_id>/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/<int:thread_id>/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))

102
apps/board/db.py Normal file
View File

@ -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)

115
apps/board/gitea.py Normal file
View File

@ -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}).")

248
apps/board/members.py Normal file
View File

@ -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/<int:member_id>/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/<int:member_id>/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/<int:member_id>/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/<int:member_id>/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"'},
)

40
apps/board/render.py Normal file
View File

@ -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 `<script>` in a post comes out as visible
characters, not as a tag.
That is why there is no sanitiser here. A sanitiser is what you need when you
have decided to allow *some* HTML and must then decide which — a judgement with
a long history of near misses. Allowing none is a smaller thing to get right.
Anyone tempted to set `html=True` later to embed a video: that single flag
turns this file into an XSS hole, and re-enabling it means adding a sanitiser
and owning its allowlist forever.
"""
from __future__ import annotations
from markdown_it import MarkdownIt
from markupsafe import Markup
_md = (
MarkdownIt("commonmark", {"html": False, "linkify": True, "breaks": True})
.enable("linkify")
.enable("table")
.enable("strikethrough")
)
def to_html(text: str) -> Markup:
return Markup(_md.render(text or ""))
def excerpt(text: str, limit: int = 220) -> str:
"""A plain-text preview for the thread list — no markup, no truncated tags."""
flat = " ".join((text or "").split())
if len(flat) <= limit:
return flat
return flat[:limit].rsplit(" ", 1)[0] + " …"

View File

@ -0,0 +1,12 @@
# Pinned rather than floating: this image is rebuilt rarely and by hand, and a
# silent minor-version bump between two builds is exactly the kind of drift
# that makes "it worked on the old container" impossible to investigate.
#
# All permissive, which matters because the platform is meant to be resold:
# Flask BSD-3, gunicorn MIT, markdown-it-py MIT, linkify-it-py MIT,
# requests Apache-2.0, SQLite public domain.
flask==3.1.3
gunicorn==23.0.0
markdown-it-py==4.2.0
linkify-it-py==2.2.0
requests==2.33.1

56
apps/board/schema.sql Normal file
View File

@ -0,0 +1,56 @@
-- Members area schema.
--
-- Applied at every startup and written to be idempotent, so deploying a new
-- version of the app needs no migration step for as long as the schema only
-- grows. A change that alters an existing column will need a real migration;
-- there is deliberately no framework here to pretend otherwise.
CREATE TABLE IF NOT EXISTS members (
id INTEGER PRIMARY KEY,
-- COLLATE NOCASE because Gitea treats logins case-insensitively; without it
-- "Pablo" and "pablo" would be two members with one Gitea account.
gitea_login TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'user', 'tombstone')),
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
created_by INTEGER REFERENCES members(id),
last_seen_at TEXT
);
-- The one-owner rule, held by the database rather than by the application, so
-- a mistake in a handler cannot produce a second owner. SQLite enforces a
-- partial unique index exactly like a full one.
CREATE UNIQUE INDEX IF NOT EXISTS members_one_owner
ON members(role) WHERE role = 'owner';
CREATE TABLE IF NOT EXISTS threads (
id INTEGER PRIMARY KEY,
author_id INTEGER NOT NULL REFERENCES members(id),
title TEXT NOT NULL,
body_md TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
edited_at TEXT,
pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)),
locked INTEGER NOT NULL DEFAULT 0 CHECK (locked IN (0, 1)),
-- Soft delete: a moderator's mistake stays recoverable, and removing one
-- comment does not tear a hole in the conversation around it.
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS threads_live
ON threads(pinned DESC, created_at DESC) WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY,
thread_id INTEGER NOT NULL REFERENCES threads(id),
author_id INTEGER NOT NULL REFERENCES members(id),
body_md TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
edited_at TEXT,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS comments_thread
ON comments(thread_id, created_at) WHERE deleted_at IS NULL;

64
apps/board/security.py Normal file
View File

@ -0,0 +1,64 @@
"""CSRF tokens and the decorators that gate routes by role.
The CSRF check is installed once as a before-request hook in app.py rather than
as a decorator on each handler. A decorator is something a future route can
forget; a hook covering every unsafe method is something it has to actively opt
out of.
"""
from __future__ import annotations
import hmac
import secrets
from functools import wraps
from flask import abort, g, redirect, request, session, url_for
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
def csrf_token() -> str:
if "csrf" not in session:
session["csrf"] = secrets.token_urlsafe(32)
return session["csrf"]
def check_csrf() -> None:
if request.method in SAFE_METHODS:
return
sent = request.form.get("csrf_token", "")
expected = session.get("csrf", "")
# compare_digest rather than == so a wrong token cannot be guessed a
# character at a time by measuring how long the comparison takes.
if not expected or not hmac.compare_digest(sent, expected):
abort(400, "Formulario caducado. Vuelve a cargar la página e inténtalo de nuevo.")
def login_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if g.member is None:
return redirect(url_for("auth.login", next=request.path))
return view(*args, **kwargs)
return wrapped
def admin_required(view):
"""Owner counts as an admin. Admin does not count as owner."""
@wraps(view)
@login_required
def wrapped(*args, **kwargs):
if g.member["role"] not in ("owner", "admin"):
abort(403)
return view(*args, **kwargs)
return wrapped
def owner_required(view):
@wraps(view)
@login_required
def wrapped(*args, **kwargs):
if g.member["role"] != "owner":
abort(403)
return view(*args, **kwargs)
return wrapped

275
apps/board/static/board.css Normal file
View File

@ -0,0 +1,275 @@
/* =========================================================
Members area — stylesheet.
The :root block below is copied from
themes/vienalatina/assets/css/main.css, which already duplicates it for the
same reason: each stylesheet stands on its own. The two files are served by
different things — Hugo fingerprints one, Flask serves the other — so
sharing a file would couple the app's deploy to the site's build for no gain.
If the brand colours change, change them in both.
========================================================= */
:root {
color-scheme: light;
--brand: #c0391c;
--brand-dark: #9a2a0f;
--brand-soft: #fbe2db;
--brand-softer: #fdf1ed;
--bg: #f8f3ef;
--surface: #ffffff;
--surface-soft: #f2ebe6;
--border: #c2a89f;
--border-light: #e0cec8;
--text: #1a0d09;
--muted: #5c3d37;
--subtle: #9a7870;
--danger: #a11b1b;
--shadow: 0 2px 16px rgba(60, 20, 10, 0.09);
--shadow-sm: 0 1px 6px rgba(60, 20, 10, 0.06);
--radius-xl: 18px;
--radius-lg: 12px;
--radius-md: 8px;
--radius-sm: 5px;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: "Montserrat", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 { font-weight: 700; letter-spacing: -0.01em; }
a { color: var(--brand-dark); }
button, input, select, textarea { font: inherit; }
/* --- chrome ---------------------------------------------------------- */
.site-bar {
background: var(--surface);
border-bottom: 1px solid var(--border-light);
}
.site-bar__inner {
max-width: 860px;
margin: 0 auto;
padding: 1rem 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.site-bar__brand { text-decoration: none; }
.site-bar__brand-text { color: var(--brand); font-weight: 700; font-size: 1.25rem; }
.site-bar__account { display: flex; align-items: center; gap: 0.75rem; }
.site-bar__account form { margin: 0; }
.who { color: var(--muted); font-size: 0.9rem; }
.site-bar__pages { border-top: 1px solid var(--border-light); background: var(--surface); }
.page-nav {
max-width: 860px;
margin: 0 auto;
padding: 0.6rem 16px;
list-style: none;
display: flex;
gap: 1.5rem;
}
.page-nav a { color: var(--muted); text-decoration: none; }
.page-nav a:hover { color: var(--brand); }
.page-shell { max-width: 860px; margin: 0 auto; padding: 2rem 16px 4rem; }
.site-foot {
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-light);
text-align: center;
font-size: 0.9rem;
color: var(--subtle);
}
.site-foot a { color: var(--subtle); }
.site-foot__legal { margin-top: 0.5rem; }
/* --- cards ----------------------------------------------------------- */
.feed-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.feed-head h1 { margin: 0; }
.card {
background: var(--surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-sm);
padding: 1.5rem;
margin-bottom: 1.25rem;
}
.card--center { text-align: center; }
.card--comment { background: var(--surface); border-left: 3px solid var(--border-light); }
.card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--subtle);
}
.card__title { margin: 0.4rem 0 0.6rem; font-size: 1.35rem; }
.card__title a { color: var(--text); text-decoration: none; }
.card__title a:hover { color: var(--brand); }
.card__excerpt { margin: 0; color: var(--muted); }
.card__foot { margin: 0.8rem 0 0; font-size: 0.85rem; color: var(--subtle); }
.section-head { margin: 2rem 0 1rem; font-size: 1.05rem; color: var(--muted); }
.tag {
background: var(--brand-soft);
color: var(--brand-dark);
border-radius: 999px;
padding: 0.15rem 0.6rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tag--quiet { background: var(--surface-soft); color: var(--muted); }
/* --- prose ----------------------------------------------------------- */
.prose { line-height: 1.65; }
.prose :first-child { margin-top: 0; }
.prose :last-child { margin-bottom: 0; }
.prose img { max-width: 100%; height: auto; border-radius: var(--radius-md); }
.prose pre {
background: var(--surface-soft);
padding: 0.9rem;
border-radius: var(--radius-md);
overflow-x: auto;
}
.prose code { background: var(--surface-soft); padding: 0.1rem 0.3rem; border-radius: var(--radius-sm); }
.prose pre code { background: none; padding: 0; }
.prose blockquote {
margin: 1rem 0;
padding-left: 1rem;
border-left: 3px solid var(--border-light);
color: var(--muted);
}
/* --- forms and buttons ------------------------------------------------ */
label { display: block; margin: 1rem 0 0.35rem; font-weight: 600; font-size: 0.9rem; }
label.check { display: flex; align-items: center; gap: 0.5rem; font-weight: 400; }
label.check input { margin: 0; }
input[type="text"], input[type="email"], input:not([type]), select, textarea {
width: 100%;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
background: var(--bg);
color: var(--text);
}
input[type="checkbox"] { width: auto; }
textarea { resize: vertical; line-height: 1.55; }
:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.btn {
display: inline-block;
background: var(--brand);
color: #fff;
border: 0;
border-radius: 999px;
padding: 0.6rem 1.4rem;
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.btn:hover { background: var(--brand-dark); }
.linkish {
background: none;
border: 0;
padding: 0;
color: var(--brand-dark);
text-decoration: underline;
cursor: pointer;
font-size: 0.9rem;
}
.linkish--danger { color: var(--danger); }
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
margin-top: 1.25rem;
}
.actions form { margin: 0; }
.actions--row { margin-top: 0; gap: 0.75rem; }
.pager { display: flex; justify-content: space-between; margin-top: 2rem; }
/* --- tables, flashes, odds and ends ----------------------------------- */
.table {
width: 100%;
border-collapse: collapse;
background: var(--surface);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.table th, .table td {
text-align: left;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-light);
font-size: 0.92rem;
vertical-align: top;
}
.table th { background: var(--surface-soft); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
.table tr:last-child td { border-bottom: 0; }
.row--off { opacity: 0.55; }
.flash {
padding: 0.85rem 1.1rem;
border-radius: var(--radius-md);
margin-bottom: 1.25rem;
}
.flash--ok { background: var(--brand-softer); color: var(--brand-dark); }
.flash--error { background: #fdecea; color: var(--danger); }
.muted { color: var(--muted); }
.small { font-size: 0.85rem; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.password {
font-size: 1.3rem;
letter-spacing: 0.06em;
background: var(--surface-soft);
padding: 1rem;
border-radius: var(--radius-md);
user-select: all;
word-break: break-all;
}
@media (max-width: 640px) {
.feed-head { flex-direction: column; align-items: flex-start; }
.table { display: block; overflow-x: auto; }
}

View File

@ -0,0 +1,15 @@
// The only JavaScript in the members area: "are you sure?" before a form that
// destroys something.
//
// It lives in a file rather than in onsubmit="" attributes because the
// Content-Security-Policy set in app.py forbids inline script. An inline
// handler under that policy is not an error anyone sees — the browser simply
// ignores it, the dialog never appears, and the delete button quietly loses its
// guard. One listener on the document covers every form, including ones added
// to templates later.
document.addEventListener("submit", function (event) {
var message = event.target.getAttribute("data-confirm");
if (message && !window.confirm(message)) {
event.preventDefault();
}
});

View File

@ -0,0 +1,58 @@
{# Chrome deliberately mirrors the public site's header and footer, so moving
between vienalatina.com and the members area does not feel like leaving. #}
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{% block title %}Comunidad{% endblock %} — Viena Latina</title>
<link rel="stylesheet" href="{{ url_for('static', filename='board.css') }}">
<script src="{{ url_for('static', filename='board.js') }}" defer></script>
</head>
<body>
<header class="site-bar">
<div class="site-bar__inner">
<a class="site-bar__brand" href="/"><span class="site-bar__brand-text">Viena Latina</span></a>
{% if g.member %}
<div class="site-bar__account">
<span class="who">{{ g.member.display_name }}</span>
<form method="post" action="{{ url_for('auth.logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="linkish" type="submit">Salir</button>
</form>
</div>
{% endif %}
</div>
{% if g.member %}
<nav class="site-bar__pages" aria-label="Secciones">
<ul class="page-nav">
<li><a href="{{ url_for('board.threads') }}">Mensajes</a></li>
<li><a href="{{ url_for('members.index') }}">Miembros</a></li>
</ul>
</nav>
{% endif %}
</header>
<div class="page-shell">
<main class="feed">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<p class="flash flash--{{ category }}">{{ message }}</p>
{% endfor %}
{% endwith %}
{% block main %}{% endblock %}
</main>
<footer class="site-foot">
<a href="/">Ir al sitio público</a>
{% if g.member %} · <a href="{{ url_for('members.export') }}">Descargar mis datos</a>{% endif %}
<div class="site-foot__legal">Viena Latina © {{ current_year }} · Área privada</div>
</footer>
</div>
</body>
</html>

View File

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Editar respuesta{% endblock %}
{% block main %}
<form class="card" method="post" action="{{ url_for('board.edit_comment', comment_id=comment.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h1>Editar respuesta</h1>
<label for="body">Mensaje</label>
<textarea id="body" name="body" rows="10" required>{{ comment.body_md }}</textarea>
<div class="actions">
<button class="btn" type="submit">Guardar</button>
<a class="linkish" href="{{ url_for('board.thread', thread_id=comment.thread_id) }}">Cancelar</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}{{ code }}{% endblock %}
{% block main %}
<article class="card card--center">
<h1>{{ code }}</h1>
<p class="muted">{{ message }}</p>
<a class="btn" href="{{ url_for('board.threads') }}">Volver a los mensajes</a>
</article>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Entrar{% endblock %}
{% block main %}
<article class="card card--center">
<h1>Área de la comunidad</h1>
<p class="muted">
Este espacio es sólo para miembros de Viena Latina. Se entra con la misma
cuenta que se usa para publicar en el sitio.
</p>
<a class="btn" href="{{ url_for('auth.start', next=next) }}">Entrar con Gitea</a>
<p class="muted small">
¿No tienes cuenta? Pídesela a un administrador: las cuentas se crean a mano,
no hay registro abierto.
</p>
</article>
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Miembro dado de alta{% endblock %}
{% block main %}
<article class="card">
<h1>{{ login }} ya es {{ role_label|lower }}</h1>
{% if password %}
<p>Esta contraseña se muestra <strong>una sola vez</strong>. Cópiala ahora y
entrégasela en persona o por un canal privado.</p>
<p class="password mono">{{ password }}</p>
<p class="muted small">
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.
</p>
{% else %}
<p>No se creó ninguna cuenta nueva: ya existía. Puede entrar con la que tenía.</p>
{% endif %}
<div class="actions">
<a class="btn" href="{{ url_for('members.index') }}">Volver a miembros</a>
</div>
</article>
{% endblock %}

View File

@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Dar de alta{% endblock %}
{% block main %}
<form class="card" method="post" action="{{ url_for('members.new') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h1>Dar de alta a alguien</h1>
<label for="login">Usuario</label>
<input id="login" name="login" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]*"
placeholder="maria.lopez">
<p class="muted small">Letras, números, punto, guion y guion bajo.</p>
<label for="display_name">Nombre</label>
<input id="display_name" name="display_name" placeholder="María López">
<label for="email">Correo</label>
<input id="email" name="email" type="email" placeholder="maria@ejemplo.com">
<label class="check">
<input type="checkbox" name="create_account" checked>
Crear también su cuenta (desmarca si ya tiene una)
</label>
{% if can_make_admin %}
<label for="role">Rol</label>
<select id="role" name="role">
<option value="user">Usuario</option>
<option value="admin">Administrador</option>
</select>
{% else %}
<input type="hidden" name="role" value="user">
<p class="muted small">Los administradores sólo pueden dar de alta usuarios.</p>
{% endif %}
<div class="actions">
<button class="btn" type="submit">Dar de alta</button>
<a class="linkish" href="{{ url_for('members.index') }}">Cancelar</a>
</div>
</form>
{% endblock %}

View File

@ -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 %}
<div class="feed-head">
<h1>Miembros</h1>
{% if is_admin %}
<a class="btn" href="{{ url_for('members.new') }}">Dar de alta</a>
{% endif %}
</div>
<table class="table">
<thead>
<tr><th>Nombre</th><th>Usuario</th><th>Rol</th><th>Estado</th>{% if is_admin %}<th></th>{% endif %}</tr>
</thead>
<tbody>
{% for m in members %}
<tr class="{{ '' if m.active else 'row--off' }}">
<td>{{ m.display_name }}{% if m.id == me.id %} <span class="muted small">(tú)</span>{% endif %}</td>
<td class="mono">{{ m.gitea_login }}</td>
<td>{{ labels[m.role] }}</td>
<td>{{ 'Activo' if m.active else 'Suspendido' }}</td>
{% if is_admin %}
<td class="actions actions--row">
{# The owner is untouchable, so the row shows nothing rather than
buttons that would only produce a 403. #}
{% if m.role != 'owner' %}
<form method="post" action="{{ url_for('members.set_active', member_id=m.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="active" value="{{ 0 if m.active else 1 }}">
<button class="linkish" type="submit">{{ 'Suspender' if m.active else 'Reactivar' }}</button>
</form>
{% if is_owner %}
<form method="post" action="{{ url_for('members.set_role', member_id=m.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="role" value="{{ 'user' if m.role == 'admin' else 'admin' }}">
<button class="linkish" type="submit">{{ 'Hacer usuario' if m.role == 'admin' else 'Hacer administrador' }}</button>
</form>
{% if m.role == 'admin' and m.active %}
<form method="post" action="{{ url_for('members.transfer', member_id=m.id) }}"
data-confirm="Vas a dejar de ser el responsable y {{ m.display_name }} pasará a serlo. ¿Seguro?">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="linkish" type="submit">Transferir titularidad</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('members.erase', member_id=m.id) }}"
data-confirm="Se borrarán sus datos personales. Sus mensajes quedarán como «Miembro eliminado». ¿Seguro?">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="linkish linkish--danger" type="submit">Eliminar</button>
</form>
{% endif %}
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
<p class="muted small">
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.
</p>
{% endblock %}

View File

@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}{{ thread.title }}{% endblock %}
{% block main %}
<article class="card">
<div class="card__meta">
{% if thread.pinned %}<span class="tag">Fijado</span>{% endif %}
{% if thread.locked %}<span class="tag tag--quiet">Cerrado</span>{% endif %}
<span>{{ author }}</span> · <time>{{ thread.created_at }}</time>
{% if thread.edited_at %}<span class="muted small">(editado)</span>{% endif %}
</div>
<h1 class="card__title">{{ thread.title }}</h1>
<div class="prose">{{ body_html }}</div>
<div class="actions">
{% if may_edit(thread) %}
<a class="linkish" href="{{ url_for('board.edit_thread', thread_id=thread.id) }}">Editar</a>
{% endif %}
{% if may_delete(thread) %}
<form method="post" action="{{ url_for('board.delete_thread', thread_id=thread.id) }}"
data-confirm="¿Eliminar este tema?">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="linkish linkish--danger" type="submit">Eliminar</button>
</form>
{% endif %}
{% if is_admin %}
<form method="post" action="{{ url_for('board.set_state', thread_id=thread.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="field" value="pinned">
<input type="hidden" name="value" value="{{ 0 if thread.pinned else 1 }}">
<button class="linkish" type="submit">{{ 'No fijar' if thread.pinned else 'Fijar' }}</button>
</form>
<form method="post" action="{{ url_for('board.set_state', thread_id=thread.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="field" value="locked">
<input type="hidden" name="value" value="{{ 0 if thread.locked else 1 }}">
<button class="linkish" type="submit">{{ 'Reabrir' if thread.locked else 'Cerrar' }}</button>
</form>
{% endif %}
</div>
</article>
<h2 class="section-head">{{ comments|length }} respuesta{{ '' if comments|length == 1 else 's' }}</h2>
{% for c in comments %}
<article class="card card--comment">
<div class="card__meta">
<span>{{ c.author }}</span> · <time>{{ c.created_at }}</time>
{% if c.edited_at %}<span class="muted small">(editado)</span>{% endif %}
</div>
<div class="prose">{{ to_html(c.body_md) }}</div>
<div class="actions">
{% if may_edit(c) %}
<a class="linkish" href="{{ url_for('board.edit_comment', comment_id=c.id) }}">Editar</a>
{% endif %}
{% if may_delete(c) %}
<form method="post" action="{{ url_for('board.delete_comment', comment_id=c.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="linkish linkish--danger" type="submit">Eliminar</button>
</form>
{% endif %}
</div>
</article>
{% endfor %}
<span id="final"></span>
{% if thread.locked and not is_admin %}
<p class="muted">Este tema está cerrado a nuevas respuestas.</p>
{% else %}
<form class="card" method="post" action="{{ url_for('board.comment', thread_id=thread.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="body">Responder</label>
<textarea id="body" name="body" rows="5" required
placeholder="Se puede usar Markdown: **negrita**, listas, enlaces."></textarea>
<button class="btn" type="submit">Publicar respuesta</button>
</form>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}{{ 'Editar tema' if thread else 'Escribir' }}{% endblock %}
{% block main %}
<form class="card" method="post"
action="{{ url_for('board.edit_thread', thread_id=thread.id) if thread else url_for('board.new_thread') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h1>{{ 'Editar tema' if thread else 'Nuevo tema' }}</h1>
<label for="title">Título</label>
<input id="title" name="title" maxlength="140" required
value="{{ thread.title if thread else '' }}">
<label for="body">Mensaje</label>
<textarea id="body" name="body" rows="14" required
placeholder="Se puede usar Markdown: **negrita**, listas, enlaces.">{{ thread.body_md if thread else '' }}</textarea>
<div class="actions">
<button class="btn" type="submit">{{ 'Guardar' if thread else 'Publicar' }}</button>
<a class="linkish" href="{{ url_for('board.thread', thread_id=thread.id) if thread else url_for('board.threads') }}">Cancelar</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Mensajes{% endblock %}
{% block main %}
<div class="feed-head">
<h1>Mensajes</h1>
<a class="btn" href="{{ url_for('board.new_thread') }}">Escribir</a>
</div>
{% if not threads %}
<article class="card">
<p class="muted">Todavía no hay mensajes. Escribe el primero.</p>
</article>
{% endif %}
{% for t in threads %}
<article class="card">
<div class="card__meta">
{% if t.pinned %}<span class="tag">Fijado</span>{% endif %}
{% if t.locked %}<span class="tag tag--quiet">Cerrado</span>{% endif %}
<span>{{ t.author }}</span> · <time>{{ t.created_at }}</time>
</div>
<h2 class="card__title">
<a href="{{ url_for('board.thread', thread_id=t.id) }}">{{ t.title }}</a>
</h2>
<p class="card__excerpt">{{ excerpt(t.body_md) }}</p>
<p class="card__foot">{{ t.replies }} respuesta{{ '' if t.replies == 1 else 's' }}</p>
</article>
{% endfor %}
{% if page > 1 or has_next %}
<nav class="pager">
{% if page > 1 %}<a href="{{ url_for('board.threads', page=page - 1) }}">← Anteriores</a>{% endif %}
{% if has_next %}<a href="{{ url_for('board.threads', page=page + 1) }}">Siguientes →</a>{% endif %}
</nav>
{% endif %}
{% endblock %}

View File

View File

@ -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

View File

@ -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"

View File

@ -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 <script>alert(1)</script>",
})
body = client.get("/comunidad/tema/1").get_data(as_text=True)
assert "<strong>fuerte</strong>" in body
assert "<script>alert(1)</script>" not in body
assert "&lt;script&gt;" 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

View File

@ -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

View File

@ -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"<form[^>]*method=[\"']post[\"'][^>]*>(.*?)</form>", 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"

5
apps/board/wsgi.py Normal file
View File

@ -0,0 +1,5 @@
"""Entry point for gunicorn: `gunicorn apps.board.wsgi:application`."""
from .app import create_app
application = create_app()

40
docker/board/Dockerfile Normal file
View File

@ -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"]

View File

@ -307,3 +307,130 @@ sudo restic -r sftp:uXXXXXX@uXXXXXX.your-storagebox.de:backups init
# then a root cron entry, e.g.: # then a root cron entry, e.g.:
# 0 3 * * * restic -r sftp:... backup /srv /var/www --password-file /root/.restic-pw # 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.

20
infra/board/.env.example Normal file
View File

@ -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=

View File

@ -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"

View File

@ -26,6 +26,17 @@ vienalatina.com {
encode zstd gzip encode zstd gzip
file_server 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) # llms.txt is markdown (matches the old WP behaviour)
header /llms.txt Content-Type "text/markdown; charset=utf-8" header /llms.txt Content-Type "text/markdown; charset=utf-8"

44
scripts/backup-board.sh Executable file
View File

@ -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))"

View File

@ -1,39 +1,66 @@
# Viena Latina — robots.txt # Viena Latina — robots.txt
# Ported from theme/inc/robots-and-llms.php (PR #37): explicit allow for AI crawlers. # 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: * User-agent: *
Allow: / Allow: /
Disallow: /admin/ Disallow: /admin/
Disallow: /comunidad/
# AI crawlers — explicit allow # AI crawlers — explicit allow for the public site, never the private areas
User-agent: GPTBot User-agent: GPTBot
Allow: / Allow: /
Disallow: /admin/
Disallow: /comunidad/
User-agent: ClaudeBot User-agent: ClaudeBot
Allow: / Allow: /
Disallow: /admin/
Disallow: /comunidad/
User-agent: anthropic-ai User-agent: anthropic-ai
Allow: / Allow: /
Disallow: /admin/
Disallow: /comunidad/
User-agent: PerplexityBot User-agent: PerplexityBot
Allow: / Allow: /
Disallow: /admin/
Disallow: /comunidad/
User-agent: Google-Extended User-agent: Google-Extended
Allow: / Allow: /
Disallow: /admin/
Disallow: /comunidad/
User-agent: CCBot User-agent: CCBot
Allow: / Allow: /
Disallow: /admin/
Disallow: /comunidad/
User-agent: Amazonbot User-agent: Amazonbot
Allow: / Allow: /
Disallow: /admin/
Disallow: /comunidad/
User-agent: Bytespider User-agent: Bytespider
Allow: / Allow: /
Disallow: /admin/
Disallow: /comunidad/
User-agent: Applebot-Extended User-agent: Applebot-Extended
Allow: / Allow: /
Disallow: /admin/
Disallow: /comunidad/
User-agent: meta-externalagent User-agent: meta-externalagent
Allow: / Allow: /
Disallow: /admin/
Disallow: /comunidad/
Sitemap: https://vienalatina.com/sitemap.xml Sitemap: https://vienalatina.com/sitemap.xml