From a79e04396b527ac72ee1489923d2c4aa7fed5946 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 19:30:06 +0000 Subject: [PATCH 1/2] Stop showing members the name of the software behind the login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A member signing in has no idea what Gitea is, and for anyone this platform is ever sold to it is a competitor's name on their login page. Fourteen strings named it — a button, a logout page, an account-creation notice and eleven error messages — plus Gitea's own sign-in and authorize screens, which every member passes through. Ours are reworded: "el servidor de cuentas" where the thing has to be referred to at all, and nothing where it did not. Gitea's own screens take APP_NAME plus the two footer switches, which are supported settings rather than a patched template. APP_NAME goes in app.ini's unnamed root section, spelled DEFAULT in the environment mapping, so the docs carry a command to confirm it landed — a key written to a section that does not exist is accepted in silence. Comments, docstrings, column names and env vars keep the real name. The code has to stay honest about what it talks to, none of it reaches a browser, and renaming gitea_login would mean a migration for nothing. Two guards added, since this is the kind of thing that creeps back one error message at a time: no template renders the word outside a Jinja comment, and no string literal outside a docstring contains it. Checked against the previous commit, where they catch the one message that had already been missed by hand. Licence: MIT, no attribution-in-UI clause, and we redistribute nothing — the official image runs unmodified with its own LICENSE intact. Gitea ships the "powered by" switch itself. Reasoning recorded in §12. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn --- apps/board/auth.py | 5 ++-- apps/board/gitea.py | 24 +++++++-------- apps/board/members.py | 2 +- apps/board/templates/logged_out.html | 12 ++++---- apps/board/templates/login.html | 2 +- apps/board/templates/member_new.html | 8 ++--- apps/board/tests/test_invites.py | 2 +- apps/board/tests/test_templates.py | 44 ++++++++++++++++++++++++++++ docs/server-setup.md | 38 ++++++++++++++++++++++++ infra/gitea/docker-compose.yml | 18 ++++++++++++ 10 files changed, 128 insertions(+), 27 deletions(-) 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/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/templates/logged_out.html b/apps/board/templates/logged_out.html index 95b8318..d3bd326 100644 --- a/apps/board/templates/logged_out.html +++ b/apps/board/templates/logged_out.html @@ -8,18 +8,18 @@

Saliste del área de la comunidad

- 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/tests/test_invites.py b/apps/board/tests/test_invites.py index 04b6024..59d087b 100644 --- a/apps/board/tests/test_invites.py +++ b/apps/board/tests/test_invites.py @@ -380,7 +380,7 @@ def test_a_member_without_a_gitea_account_is_named_as_such( {"password": "una-contrasena-larga", "confirm": "una-contrasena-larga"}).get_data(as_text=True) - assert "No existe la cuenta «fantasma» en Gitea" in page + assert "No existe la cuenta «fantasma»" in page assert "404" not in page # And the link survives, so it still works once the account exists. assert db.execute("SELECT used_at FROM invites").fetchone()["used_at"] is None diff --git a/apps/board/tests/test_templates.py b/apps/board/tests/test_templates.py index 2853f0c..61f76d4 100644 --- a/apps/board/tests/test_templates.py +++ b/apps/board/tests/test_templates.py @@ -36,3 +36,47 @@ def test_every_post_form_carries_a_csrf_token(template): re.IGNORECASE | re.DOTALL) for form in forms: assert "csrf_token" in form, f"{template.name} has a POST form without a CSRF token" + + +# --- the name of the software behind the login --------------------------- +# +# Members sign in through an OAuth provider that happens to be Gitea. They are +# never told so: as far as anyone using vienalatina.com is concerned there is +# one site, and a stray brand name in an error message is the seam showing. +# Comments and docstrings are exempt — the code has to stay honest about what +# it talks to, and neither reaches a browser. + +JINJA_COMMENT = re.compile(r"\{#.*?#\}", re.DOTALL) + + +@pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name) +def test_no_template_shows_the_name_of_the_account_server(template): + body = JINJA_COMMENT.sub("", template.read_text(encoding="utf-8")) + assert "Gitea" not in body, ( + f"{template.name} shows 'Gitea' to the member; call it el servidor de " + "cuentas, or put the remark in a {# Jinja comment #}" + ) + + +def test_no_message_in_the_code_shows_it_either(): + """String literals only, docstrings excluded, and case-sensitive on + purpose: `gitea_login` and `gitea_tokens` are column names nobody sees, so + only the capitalised prose form is worth failing on.""" + import ast + + offenders = [] + for path in sorted(Path(__file__).resolve().parents[1].glob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + docstrings = { + doc for node in ast.walk(tree) + if isinstance(node, (ast.Module, ast.FunctionDef, + ast.AsyncFunctionDef, ast.ClassDef)) + and (doc := ast.get_docstring(node, clean=False)) + } + offenders += [ + f"{path.name}:{node.lineno}: {node.value!r}" + for node in ast.walk(tree) + if isinstance(node, ast.Constant) and isinstance(node.value, str) + and "Gitea" in node.value and node.value not in docstrings + ] + assert not offenders, "\n".join(offenders) diff --git a/docs/server-setup.md b/docs/server-setup.md index 86d6d72..8bb0f01 100644 --- a/docs/server-setup.md +++ b/docs/server-setup.md @@ -586,6 +586,44 @@ How often anyone sees them is worth knowing before judging the result: the and the **sign-in form only when their Gitea session has lapsed**, which "Remember This Device" pushes out to weeks. This is a first-impression fix. +### The name, not just the colours + +Themed or not, those two screens said **Gitea** — in the tab, the heading and +the footer. A member has no idea what that is, and for anyone the platform is +ever sold to it is a competitor's name on their login page. Three settings in +`/srv/gitea/docker-compose.yml` take care of it: + +``` +GITEA__DEFAULT__APP_NAME=Viena Latina +GITEA__other__SHOW_FOOTER_POWERED_BY=false +GITEA__other__SHOW_FOOTER_VERSION=false +``` + +`APP_NAME` lives in `app.ini`'s unnamed root section, which the environment +mapping spells `DEFAULT`. **Check it took**, because a key written to a section +that does not exist is accepted in silence: + +```sh +cd /srv/gitea && sudo docker compose up -d +sudo docker compose exec gitea head -5 /data/gitea/conf/app.ini +``` + +That should show `APP_NAME = Viena Latina`. Hiding the version is the one with +a security argument as well as a cosmetic one: it tells a passer-by exactly +which advisories to try. + +**On the licence**, since this is rebranding somebody else's software: Gitea is +MIT, whose only obligation is that the copyright and permission notice travel +with copies of the software. We are not redistributing it — the official image +runs unmodified, with its own `LICENSE` file untouched, and we talk to it over +HTTP. MIT requires no attribution in a user interface, and Gitea itself ships +`SHOW_FOOTER_POWERED_BY` as a supported setting, which settles what the project +intends. The name is a trademark of Gitea Limited; that restricts using it to +brand something else, not declining to display it. Redistributing a modified +Gitea under its own name would be a different question — this is not that. + +### The theme + Unlike Decap, Gitea supports this properly: a theme is a CSS file in a directory it already reads. diff --git a/infra/gitea/docker-compose.yml b/infra/gitea/docker-compose.yml index 04f42e4..fc5d507 100644 --- a/infra/gitea/docker-compose.yml +++ b/infra/gitea/docker-compose.yml @@ -31,6 +31,24 @@ services: # Install the theme first: bash scripts/gitea-theme.sh - GITEA__ui__DEFAULT_THEME=vienalatina + # The name on those same screens. A member signing in should not be + # handed off to a product they have never heard of — as far as they are + # concerned this is still Viena Latina, and the page title, the tab and + # the heading should say so. APP_NAME lives in app.ini's unnamed root + # section, which is spelled DEFAULT in the environment mapping. + # + # VERIFY IT TOOK: `docker compose exec gitea cat /data/gitea/conf/app.ini + # | head -5` should show APP_NAME = Viena Latina. A key written to the + # wrong section is accepted in silence and changes nothing. + - GITEA__DEFAULT__APP_NAME=Viena Latina + + # The footer, which otherwise advertises the software and its version on + # every page. The version is the one with a security argument: it tells a + # passer-by exactly which advisories to try. + - GITEA__other__SHOW_FOOTER_POWERED_BY=false + - GITEA__other__SHOW_FOOTER_VERSION=false + - GITEA__other__SHOW_FOOTER_TEMPLATE_LOAD_TIME=false + # Two tabs on the sign-in page that should not be offered here. # OpenID is sign-in with an external identity URL, which nobody in # this association will ever use; the register button contradicts From 9355121b7ce6da43b598ece3ebc9b1e77eb64495 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:02:18 +0000 Subject: [PATCH 2/2] Let members post pictures, and back them up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B. Images on threads and comments, stored in /data/uploads — inside the volume that already holds board.db, so there is one directory to back up rather than two and no second mount to remember. Not in the site repository, which is the distinction that matters: the content editor uploads by committing, and a private photo committed there would go through the build pipeline and out onto vienalatina.com. The type is decided by the first bytes, not the filename. content.py trusts the extension, which is tolerable where Hugo serves the result; here we serve it back, so an HTML file called gato.png would be a script running on our own origin. Five magic-number checks, no new dependency. The uploaded name is kept only as text to show a person; the name on disk is generated. Staging is separate from saving so a refused picture cannot leave a half-made thread behind. Threads and comments are soft-deleted, so the serving route checks the parent is still live. Without it, taking a post down leaves its photo readable by anyone who noted the URL. And the hole this phase existed to close: backup-board.sh archived board.db and nothing else, so the first upload would have made the nightly backup silently incomplete while still reporting success. It now archives the uploads directory too, verifies the archive, and names the directory when it is empty rather than passing over it in silence. Tested by restoring: destroyed the directory, restored from the archive, compared bytes. IMAGE_EXTENSIONS moves to uploads.py and content.py imports it, so the editor and the board cannot drift apart about what counts as a picture. 46 new tests, 176 in total. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn --- apps/board/app.py | 7 +- apps/board/board.py | 23 ++- apps/board/content.py | 5 +- apps/board/schema.sql | 26 +++ apps/board/static/board.css | 18 +++ apps/board/templates/_attachments.html | 16 ++ apps/board/templates/thread.html | 10 +- apps/board/templates/thread_form.html | 10 +- apps/board/tests/conftest.py | 1 + apps/board/tests/test_uploads.py | 216 +++++++++++++++++++++++++ apps/board/uploads.py | 214 ++++++++++++++++++++++++ docs/server-setup.md | 39 +++++ scripts/backup-board.sh | 26 ++- 13 files changed, 603 insertions(+), 8 deletions(-) create mode 100644 apps/board/templates/_attachments.html create mode 100644 apps/board/tests/test_uploads.py create mode 100644 apps/board/uploads.py 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 %} +
    + {% for image in images %} +
  • + + {{ image.original_name }} + +
  • + {% endfor %} +
+{% endif %} +{% endmacro %} 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 @@

{{ thread.title }}

{{ body_html }}
+ {{ attachments(thread_images) }}
{% if may_edit(thread) %} @@ -49,6 +51,7 @@ {% if c.edited_at %}(editado){% endif %}
{{ to_html(c.body_md) }}
+ {{ attachments(comment_images.get(c.id, [])) }}
{% if may_edit(c) %} Editar @@ -68,11 +71,16 @@ {% if thread.locked and not is_admin %}

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

{{ 'Editar tema' if thread else 'Nuevo tema' }}

@@ -15,6 +15,14 @@ + {# Only when writing, not when editing: the edit route does not read files, + and an input that silently does nothing is worse than no input. Photos + already attached survive an edit — they are separate rows. #} + {% if not thread %} + + + {% endif %} +
Cancelar diff --git a/apps/board/tests/conftest.py b/apps/board/tests/conftest.py index 049f6eb..0d0a0b2 100644 --- a/apps/board/tests/conftest.py +++ b/apps/board/tests/conftest.py @@ -34,6 +34,7 @@ def app(tmp_path): "OAUTH_CLIENT_ID": "cid", "OAUTH_CLIENT_SECRET": "secret", "ADMIN_TOKEN": "admintoken", + "UPLOAD_DIR": str(tmp_path / "uploads"), "TESTING": True, }) yield application diff --git a/apps/board/tests/test_uploads.py b/apps/board/tests/test_uploads.py new file mode 100644 index 0000000..56e41b1 --- /dev/null +++ b/apps/board/tests/test_uploads.py @@ -0,0 +1,216 @@ +"""Pictures on the board. + +Two things are being defended here, and they are not the same thing. + +One is the member: a photo they attach must survive, be visible to other +members, and stop being visible when the post comes down. + +The other is the server: an upload is the one place where a member hands over +bytes that this app later serves back. Most of these tests are about the ways +that can be abused, and they are written against the bytes rather than the +filename, because the filename is the attacker's to choose. +""" + +from __future__ import annotations + +import io +from pathlib import Path + +import pytest + +from apps.board import uploads + +PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 +JPEG = b"\xff\xd8\xff\xe0" + b"\x00" * 64 +WEBP = b"RIFF\x00\x00\x00\x00WEBP" + b"\x00" * 64 +HTML = b"" + + +def picture(data=PNG, name="foto.png"): + return (io.BytesIO(data), name) + + +def post_thread(post, follow_redirects=False, **files): + """follow_redirects is named explicitly so it cannot fall into **files and + be posted as a form field — which it silently was, leaving two tests + asserting against a bare 302 body.""" + payload = {"title": "Con foto", "body": "Mirad esto"} + payload.update(files) + return post("/comunidad/nuevo", payload, content_type="multipart/form-data", + follow_redirects=follow_redirects) + + +# --- what counts as an image --------------------------------------------- + +@pytest.mark.parametrize("data,expected", [ + (PNG, "png"), (JPEG, "jpg"), (WEBP, "webp"), + (b"GIF89a" + b"\x00" * 32, "gif"), + (b"\x00\x00\x00\x20ftypavif" + b"\x00" * 32, "avif"), + (HTML, None), + (b"", None), + (b"\x89PNG", None), # truncated magic +]) +def test_the_bytes_decide(data, expected): + assert uploads._detect(data) is expected + + +def test_a_script_named_like_a_picture_is_refused(app, client, db, post, make_member, sign_in): + """The filename is the uploader's to choose, so it decides nothing. This is + the case that makes sniffing worth the code: stored and served back as + image/png, an HTML file is a script running on our own origin.""" + sign_in(make_member("maria")) + response = post_thread(post, pictures=picture(HTML, "gato.png"), follow_redirects=True) + + assert "no parece una imagen" in response.get_data(as_text=True) + assert db.execute("SELECT 1 FROM attachments").fetchone() is None + + +def test_a_misnamed_but_real_picture_is_kept(app, client, db, post, make_member, sign_in): + """The mirror of the test above, and the reason the extension is ignored + rather than compared: a JPEG called .png is somebody's phone being untidy, + not an attack, and it is stored as what it actually is.""" + sign_in(make_member("maria")) + post_thread(post, pictures=picture(JPEG, "foto.png")) + + row = db.execute("SELECT * FROM attachments").fetchone() + assert row["content_type"] == "image/jpeg" + assert row["stored_name"].endswith(".jpg") + + +def test_an_oversized_picture_is_refused(app, client, db, post, make_member, sign_in): + app.config["UPLOAD_MAX_BYTES"] = 100 + sign_in(make_member("maria")) + response = post_thread(post, pictures=picture(PNG + b"\x00" * 500), follow_redirects=True) + + assert "máximo" in response.get_data(as_text=True) + assert db.execute("SELECT 1 FROM attachments").fetchone() is None + + +def test_too_many_at_once_is_refused(app, client, db, post, make_member, sign_in): + sign_in(make_member("maria")) + response = post("/comunidad/nuevo", { + "title": "Muchas", "body": "Texto", + "pictures": [picture(PNG, f"f{n}.png") for n in range(uploads.MAX_FILES + 1)], + }, content_type="multipart/form-data", follow_redirects=True) + + assert "Como máximo" in response.get_data(as_text=True) + assert db.execute("SELECT 1 FROM attachments").fetchone() is None + + +# --- a refusal must not leave wreckage ----------------------------------- + +def test_a_refused_picture_leaves_no_thread_behind(app, client, db, post, make_member, sign_in): + """The reason staging is separate from saving. Insert the thread first and + a rejected photo leaves its author looking at a post they did not finish + writing, with no way to tell what happened.""" + sign_in(make_member("maria")) + post_thread(post, pictures=picture(HTML, "malo.png")) + + assert db.execute("SELECT 1 FROM threads").fetchone() is None + + +# --- the name on disk ----------------------------------------------------- + +def test_the_uploaded_name_is_never_used_as_a_path(app, client, db, post, make_member, sign_in): + sign_in(make_member("maria")) + post_thread(post, pictures=picture(PNG, "../../../etc/passwd.png")) + + stored = db.execute("SELECT * FROM attachments").fetchone() + assert "/" not in stored["stored_name"] + assert uploads.STORED_NAME.match(stored["stored_name"]) + # The original is kept, but only ever as text to show a person. + assert stored["original_name"] == "../../../etc/passwd.png" + + +def test_a_name_we_did_not_generate_is_not_served(app, client, make_member, sign_in): + sign_in(make_member("maria")) + for name in ("../board.db", "..%2Fboard.db", "board.db", "foto.png"): + assert client.get(f"/comunidad/media/{name}").status_code == 404 + + +# --- who can see them ----------------------------------------------------- + +def test_a_picture_is_served_to_a_member(app, client, db, post, make_member, sign_in): + sign_in(make_member("maria")) + post_thread(post, pictures=picture(PNG)) + name = db.execute("SELECT stored_name FROM attachments").fetchone()["stored_name"] + + response = client.get(f"/comunidad/media/{name}") + assert response.status_code == 200 + assert response.mimetype == "image/png" + assert response.data == PNG + + +def test_a_picture_is_not_served_to_a_stranger(app, client, db, post, make_member, sign_in): + sign_in(make_member("maria")) + post_thread(post, pictures=picture(PNG)) + name = db.execute("SELECT stored_name FROM attachments").fetchone()["stored_name"] + + with client.session_transaction() as session: + session.clear() + response = client.get(f"/comunidad/media/{name}") + assert response.status_code == 302 + assert "/comunidad/login" in response.headers["Location"] + + +def test_deleting_the_thread_takes_its_pictures_out_of_reach( + app, client, db, post, make_member, sign_in): + """Threads are soft-deleted, so without this check the row stays, the file + stays, and the photo of a post somebody asked to have removed is still + readable by anyone who noted the URL.""" + sign_in(make_member("maria")) + post_thread(post, pictures=picture(PNG)) + name = db.execute("SELECT stored_name FROM attachments").fetchone()["stored_name"] + thread_id = db.execute("SELECT id FROM threads").fetchone()["id"] + assert client.get(f"/comunidad/media/{name}").status_code == 200 + + post(f"/comunidad/tema/{thread_id}/eliminar") + assert client.get(f"/comunidad/media/{name}").status_code == 404 + + +def test_the_same_applies_to_a_deleted_comment(app, client, db, post, make_member, sign_in): + member = make_member("maria") + sign_in(member) + post_thread(post, pictures=picture(PNG)) + thread_id = db.execute("SELECT id FROM threads").fetchone()["id"] + post(f"/comunidad/tema/{thread_id}/comentar", + {"body": "Yo también", "pictures": picture(JPEG, "mia.jpg")}, + content_type="multipart/form-data") + + name = db.execute( + "SELECT stored_name FROM attachments WHERE comment_id IS NOT NULL").fetchone()["stored_name"] + comment_id = db.execute("SELECT id FROM comments").fetchone()["id"] + assert client.get(f"/comunidad/media/{name}").status_code == 200 + + post(f"/comunidad/comentario/{comment_id}/eliminar") + assert client.get(f"/comunidad/media/{name}").status_code == 404 + + +# --- and they show up ----------------------------------------------------- + +def test_the_picture_appears_on_the_thread(app, client, db, post, make_member, sign_in): + sign_in(make_member("maria")) + post_thread(post, pictures=picture(PNG)) + thread_id = db.execute("SELECT id FROM threads").fetchone()["id"] + name = db.execute("SELECT stored_name FROM attachments").fetchone()["stored_name"] + + body = client.get(f"/comunidad/tema/{thread_id}").get_data(as_text=True) + assert f"/comunidad/media/{name}" in body + + +def test_the_file_reaches_the_disk(app, client, db, post, make_member, sign_in): + sign_in(make_member("maria")) + post_thread(post, pictures=picture(PNG)) + name = db.execute("SELECT stored_name FROM attachments").fetchone()["stored_name"] + + assert (Path(app.config["UPLOAD_DIR"]) / name).read_bytes() == PNG + + +def test_nothing_is_written_when_no_picture_is_chosen(app, client, db, post, make_member, sign_in): + """An empty file input still arrives in the request; it must not become a + zero-byte attachment.""" + sign_in(make_member("maria")) + post_thread(post, pictures=(io.BytesIO(b""), "")) + + assert db.execute("SELECT 1 FROM attachments").fetchone() is None + assert db.execute("SELECT 1 FROM threads").fetchone() is not None diff --git a/apps/board/uploads.py b/apps/board/uploads.py new file mode 100644 index 0000000..10a1a31 --- /dev/null +++ b/apps/board/uploads.py @@ -0,0 +1,214 @@ +"""Pictures on the board. + +Three decisions worth stating, because each one is a place this could have +gone wrong quietly. + +**Not in the site repository.** `content.py` uploads pictures by committing +them, which is right for a post that is about to be published. A photo attached +to a thread in here is the opposite: it is private, and committing it would put +it through the build pipeline and out onto vienalatina.com. These go to a +directory on disk that only this app serves, behind the same login as the +thread they belong to. + +**The file says what it is; the filename is an opinion.** `content.py` trusts +the extension, which is tolerable there because Hugo serves the result as a +static file. Here *we* serve it back, so the type is read from the first bytes +and the name the browser sent is never used for anything but display. A file +called `gato.png` that is really HTML is refused, rather than stored and later +handed back with a content type that invites the browser to run it. + +**The directory lives inside the volume that gets backed up.** `/data/uploads` +sits next to `board.db` in the same mount, so there is one thing to back up, +not two — and `scripts/backup-board.sh` covers both. +""" + +from __future__ import annotations + +import re +import secrets +from pathlib import Path + +from flask import Blueprint, abort, current_app, send_from_directory + +from .db import get_db +from .security import login_required + +bp = Blueprint("uploads", __name__) + +# How many pictures one post or comment may carry. Not a security limit — +# MAX_CONTENT_LENGTH is — just a bound on what one form submission can become. +MAX_FILES = 4 + +# extension -> content type. The extension is ours, derived from the bytes, +# never taken from the upload. +CONTENT_TYPES = { + "jpg": "image/jpeg", + "png": "image/png", + "gif": "image/gif", + "webp": "image/webp", + "avif": "image/avif", +} + +# What content.py offers writers, kept here so there is one list rather than +# two that drift apart. `jpeg` appears only as an accepted spelling; anything +# stored is named `.jpg`. +IMAGE_EXTENSIONS = set(CONTENT_TYPES) | {"jpeg"} + +# Stored names are generated by this module, so the pattern is a check on our +# own output — which is exactly why it is worth having. It is the last thing +# between a crafted request and send_from_directory. +STORED_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{12}\.[a-z]{3,4}$") + + +class RejectedUpload(ValueError): + """The message is written for the member who chose the file.""" + + +def _detect(data: bytes) -> str | None: + """The extension these bytes actually deserve, or None. + + Magic numbers rather than a library: five formats, each identified by a + fixed prefix, is less code than a dependency and has no version to track. + """ + if data.startswith(b"\xff\xd8\xff"): + return "jpg" + if data.startswith(b"\x89PNG\r\n\x1a\n"): + return "png" + if data.startswith((b"GIF87a", b"GIF89a")): + return "gif" + # Both of these are container formats: the marker sits at a fixed offset + # rather than at the very start, so a prefix check would miss them. + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "webp" + if data[4:8] == b"ftyp" and data[8:12] in (b"avif", b"avis"): + return "avif" + return None + + +def slugify(text: str) -> str: + """Only ever applied to a display name to build a readable stored name. + + Falls back to "imagen" rather than the empty string: a name that is all + punctuation, or written in a script this strips entirely, must still + produce something that matches STORED_NAME. + """ + slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") + return slug[:48] or "imagen" + + +def directory() -> Path: + path = Path(current_app.config["UPLOAD_DIR"]) + path.mkdir(parents=True, exist_ok=True) + return path + + +def stage(files) -> list[dict]: + """Read and check everything before anything is written or inserted. + + Separate from `save` on purpose. A picture that is refused must not leave a + half-made thread behind, so nothing touches the database until every file + in the submission has passed. + """ + staged = [] + real = [f for f in files if f and f.filename] + if len(real) > MAX_FILES: + raise RejectedUpload(f"Como máximo {MAX_FILES} imágenes por mensaje.") + + for upload in real: + data = upload.read() + if not data: + continue + maximum = current_app.config["UPLOAD_MAX_BYTES"] + if len(data) > maximum: + raise RejectedUpload( + f"«{upload.filename}» pesa {len(data) // 1024}KB y el máximo " + f"es {maximum // 1024}KB." + ) + extension = _detect(data) + if extension is None: + raise RejectedUpload( + f"«{upload.filename}» no parece una imagen. Se aceptan " + f"{', '.join(sorted(CONTENT_TYPES))}." + ) + stem = slugify(upload.filename.rsplit(".", 1)[0]) + staged.append({ + "data": data, + "original_name": upload.filename[:200], + "content_type": CONTENT_TYPES[extension], + # A random suffix rather than a counter: two people uploading + # "foto.jpg" in the same second must not race for one path. + "stored_name": f"{stem}-{secrets.token_hex(6)}.{extension}", + }) + return staged + + +def save(staged: list[dict], member_id: int, + thread_id: int | None = None, comment_id: int | None = None) -> None: + """Write the files, then record them. In that order. + + A row pointing at a file that does not exist renders as a broken image on + every future visit. A file with no row is invisible and gets swept up by + the next audit — so if one of the two has to happen first, it is the file. + """ + folder = directory() + for item in staged: + (folder / item["stored_name"]).write_bytes(item["data"]) + get_db().execute( + """INSERT INTO attachments + (thread_id, comment_id, stored_name, original_name, + content_type, bytes, uploaded_by) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (thread_id, comment_id, item["stored_name"], item["original_name"], + item["content_type"], len(item["data"]), member_id), + ) + + +def for_threads(thread_ids: list[int]) -> dict[int, list]: + return _grouped("thread_id", thread_ids) + + +def for_comments(comment_ids: list[int]) -> dict[int, list]: + return _grouped("comment_id", comment_ids) + + +def _grouped(column: str, ids: list[int]) -> dict[int, list]: + """One query for a whole page rather than one per comment.""" + if not ids: + return {} + marks = ",".join("?" * len(ids)) + rows = get_db().execute( + f"""SELECT * FROM attachments WHERE {column} IN ({marks}) + ORDER BY id""", + ids, + ).fetchall() + grouped: dict[int, list] = {} + for row in rows: + grouped.setdefault(row[column], []).append(row) + return grouped + + +@bp.route("/media/") +@login_required +def serve(name: str): + """Behind the login, like the thread the picture belongs to. + + The deleted check is the part that is easy to leave out: threads and + comments are *soft*-deleted, so without it, taking a post down would leave + its photo readable forever by anyone who noted the URL. + """ + if not STORED_NAME.match(name): + abort(404) + row = get_db().execute( + """SELECT a.content_type + FROM attachments a + LEFT JOIN threads t ON t.id = a.thread_id + LEFT JOIN comments c ON c.id = a.comment_id + WHERE a.stored_name = ? + AND COALESCE(t.deleted_at, c.deleted_at) IS NULL""", + (name,), + ).fetchone() + if row is None: + abort(404) + # mimetype from our own column, never guessed from the name on disk, and + # paired with the X-Content-Type-Options: nosniff set in app.py. + return send_from_directory(directory(), name, mimetype=row["content_type"]) diff --git a/docs/server-setup.md b/docs/server-setup.md index 8bb0f01..bc3da10 100644 --- a/docs/server-setup.md +++ b/docs/server-setup.md @@ -471,6 +471,17 @@ database is live and in WAL mode — a plain `cp` can capture it missing its mos 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. +**It also archives `/srv/board/data/uploads`**, the pictures members attach to +threads, as a second file `uploads-.tar.gz`. This is not an extra: a +database backup that completes looks exactly like a backup that worked, so +before uploads were covered the nightly job would have gone on reporting +success while silently leaving every photograph out. Restoring them is a plain +`tar -xzf`, into `/srv/board/data/`. + +If there are no uploads yet the log says so by name, rather than saying +nothing — "nobody has posted a photo" and "the path moved a month ago and this +has been archiving air" are otherwise the same empty line. + ### 11.6 Who can do what | | Owner | Admin | User | @@ -574,6 +585,34 @@ Not urgent — leaving it costs a folder and one `wget` in the pipeline: 2. Delete the `wget … decap-cms.js` line from `.woodpecker.yml` 3. Delete the `decap-cms` OAuth application in Gitea +### 11.9 Pictures on the board + +Members can attach images when they start a thread or reply. Nothing to +install: the files go to `/data/uploads` inside the container, which is +`/srv/board/data/uploads` on the host — the same volume that already holds +`board.db`, so there is one directory to back up rather than two. + +**They are deliberately not in the site repository.** `/comunidad/contenido/` +uploads pictures by committing them, which is right for a post about to be +published. A photo in a private thread is the opposite: committing it would +send it through the build pipeline and out onto vienalatina.com. These are +served by the app, behind the same login as the thread. + +Three things the code does that are worth knowing if you ever change it: + +- **The type is read from the first bytes, not the filename.** A file called + `gato.png` containing HTML is refused. Served back as `image/png` from our + own domain, it would otherwise be a script running on vienalatina.com. +- **The stored name is generated.** The name the browser sent is kept only as + text to show a person, never as a path. +- **Deleting a post hides its pictures.** Threads and comments are soft-deleted, + so the serving route checks the parent is still live. Without that, taking a + post down would leave its photo readable by anyone who noted the URL. + +Limits: 4 images per message, and `BOARD_UPLOAD_MAX_BYTES` (8MB by default) +each. Adding a picture to a post *after* publishing it means posting a reply — +editing changes the words, and leaves the pictures alone. + ## 12. Make Gitea look like the site Members sign in to `/comunidad/` through Gitea, so Gitea's sign-in form and its diff --git a/scripts/backup-board.sh b/scripts/backup-board.sh index cc50b3d..ea742fe 100755 --- a/scripts/backup-board.sh +++ b/scripts/backup-board.sh @@ -15,6 +15,7 @@ set -euo pipefail DB="${BOARD_DB:-/srv/board/data/board.db}" +UPLOADS="${BOARD_UPLOADS:-/srv/board/data/uploads}" DEST="${1:-/srv/board/backups}" KEEP_DAYS="${KEEP_DAYS:-30}" STAMP="$(date -u +%Y%m%dT%H%M%SZ)" @@ -39,6 +40,27 @@ if ! gzip -t "$DEST/board-$STAMP.db.gz"; then exit 1 fi -find "$DEST" -name 'board-*.db.gz' -mtime "+$KEEP_DAYS" -delete +# The pictures members attach to threads. Without this the backup covers the +# text of the members area and none of its photographs — and it would do that +# silently, since a database backup that completes looks like a backup that +# worked. The files are immutable once written (a new upload never overwrites +# an old name), so a plain tar of the directory is a consistent snapshot; no +# equivalent of SQLite's .backup is needed. +if [ -d "$UPLOADS" ] && [ -n "$(ls -A "$UPLOADS" 2>/dev/null)" ]; then + tar -czf "$DEST/uploads-$STAMP.tar.gz" -C "$(dirname "$UPLOADS")" "$(basename "$UPLOADS")" + if ! tar -tzf "$DEST/uploads-$STAMP.tar.gz" >/dev/null; then + echo "Uploads archive failed its own integrity check." >&2 + exit 1 + fi + UPLOADS_NOTE="+ $(du -h "$DEST/uploads-$STAMP.tar.gz" | cut -f1) de imágenes" +else + # Said out loud rather than skipped in silence: "no uploads yet" and "the + # path moved and this has been backing up nothing for a month" look + # identical in a log that says nothing. + UPLOADS_NOTE="(sin imágenes en $UPLOADS)" +fi -echo "$(date -u +%FT%TZ) wrote $DEST/board-$STAMP.db.gz ($(du -h "$DEST/board-$STAMP.db.gz" | cut -f1))" +find "$DEST" -name 'board-*.db.gz' -mtime "+$KEEP_DAYS" -delete +find "$DEST" -name 'uploads-*.tar.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)) $UPLOADS_NOTE"