Add a members area: roles and an internal board
Everything on this site so far has been a file built from git. This is the first component that runs code to answer a request and the first whose data git does not hold, so the trade is stated in the README and the backup script is not optional. Roles are owner, admin and user. The owner is seeded once from BOARD_OWNER and cannot be seeded again, because otherwise editing a compose file would be a quieter way to take the top role than asking for it; ownership moves only by transfer, inside the app. There is exactly one owner and a partial unique index enforces it, so the invariant holds even when a handler is wrong. The owner is beyond suspension and demotion by everyone, themselves included. Only the owner makes admins; admins make users. Sign-in goes through Gitea as a confidential OAuth client — the opposite of Decap, which has to be public because it runs in the browser. The rule the whole thing rests on is that a Gitea account is not a membership: entry needs an active row in `members`, or every account on the instance is a member, starting with the translations bot. Admins can delete any post; nobody can edit anyone else's, admins included. Taking a post down is visible to its author. Quietly rewriting it is not, and an admin who could do that could leave a sentence attributed to someone who never wrote it. The plan said admins could do both; this is the one place the implementation departs from it. Markdown renders with raw HTML disabled, which is the entire XSS defence and the reason there is no sanitiser: the renderer emits only its own tags and escapes the rest. The CSP carries no 'unsafe-inline', which makes an inline onsubmit silently inert rather than broken, so the confirmation dialogs live in a static file and a test fails any template that grows an inline handler. GDPR is in scope rather than deferred: erasure removes the member row and moves their authorship to a tombstone so the conversations around them still read, and any member can download their own writing. Verified: 63 checks pass, covering the membership gate, every role predicate, a direct insert of a second owner being refused by the index, atomic ownership transfer, CSRF, an offsite login redirect, script tags rendering as text, soft deletes leaving both listings and exports, and the member screens rendering for each role. Smoke-tested live: headers, both static assets, and the bare /comunidad redirect that the Caddy matcher has to cover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
This commit is contained in:
parent
da8cc784be
commit
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