Merge branch 'claude/relaxed-faraday-h4zd09' of https://github.com/pablovolenski/vienalatina
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
commit
3d12fc499c
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}).")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
16
apps/board/templates/_attachments.html
Normal file
16
apps/board/templates/_attachments.html
Normal file
@ -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 %}
|
||||
<ul class="shots">
|
||||
{% for image in images %}
|
||||
<li>
|
||||
<a href="{{ url_for('uploads.serve', name=image.stored_name) }}">
|
||||
<img src="{{ url_for('uploads.serve', name=image.stored_name) }}"
|
||||
alt="{{ image.original_name }}" loading="lazy">
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
@ -8,18 +8,18 @@
|
||||
<h1>Saliste del área de la comunidad</h1>
|
||||
|
||||
<p class="muted">
|
||||
Tu sesión aquí está cerrada. Pero este navegador <strong>sigue conectado a
|
||||
Gitea</strong>, 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 <strong>sigue conectado al
|
||||
servidor de cuentas</strong>, que es donde se guardan las contraseñas — así
|
||||
que quien use este ordenador después podría volver a entrar sin escribirla.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a class="btn" href="{{ gitea_url }}/user/logout">Cerrar sesión también en Gitea</a>
|
||||
<a class="btn" href="{{ gitea_url }}/user/logout">Cerrar sesión del todo</a>
|
||||
</p>
|
||||
|
||||
<p class="muted small">
|
||||
Si esa página no te desconecta, abre el menú de tu perfil en
|
||||
<a href="{{ gitea_url }}">Gitea</a> y elige <em>Sign Out</em>.
|
||||
Si esa página no te desconecta, abre el menú de tu perfil
|
||||
<a href="{{ gitea_url }}">ahí</a> y elige <em>Sign Out</em>.
|
||||
Cerrar el navegador también sirve.
|
||||
</p>
|
||||
|
||||
|
||||
@ -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.
|
||||
</p>
|
||||
<a class="btn" href="{{ url_for('auth.start', next=next) }}">Entrar con Gitea</a>
|
||||
<a class="btn" href="{{ url_for('auth.start', next=next) }}">Entrar</a>
|
||||
{# 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 %}
|
||||
|
||||
@ -30,10 +30,10 @@
|
||||
Crear también su cuenta
|
||||
</label>
|
||||
<p class="muted small">
|
||||
Este servidor no puede crear cuentas (no tiene un token de administración
|
||||
de Gitea). Crea la cuenta primero en
|
||||
<a href="{{ gitea_url }}/admin/users/new">Gitea</a> 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
|
||||
<a href="{{ gitea_url }}/admin/users/new">servidor de cuentas</a> y luego
|
||||
da de alta aquí ese mismo usuario.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_attachments.html" import attachments %}
|
||||
{% block title %}{{ thread.title }}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
@ -11,6 +12,7 @@
|
||||
</div>
|
||||
<h1 class="card__title">{{ thread.title }}</h1>
|
||||
<div class="prose">{{ body_html }}</div>
|
||||
{{ attachments(thread_images) }}
|
||||
|
||||
<div class="actions">
|
||||
{% if may_edit(thread) %}
|
||||
@ -49,6 +51,7 @@
|
||||
{% if c.edited_at %}<span class="muted small">(editado)</span>{% endif %}
|
||||
</div>
|
||||
<div class="prose">{{ to_html(c.body_md) }}</div>
|
||||
{{ attachments(comment_images.get(c.id, [])) }}
|
||||
<div class="actions">
|
||||
{% if may_edit(c) %}
|
||||
<a class="linkish" href="{{ url_for('board.edit_comment', comment_id=c.id) }}">Editar</a>
|
||||
@ -68,11 +71,16 @@
|
||||
{% 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) }}">
|
||||
<form class="card" method="post" enctype="multipart/form-data"
|
||||
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>
|
||||
|
||||
<label for="pictures">Imágenes <span class="muted small">(opcional)</span></label>
|
||||
<input id="pictures" name="pictures" type="file" multiple accept="image/*">
|
||||
|
||||
<button class="btn" type="submit">Publicar respuesta</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
{% block title %}{{ 'Editar tema' if thread else 'Escribir' }}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<form class="card" method="post"
|
||||
<form class="card" method="post" enctype="multipart/form-data"
|
||||
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>
|
||||
@ -15,6 +15,14 @@
|
||||
<textarea id="body" name="body" rows="14" required
|
||||
placeholder="Se puede usar Markdown: **negrita**, listas, enlaces.">{{ thread.body_md if thread else '' }}</textarea>
|
||||
|
||||
{# 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 %}
|
||||
<label for="pictures">Imágenes <span class="muted small">(opcional)</span></label>
|
||||
<input id="pictures" name="pictures" type="file" multiple accept="image/*">
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
216
apps/board/tests/test_uploads.py
Normal file
216
apps/board/tests/test_uploads.py
Normal file
@ -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"<!DOCTYPE html><script>alert(1)</script>"
|
||||
|
||||
|
||||
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
|
||||
214
apps/board/uploads.py
Normal file
214
apps/board/uploads.py
Normal file
@ -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/<name>")
|
||||
@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"])
|
||||
@ -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-<stamp>.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
|
||||
@ -586,6 +625,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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user