diff --git a/apps/board/app.py b/apps/board/app.py index fc67b0b..cf08581 100644 --- a/apps/board/app.py +++ b/apps/board/app.py @@ -78,6 +78,10 @@ def create_app(overrides: dict | None = None) -> Flask: # picture is silently refused has no way to tell what went wrong. MAX_CONTENT_LENGTH=10 * 1024 * 1024, UPLOAD_MAX_BYTES=int(os.environ.get("BOARD_UPLOAD_MAX_BYTES", 8 * 1024 * 1024)), + # Board pictures, deliberately inside the volume that already + # holds board.db: one directory to back up, not two, and no + # second mount to remember when moving the app to a new box. + UPLOAD_DIR=os.environ.get("BOARD_UPLOAD_DIR", "/data/uploads"), ) if overrides: app.config.update(overrides) @@ -87,10 +91,11 @@ def create_app(overrides: dict | None = None) -> Flask: # 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, content, members + from . import auth, board, content, members, uploads app.register_blueprint(auth.bp, url_prefix=URL_PREFIX) app.register_blueprint(members.bp, url_prefix=URL_PREFIX) app.register_blueprint(content.bp, url_prefix=URL_PREFIX) + app.register_blueprint(uploads.bp, url_prefix=URL_PREFIX) app.register_blueprint(board.bp, url_prefix=URL_PREFIX) app.teardown_appcontext(close_db) diff --git a/apps/board/auth.py b/apps/board/auth.py index 1749863..a073034 100644 --- a/apps/board/auth.py +++ b/apps/board/auth.py @@ -81,7 +81,7 @@ def callback(): code = request.args.get("code", "") if not code: - flash("Gitea no devolvió un código de autorización.", "error") + flash("No se recibió el código de autorización. Inténtalo de nuevo.", "error") return redirect(url_for("auth.login")) try: @@ -93,7 +93,8 @@ def callback(): 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") + flash("No se pudo contactar con el servidor de cuentas. " + "Inténtalo más tarde.", "error") return redirect(url_for("auth.login")) login_name = (profile.get("login") or "").strip() diff --git a/apps/board/board.py b/apps/board/board.py index d6e3f93..9e58a90 100644 --- a/apps/board/board.py +++ b/apps/board/board.py @@ -18,6 +18,7 @@ from __future__ import annotations from flask import (Blueprint, abort, current_app, flash, g, redirect, render_template, request, url_for) +from . import uploads from .db import get_db from .render import excerpt, to_html from .security import admin_required, login_required @@ -130,7 +131,9 @@ def thread(thread_id: int): 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()) + is_admin=is_admin(), + thread_images=uploads.for_threads([thread_id]).get(thread_id, []), + comment_images=uploads.for_comments([c["id"] for c in comments])) @bp.route("/nuevo", methods=["GET", "POST"]) @@ -147,11 +150,20 @@ def new_thread(): flash(f"Espera {wait} segundos antes de publicar otra vez.", "error") return redirect(url_for("board.new_thread")) + # Checked before the thread exists, so a refused picture does not leave a + # half-made post behind for its author to find and wonder about. + try: + staged = uploads.stage(request.files.getlist("pictures")) + except uploads.RejectedUpload as exc: + flash(str(exc), "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), ) + uploads.save(staged, g.member["id"], thread_id=cursor.lastrowid) return redirect(url_for("board.thread", thread_id=cursor.lastrowid)) @@ -203,10 +215,17 @@ def comment(thread_id: int): flash(f"Espera {wait} segundos antes de comentar otra vez.", "error") return redirect(url_for("board.thread", thread_id=thread_id)) - get_db().execute( + try: + staged = uploads.stage(request.files.getlist("pictures")) + except uploads.RejectedUpload as exc: + flash(str(exc), "error") + return redirect(url_for("board.thread", thread_id=thread_id)) + + cursor = get_db().execute( "INSERT INTO comments (thread_id, author_id, body_md) VALUES (?, ?, ?)", (thread_id, g.member["id"], body), ) + uploads.save(staged, g.member["id"], comment_id=cursor.lastrowid) return redirect(url_for("board.thread", thread_id=thread_id) + "#final") diff --git a/apps/board/content.py b/apps/board/content.py index f9ad999..5f9aec0 100644 --- a/apps/board/content.py +++ b/apps/board/content.py @@ -34,6 +34,10 @@ from . import gitea, tokens from .db import get_db from .render import to_html from .security import admin_required +# One list of accepted formats for the whole app, kept in the module that +# knows what each one looks like on the wire, so the editor and the board +# cannot drift apart about what a picture is. +from .uploads import IMAGE_EXTENSIONS bp = Blueprint("content", __name__) @@ -51,7 +55,6 @@ COLLECTIONS = { CATEGORIES = ["Turismo", "Cultura", "Gastronomía", "Comunidad", "Comercio"] UPLOAD_FOLDER = "static/uploads" -IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "webp", "gif", "avif"} TITLE_MAX = 140 BODY_MAX = 100_000 diff --git a/apps/board/gitea.py b/apps/board/gitea.py index 9f174a1..9ca0198 100644 --- a/apps/board/gitea.py +++ b/apps/board/gitea.py @@ -86,7 +86,7 @@ def _token_request(payload: dict) -> dict: raise GiteaError("No se pudo completar el inicio de sesión.") data = response.json() if not data.get("access_token"): - raise GiteaError("Gitea no devolvió un token de acceso.") + raise GiteaError("El servidor de cuentas no devolvió un token de acceso.") return data @@ -115,7 +115,7 @@ def fetch_user(token: str) -> dict: timeout=TIMEOUT, ) if response.status_code != 200: - raise GiteaError("No se pudo leer el perfil desde Gitea.") + raise GiteaError("No se pudo leer tu perfil desde el servidor de cuentas.") return response.json() @@ -163,10 +163,10 @@ def admin_create_user(login: str, email: str, full_name: str, password: str) -> if response.status_code in (201, 200): return if response.status_code == 422: - raise GiteaError("Ese usuario o correo ya existe en Gitea.") + raise GiteaError("Ese usuario o ese correo ya están en uso.") 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}).") + raise GiteaError("El token de administración no es válido.") + raise GiteaError(f"No se pudo crear la cuenta ({response.status_code}).") def admin_set_password(login: str, password: str) -> None: @@ -204,19 +204,19 @@ def admin_set_password(login: str, password: str) -> None: # Gitea enforces its own minimum length and complexity, and its message # is in the admin's language rather than the member's, so it is not # passed through. - raise GiteaError("Gitea rechazó esa contraseña. Prueba con una más larga.") + raise GiteaError("No se aceptó esa contraseña. Prueba con una más larga.") if response.status_code in (401, 403): - raise GiteaError("El token de administración de Gitea no es válido.") + raise GiteaError("El token de administración no es válido.") if response.status_code == 404: # Reachable: a member can be added here without ticking "crear también # su cuenta", and then invited. Everything works right up to this call, # which is asked to change the password of an account that was never # made. A bare "(404)" sends the admin looking at the wrong thing. raise GiteaError( - f"No existe la cuenta «{login}» en Gitea, así que no se le puede " + f"No existe la cuenta «{login}», así que no se le puede " "poner contraseña. Pide a un administrador que la cree." ) - raise GiteaError(f"Gitea rechazó el cambio de contraseña ({response.status_code}).") + raise GiteaError(f"No se pudo cambiar la contraseña ({response.status_code}).") # --- content (the member's own token) ------------------------------------ @@ -245,7 +245,7 @@ def list_directory(path: str, token: str) -> list[dict]: if response.status_code == 404: return [] # an empty content folder is normal, not an error if response.status_code != 200: - raise GiteaError(f"Gitea no devolvió la lista de archivos ({response.status_code}).") + raise GiteaError(f"No se pudo leer la lista de archivos ({response.status_code}).") payload = response.json() return [item for item in payload if item.get("type") == "file"] @@ -282,7 +282,7 @@ def write_file(path: str, data: bytes, message: str, token: str, "Alguien más guardó este archivo mientras lo editabas. " "Vuelve a abrirlo para no perder su trabajo." ) - raise GiteaError(f"Gitea rechazó el guardado ({response.status_code}).") + raise GiteaError(f"No se pudo guardar el archivo ({response.status_code}).") def delete_file(path: str, sha: str, message: str, token: str) -> None: @@ -293,4 +293,4 @@ def delete_file(path: str, sha: str, message: str, token: str) -> None: return if response.status_code in (409, 422): raise StaleFile("El archivo cambió desde que lo abriste. Recarga la lista.") - raise GiteaError(f"Gitea rechazó el borrado ({response.status_code}).") + raise GiteaError(f"No se pudo borrar el archivo ({response.status_code}).") diff --git a/apps/board/members.py b/apps/board/members.py index d6d2218..5d2d52a 100644 --- a/apps/board/members.py +++ b/apps/board/members.py @@ -148,7 +148,7 @@ def new(): 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") + flash("Hace falta un correo válido para crear la cuenta.", "error") return redirect(url_for("members.new")) db = get_db() diff --git a/apps/board/schema.sql b/apps/board/schema.sql index 4be14bd..b46b7a6 100644 --- a/apps/board/schema.sql +++ b/apps/board/schema.sql @@ -55,6 +55,32 @@ CREATE TABLE IF NOT EXISTS comments ( CREATE INDEX IF NOT EXISTS comments_thread ON comments(thread_id, created_at) WHERE deleted_at IS NULL; +-- Pictures attached to a thread or a comment. +-- +-- The file itself lives in /data/uploads; this is the record of what it is and +-- what it belongs to. `stored_name` is generated, never the name the browser +-- sent, and is UNIQUE because it is also the URL. +-- +-- The CHECK is the shape of the thing: an attachment hangs off exactly one of +-- the two, never both and never neither. Without it a row with both columns +-- set would be served under whichever parent was still alive, which is a +-- quiet way for a deleted thread's photo to stay readable. +CREATE TABLE IF NOT EXISTS attachments ( + id INTEGER PRIMARY KEY, + thread_id INTEGER REFERENCES threads(id), + comment_id INTEGER REFERENCES comments(id), + stored_name TEXT NOT NULL UNIQUE, + original_name TEXT NOT NULL, + content_type TEXT NOT NULL, + bytes INTEGER NOT NULL, + uploaded_by INTEGER NOT NULL REFERENCES members(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + CHECK ((thread_id IS NULL) <> (comment_id IS NULL)) +); + +CREATE INDEX IF NOT EXISTS attachments_thread ON attachments(thread_id); +CREATE INDEX IF NOT EXISTS attachments_comment ON attachments(comment_id); + -- Gitea access tokens for the editor. -- -- Kept here rather than in the session cookie. Flask signs cookies but does not diff --git a/apps/board/static/board.css b/apps/board/static/board.css index 174fabc..80cbdb4 100644 --- a/apps/board/static/board.css +++ b/apps/board/static/board.css @@ -306,3 +306,21 @@ input[type="file"] { padding: 0.5rem 0; font-size: 0.9rem; } + +/* Attached pictures. A row that wraps, thumbnails rather than full-bleed: + a thread with four photos should still read as a conversation. */ +.shots { + list-style: none; + margin: 0.75rem 0 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.shots img { + display: block; + max-height: 220px; + max-width: 100%; + border-radius: var(--radius-md); + border: 1px solid var(--border-light); +} diff --git a/apps/board/templates/_attachments.html b/apps/board/templates/_attachments.html new file mode 100644 index 0000000..9394444 --- /dev/null +++ b/apps/board/templates/_attachments.html @@ -0,0 +1,16 @@ +{# Pictures on a thread or a comment. Each one links to itself so a photo can + be opened at full size without needing a viewer — the link is the viewer. #} +{% macro attachments(images) %} +{% if images %} +
- Tu sesión aquí está cerrada. Pero este navegador sigue conectado a - Gitea, que es donde se guardan las cuentas — así que quien use - este ordenador después podría volver a entrar sin contraseña. + Tu sesión aquí está cerrada. Pero este navegador sigue conectado al + servidor de cuentas, que es donde se guardan las contraseñas — así + que quien use este ordenador después podría volver a entrar sin escribirla.
- Cerrar sesión también en Gitea + Cerrar sesión del todo
- Si esa página no te desconecta, abre el menú de tu perfil en - Gitea y elige Sign Out. + Si esa página no te desconecta, abre el menú de tu perfil + ahí y elige Sign Out. Cerrar el navegador también sirve.
diff --git a/apps/board/templates/login.html b/apps/board/templates/login.html index 477646b..9676b22 100644 --- a/apps/board/templates/login.html +++ b/apps/board/templates/login.html @@ -8,7 +8,7 @@ Este espacio es sólo para miembros de Viena Latina. Se entra con la misma cuenta que se usa para publicar en el sitio. - Entrar con Gitea + Entrar {# Offered only when the server can actually complete it: without a Gitea admin token the recovery page can do nothing but apologise. #} {% if can_recover %} diff --git a/apps/board/templates/member_new.html b/apps/board/templates/member_new.html index 3e522b4..4c7198b 100644 --- a/apps/board/templates/member_new.html +++ b/apps/board/templates/member_new.html @@ -30,10 +30,10 @@ Crear también su cuenta- Este servidor no puede crear cuentas (no tiene un token de administración - de Gitea). Crea la cuenta primero en - Gitea y luego da de alta - aquí ese mismo usuario. + Este servidor no puede crear cuentas (le falta el token de administración). + Créala primero en el + servidor de cuentas y luego + da de alta aquí ese mismo usuario.
{% endif %} diff --git a/apps/board/templates/thread.html b/apps/board/templates/thread.html index df48f51..0b51c00 100644 --- a/apps/board/templates/thread.html +++ b/apps/board/templates/thread.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% from "_attachments.html" import attachments %} {% block title %}{{ thread.title }}{% endblock %} {% block main %} @@ -11,6 +12,7 @@Este tema está cerrado a nuevas respuestas.
{% else %} - {% endif %} diff --git a/apps/board/templates/thread_form.html b/apps/board/templates/thread_form.html index 3f3b18f..0f4ee8e 100644 --- a/apps/board/templates/thread_form.html +++ b/apps/board/templates/thread_form.html @@ -2,7 +2,7 @@ {% block title %}{{ 'Editar tema' if thread else 'Escribir' }}{% endblock %} {% block main %} -