Merge branch 'claude/relaxed-faraday-h4zd09' of https://github.com/pablovolenski/vienalatina
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
commit
16e4905dcb
9
.gitignore
vendored
9
.gitignore
vendored
@ -7,3 +7,12 @@ resources/
|
||||
static/admin/decap-cms.js
|
||||
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
|
||||
# Members area: secrets and live data. The .env holds the OAuth client secret
|
||||
# and, where configured, a Gitea site-admin token; board.db holds every
|
||||
# member's name, address and writing.
|
||||
infra/board/.env
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
65
README.md
65
README.md
@ -1,24 +1,28 @@
|
||||
# Viena Latina
|
||||
|
||||
Static, self-hosted, trilingual blog for the Latin American community in
|
||||
Vienna — Hugo + Decap CMS + Gitea + Woodpecker CI on a single Hetzner CX22
|
||||
(Nuremberg, DE). Replaces the previous WordPress + Polylang + synchronous
|
||||
DeepL stack.
|
||||
Self-hosted, trilingual site for the Latin American community in Vienna —
|
||||
Hugo + Decap CMS + Gitea + Woodpecker CI on a single Hetzner CX22 (Falkenstein,
|
||||
DE). Replaces the previous WordPress + Polylang + synchronous DeepL stack.
|
||||
|
||||
Translation is self-hosted too: M2M100 418M (MIT) runs on CPU via CTranslate2
|
||||
inside the pipeline image. No API key, no quota, and no visitor or content data
|
||||
leaving the server.
|
||||
Translation is self-hosted too: OPUS-MT (CC-BY-4.0) runs on CPU via CTranslate2
|
||||
in the pipeline image, with the models mounted from the host. No API key, no
|
||||
quota, and no visitor or content data leaving the server.
|
||||
|
||||
```
|
||||
Pablo ──► Decap CMS (/admin) ──commit──► Gitea ──webhook──► Woodpecker CI
|
||||
│
|
||||
translate (M2M100, async) ──┤
|
||||
build (hugo) ─┤
|
||||
deploy (rsync) ─┘
|
||||
▼
|
||||
Caddy 2 serves /var/www/vienalatina.com
|
||||
▲ │
|
||||
│ translate ──────┤
|
||||
OAuth2 build (hugo) ─┤
|
||||
│ deploy (rsync) ─┘
|
||||
│ ▼
|
||||
Members ──► /comunidad/ ────────────────┘ Caddy 2 serves /var/www/vienalatina.com
|
||||
Flask + SQLite …and proxies /comunidad/ to Flask
|
||||
```
|
||||
|
||||
Everything except `/comunidad/` is a static file built from git. The members
|
||||
area is the one component that runs code to answer a request, and the one whose
|
||||
data is not reproducible from the repository — see **Members area** below.
|
||||
|
||||
## Languages
|
||||
|
||||
Spanish, German (`/de/`) and Brazilian Portuguese (`/pt-br/`). **Any of the
|
||||
@ -139,6 +143,41 @@ client ID in `app_id`. The Decap JS bundle is downloaded at build time into
|
||||
`static/admin/decap-cms.js` (gitignored) — zero third-party requests at
|
||||
runtime.
|
||||
|
||||
## Members area
|
||||
|
||||
`apps/board/` — a small Flask app at `/comunidad/`, behind Caddy, holding roles
|
||||
and an internal message board. Signed-in members only.
|
||||
|
||||
```
|
||||
apps/board/
|
||||
app.py factory, config, the CSRF and noindex hooks
|
||||
auth.py Gitea OAuth2 (confidential client) and the membership gate
|
||||
members.py roles, provisioning, ownership transfer, GDPR erasure/export
|
||||
board.py threads and comments
|
||||
render.py markdown with raw HTML disabled
|
||||
schema.sql three tables and the one-owner index
|
||||
tests/ pytest, 41 checks — `python3 -m pytest apps/board/tests`
|
||||
```
|
||||
|
||||
Three things about it are load-bearing and easy to undo by accident:
|
||||
|
||||
- **A Gitea account is not a membership.** Login succeeds only for an active row
|
||||
in `members`. Drop that check and every account on the instance gets in,
|
||||
starting with the translations bot.
|
||||
- **One owner, enforced by a partial unique index**, not by application code.
|
||||
The owner cannot be suspended or demoted by anyone; stepping down means
|
||||
transferring ownership to an admin.
|
||||
- **`html=False` in `render.py`** is the entire XSS defence, and it works
|
||||
because markdown-it then emits only its own tags. Turning it on means owning a
|
||||
sanitiser allowlist forever.
|
||||
|
||||
Admins can delete anyone's post; **nobody can edit anyone else's**, admins
|
||||
included. Removing a post is visible to its author, quietly rewriting it is not.
|
||||
|
||||
Its SQLite database is the only state on the server that git does not hold.
|
||||
`scripts/backup-board.sh` takes a consistent snapshot nightly — see
|
||||
`docs/server-setup.md` §11.
|
||||
|
||||
## GEO/SEO surfaces
|
||||
|
||||
- `hreflang` + `og:locale(:alternate)` + JSON-LD `BlogPosting`/`Blog` with
|
||||
|
||||
1
apps/board/__init__.py
Normal file
1
apps/board/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Members area for vienalatina.com — roles and an internal message board."""
|
||||
132
apps/board/app.py
Normal file
132
apps/board/app.py
Normal 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
132
apps/board/auth.py
Normal 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
255
apps/board/board.py
Normal 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
102
apps/board/db.py
Normal 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
115
apps/board/gitea.py
Normal 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
248
apps/board/members.py
Normal 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
40
apps/board/render.py
Normal 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] + " …"
|
||||
12
apps/board/requirements.txt
Normal file
12
apps/board/requirements.txt
Normal 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
56
apps/board/schema.sql
Normal 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
64
apps/board/security.py
Normal 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
275
apps/board/static/board.css
Normal 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; }
|
||||
}
|
||||
15
apps/board/static/board.js
Normal file
15
apps/board/static/board.js
Normal 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();
|
||||
}
|
||||
});
|
||||
58
apps/board/templates/base.html
Normal file
58
apps/board/templates/base.html
Normal 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>
|
||||
17
apps/board/templates/comment_form.html
Normal file
17
apps/board/templates/comment_form.html
Normal 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 %}
|
||||
10
apps/board/templates/error.html
Normal file
10
apps/board/templates/error.html
Normal 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 %}
|
||||
17
apps/board/templates/login.html
Normal file
17
apps/board/templates/login.html
Normal 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 %}
|
||||
27
apps/board/templates/member_created.html
Normal file
27
apps/board/templates/member_created.html
Normal 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 %}
|
||||
41
apps/board/templates/member_new.html
Normal file
41
apps/board/templates/member_new.html
Normal 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 %}
|
||||
72
apps/board/templates/members.html
Normal file
72
apps/board/templates/members.html
Normal 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 %}
|
||||
79
apps/board/templates/thread.html
Normal file
79
apps/board/templates/thread.html
Normal 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 %}
|
||||
23
apps/board/templates/thread_form.html
Normal file
23
apps/board/templates/thread_form.html
Normal 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 %}
|
||||
37
apps/board/templates/threads.html
Normal file
37
apps/board/templates/threads.html
Normal 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 %}
|
||||
0
apps/board/tests/__init__.py
Normal file
0
apps/board/tests/__init__.py
Normal file
89
apps/board/tests/conftest.py
Normal file
89
apps/board/tests/conftest.py
Normal 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
|
||||
104
apps/board/tests/test_auth.py
Normal file
104
apps/board/tests/test_auth.py
Normal 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"
|
||||
110
apps/board/tests/test_board.py
Normal file
110
apps/board/tests/test_board.py
Normal 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 "<script>" in body
|
||||
|
||||
|
||||
def test_nobody_edits_someone_elses_words(client, post, thread, make_member, sign_in):
|
||||
"""Not even an admin. Removing a post is visible to its author; quietly
|
||||
rewriting it is not, which is why moderation here means deletion."""
|
||||
sign_in(make_member("admina", role="admin"))
|
||||
response = post(f"/comunidad/tema/{thread['id']}/editar",
|
||||
{"title": "Reescrito", "body": "Otra cosa"})
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_a_user_cannot_delete_someone_elses_thread(client, post, thread, make_member, sign_in):
|
||||
sign_in(make_member("ajena"))
|
||||
assert post(f"/comunidad/tema/{thread['id']}/eliminar").status_code == 403
|
||||
|
||||
|
||||
def test_an_admin_can_delete_any_thread(client, db, post, thread, make_member, sign_in):
|
||||
sign_in(make_member("admina", role="admin"))
|
||||
post(f"/comunidad/tema/{thread['id']}/eliminar")
|
||||
row = db.execute("SELECT deleted_at FROM threads WHERE id = ?", (thread["id"],)).fetchone()
|
||||
assert row["deleted_at"] is not None
|
||||
|
||||
|
||||
def test_the_author_can_edit_their_own(client, db, post, thread, sign_in):
|
||||
sign_in(thread["author"])
|
||||
post(f"/comunidad/tema/{thread['id']}/editar", {"title": "Corregido", "body": "Mejor"})
|
||||
row = db.execute("SELECT title, edited_at FROM threads WHERE id = ?",
|
||||
(thread["id"],)).fetchone()
|
||||
assert row["title"] == "Corregido"
|
||||
assert row["edited_at"] is not None
|
||||
|
||||
|
||||
def test_a_deleted_thread_disappears_from_the_list_and_the_page(
|
||||
client, db, post, thread, sign_in):
|
||||
sign_in(thread["author"])
|
||||
post(f"/comunidad/tema/{thread['id']}/eliminar")
|
||||
assert "Original" not in client.get("/comunidad/").get_data(as_text=True)
|
||||
assert client.get(f"/comunidad/tema/{thread['id']}").status_code == 404
|
||||
|
||||
|
||||
def test_a_deleted_thread_is_left_out_of_the_export(client, db, post, thread, sign_in):
|
||||
sign_in(thread["author"])
|
||||
post(f"/comunidad/tema/{thread['id']}/eliminar")
|
||||
assert "Original" not in client.get("/comunidad/mis-datos").get_data(as_text=True)
|
||||
|
||||
|
||||
def test_a_closed_thread_refuses_replies(client, db, post, thread, make_member, sign_in):
|
||||
db.execute("UPDATE threads SET locked = 1 WHERE id = ?", (thread["id"],))
|
||||
sign_in(make_member("maria"))
|
||||
post(f"/comunidad/tema/{thread['id']}/comentar", {"body": "¿Hola?"})
|
||||
count = db.execute("SELECT COUNT(*) AS n FROM comments WHERE thread_id = ?",
|
||||
(thread["id"],)).fetchone()["n"]
|
||||
assert count == 0
|
||||
|
||||
|
||||
def test_only_admins_pin(client, post, thread, make_member, sign_in):
|
||||
sign_in(make_member("maria"))
|
||||
assert post(f"/comunidad/tema/{thread['id']}/estado",
|
||||
{"field": "pinned", "value": "1"}).status_code == 403
|
||||
|
||||
|
||||
def test_the_state_field_is_not_a_way_into_the_query(client, post, thread, make_member, sign_in):
|
||||
"""`field` is interpolated into the UPDATE, so it is checked against a fixed
|
||||
pair first. This asserts the check, not the interpolation."""
|
||||
sign_in(make_member("admina", role="admin"))
|
||||
assert post(f"/comunidad/tema/{thread['id']}/estado",
|
||||
{"field": "role", "value": "1"}).status_code == 400
|
||||
|
||||
|
||||
def test_the_cooldown_stops_a_double_submit(app, client, post, make_member, sign_in, db):
|
||||
app.config["COOLDOWN_SECONDS"] = 60
|
||||
sign_in(make_member("rapida"))
|
||||
post("/comunidad/nuevo", {"title": "Primero", "body": "Uno"})
|
||||
post("/comunidad/nuevo", {"title": "Segundo", "body": "Dos"})
|
||||
count = db.execute("SELECT COUNT(*) AS n FROM threads").fetchone()["n"]
|
||||
assert count == 1
|
||||
184
apps/board/tests/test_members.py
Normal file
184
apps/board/tests/test_members.py
Normal 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
|
||||
38
apps/board/tests/test_templates.py
Normal file
38
apps/board/tests/test_templates.py
Normal 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
5
apps/board/wsgi.py
Normal 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
40
docker/board/Dockerfile
Normal 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"]
|
||||
@ -307,3 +307,130 @@ sudo restic -r sftp:uXXXXXX@uXXXXXX.your-storagebox.de:backups init
|
||||
# then a root cron entry, e.g.:
|
||||
# 0 3 * * * restic -r sftp:... backup /srv /var/www --password-file /root/.restic-pw
|
||||
```
|
||||
|
||||
## 11. Members area (`/comunidad/`)
|
||||
|
||||
The private area: roles and an internal board. It is the only part of the site
|
||||
that runs code to answer a request, and the only data on the server that is not
|
||||
already in git.
|
||||
|
||||
### 11.1 Register the OAuth application
|
||||
|
||||
Gitea → **Site Administration → Integrations → Applications** →
|
||||
*Create new OAuth2 application*:
|
||||
|
||||
- Name: `vienalatina-board`
|
||||
- Redirect URI: `https://vienalatina.com/comunidad/auth/callback`
|
||||
- **Leave "Confidential Client" TICKED.**
|
||||
|
||||
That last point is the opposite of the Decap application in step 6.2, and the
|
||||
difference is worth understanding rather than memorising. Decap runs in the
|
||||
visitor's browser, where any secret would be readable by the visitor, so it has
|
||||
to be a public client using PKCE. The board runs on the server, so it can hold
|
||||
a secret and should — a confidential client is the stronger of the two.
|
||||
|
||||
Save the **Client ID** and the **Client Secret**.
|
||||
|
||||
### 11.2 Optional: a token for creating accounts
|
||||
|
||||
Without it, admins can add people who already have a Gitea login, and nothing
|
||||
else changes. With it, they can create the Gitea account from inside the members
|
||||
area and hand over a one-time password.
|
||||
|
||||
Log in as a Gitea **site administrator** → Settings → Applications → *Generate
|
||||
New Token* → scope **admin (write)**.
|
||||
|
||||
Understand what this token is before you create it: it can create and modify any
|
||||
account on the instance, including administrators. Anything that can read the
|
||||
board's environment — the compose file, `docker inspect`, a shell in the
|
||||
container — can use it. If you would rather not have that on the box, leave
|
||||
`GITEA_ADMIN_TOKEN` empty and create accounts in Gitea by hand.
|
||||
|
||||
### 11.3 Build and run
|
||||
|
||||
```sh
|
||||
cd ~/vienalatina
|
||||
docker build -t vienalatina/board:1 -f docker/board/Dockerfile .
|
||||
|
||||
sudo mkdir -p /srv/board/data
|
||||
sudo cp -r infra/board/. /srv/board/
|
||||
cd /srv/board
|
||||
sudo cp .env.example .env
|
||||
openssl rand -hex 32 # paste as BOARD_SECRET_KEY
|
||||
sudo nano .env # client id, secret, BOARD_OWNER, optional admin token
|
||||
sudo chown -R 1000:1000 /srv/board/data
|
||||
sudo docker compose up -d
|
||||
```
|
||||
|
||||
`BOARD_OWNER` is applied once, to an empty database, and ignored from then on.
|
||||
It cannot be used to take ownership later: that is deliberate, because otherwise
|
||||
editing a file on disk would be a quieter route to the top than asking for it.
|
||||
Ownership moves only through *Transferir titularidad* inside the app.
|
||||
|
||||
### 11.4 Route it through Caddy
|
||||
|
||||
Add to the `vienalatina.com` block in `/etc/caddy/Caddyfile` (already present in
|
||||
`infra/caddy/Caddyfile`):
|
||||
|
||||
```
|
||||
@board path /comunidad /comunidad/*
|
||||
reverse_proxy @board 127.0.0.1:8080
|
||||
```
|
||||
|
||||
Then `sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy`.
|
||||
|
||||
Both paths are matched on purpose: Flask redirects `/comunidad` to
|
||||
`/comunidad/`, and matching only the trailing-slash form lets the bare path fall
|
||||
through to the static site and 404.
|
||||
|
||||
### 11.5 Back it up — this part is not optional
|
||||
|
||||
Everything else on this server is reproducible from the repository. The board's
|
||||
threads, comments and membership exist in exactly one place.
|
||||
|
||||
```sh
|
||||
sudo apt install -y sqlite3
|
||||
crontab -e
|
||||
# 15 4 * * * /home/pablo/vienalatina/scripts/backup-board.sh >> /home/pablo/board-backup.log 2>&1
|
||||
```
|
||||
|
||||
The script uses SQLite's `.backup` rather than copying the file, because the
|
||||
database is live and in WAL mode — a plain `cp` can capture it missing its most
|
||||
recent commits. Test a restore before you rely on it: stop the container, gunzip
|
||||
a backup over `/srv/board/data/board.db`, start it again.
|
||||
|
||||
### 11.6 Who can do what
|
||||
|
||||
| | Owner | Admin | User |
|
||||
|---|---|---|---|
|
||||
| Post, comment, edit own | ✓ | ✓ | ✓ |
|
||||
| Delete any post | ✓ | ✓ | — |
|
||||
| Edit someone else's post | — | — | — |
|
||||
| Pin and close threads | ✓ | ✓ | — |
|
||||
| Create users | ✓ | ✓ | — |
|
||||
| Create admins | ✓ | — | — |
|
||||
| Suspend a user | ✓ | ✓ | — |
|
||||
| Suspend an admin | ✓ | — | — |
|
||||
| Transfer ownership | ✓ | — | — |
|
||||
|
||||
Nobody edits anyone else's words, administrators included. Taking a post down is
|
||||
visible to the person who wrote it; rewriting it is not, and an admin who could
|
||||
do that could leave a sentence attributed to a member who never wrote it.
|
||||
|
||||
There is exactly one owner, and the database enforces it with a unique index
|
||||
rather than trusting the application to remember. The owner cannot be suspended
|
||||
or demoted by anyone, themselves included — to step down, transfer ownership to
|
||||
an admin.
|
||||
|
||||
### 11.7 Personal data
|
||||
|
||||
Members' names, emails and writing are personal data under GDPR.
|
||||
|
||||
- **Erasure:** the owner's *Eliminar* removes the member row entirely and
|
||||
reassigns their threads and comments to a tombstone shown as "Miembro
|
||||
eliminado", so conversations other people took part in stay readable.
|
||||
- **Access:** any member can download everything they have written from
|
||||
*Descargar mis datos*.
|
||||
- **Retention:** soft-deleted posts stay in the database until removed by hand.
|
||||
If you want a real retention limit, that is a `DELETE ... WHERE deleted_at <`
|
||||
in this same cron slot — and a decision to take deliberately, not by default.
|
||||
|
||||
20
infra/board/.env.example
Normal file
20
infra/board/.env.example
Normal 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=
|
||||
41
infra/board/docker-compose.yml
Normal file
41
infra/board/docker-compose.yml
Normal 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"
|
||||
@ -26,6 +26,17 @@ vienalatina.com {
|
||||
encode zstd gzip
|
||||
file_server
|
||||
|
||||
# Members area — the only part of this site that runs code. Both paths are
|
||||
# matched: Flask redirects /comunidad to /comunidad/, and a matcher of
|
||||
# /comunidad/* alone would let the bare path fall through to file_server
|
||||
# and 404 before the app ever sees it.
|
||||
#
|
||||
# No `handle` wrapper is needed. Caddy runs reverse_proxy ahead of
|
||||
# file_server in its directive order, and reverse_proxy is terminal, so a
|
||||
# matched request never reaches the static tree.
|
||||
@board path /comunidad /comunidad/*
|
||||
reverse_proxy @board 127.0.0.1:8080
|
||||
|
||||
# llms.txt is markdown (matches the old WP behaviour)
|
||||
header /llms.txt Content-Type "text/markdown; charset=utf-8"
|
||||
|
||||
|
||||
44
scripts/backup-board.sh
Executable file
44
scripts/backup-board.sh
Executable 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))"
|
||||
@ -1,39 +1,66 @@
|
||||
# Viena Latina — robots.txt
|
||||
# Ported from theme/inc/robots-and-llms.php (PR #37): explicit allow for AI crawlers.
|
||||
#
|
||||
# The two Disallow lines are repeated in every group on purpose. A crawler obeys
|
||||
# exactly one group — the most specific one matching its name — and ignores the
|
||||
# rest, so a Disallow that appears only under `User-agent: *` does not apply to
|
||||
# GPTBot or ClaudeBot at all. Listing an agent to allow it would otherwise
|
||||
# quietly hand it the private areas as well.
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /admin/
|
||||
Disallow: /comunidad/
|
||||
|
||||
# AI crawlers — explicit allow
|
||||
# AI crawlers — explicit allow for the public site, never the private areas
|
||||
User-agent: GPTBot
|
||||
Allow: /
|
||||
Disallow: /admin/
|
||||
Disallow: /comunidad/
|
||||
|
||||
User-agent: ClaudeBot
|
||||
Allow: /
|
||||
Disallow: /admin/
|
||||
Disallow: /comunidad/
|
||||
|
||||
User-agent: anthropic-ai
|
||||
Allow: /
|
||||
Disallow: /admin/
|
||||
Disallow: /comunidad/
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Allow: /
|
||||
Disallow: /admin/
|
||||
Disallow: /comunidad/
|
||||
|
||||
User-agent: Google-Extended
|
||||
Allow: /
|
||||
Disallow: /admin/
|
||||
Disallow: /comunidad/
|
||||
|
||||
User-agent: CCBot
|
||||
Allow: /
|
||||
Disallow: /admin/
|
||||
Disallow: /comunidad/
|
||||
|
||||
User-agent: Amazonbot
|
||||
Allow: /
|
||||
Disallow: /admin/
|
||||
Disallow: /comunidad/
|
||||
|
||||
User-agent: Bytespider
|
||||
Allow: /
|
||||
Disallow: /admin/
|
||||
Disallow: /comunidad/
|
||||
|
||||
User-agent: Applebot-Extended
|
||||
Allow: /
|
||||
Disallow: /admin/
|
||||
Disallow: /comunidad/
|
||||
|
||||
User-agent: meta-externalagent
|
||||
Allow: /
|
||||
Disallow: /admin/
|
||||
Disallow: /comunidad/
|
||||
|
||||
Sitemap: https://vienalatina.com/sitemap.xml
|
||||
|
||||
Loading…
Reference in New Issue
Block a user