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