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/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/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 %} +
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 %} -