feat(billets): image upload with zoomable vignette gallery + portable export

- Upload images (png/jpeg/webp/gif ≤5 MiB) alongside the text of a billet:
  multi-image mini-gallery. New api/services/media.py validates + FULLY
  re-encodes every upload via Pillow (drops EXIF/GPS, neutralises polyglots;
  SVG refused), producing a cleaned original + a ≤480px thumbnail.
- Storage under BILLETS_MEDIA_DIR (/var/lib/secubox/billets/media), served by
  nginx /media/ alias (in-process StaticFiles fallback). img-src 'self' already
  covers it — no third party, no CSP relaxation.
- Public: vignette grid in feed + permalink, pure-JS zoomable lightbox with
  ◀▶ keyboard/arrow navigation (graceful degradation: links open the full
  image with JS off). First image drives og:image / twitter:image.
- Admin editor: multipart file input + existing-media thumbnails with delete.
  New media table (migration 0002, ON DELETE CASCADE), repo CRUD, media delete
  route; billet delete removes files. All Pillow work off the event loop
  (asyncio.to_thread).
- Portable backup: GET /admin/export.sbxsite emits every billet + media inlined
  as base64 (single re-importable file).
- nginx client_max_body_size 6m; requirements pillow==11.1.0. 104 tests (11 new).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-12 08:11:43 +02:00
parent 0d50cf6a51
commit db74e02ffc
16 changed files with 668 additions and 17 deletions

View File

@ -21,7 +21,7 @@ from . import repo
from .routes.admin import register_admin
from .routes.public import (PCSRF_COOKIE, VISITOR_COOKIE, reactions_context,
register_public, _visitor)
from .services import antispam, feeds
from .services import antispam, feeds, media
from .services import security as sec
from .services.render import linkify_plain, render_markdown
@ -106,7 +106,7 @@ _SECURITY_HEADERS = {
}
def _billet_view(row: aiosqlite.Row, base: str = "") -> dict:
def _billet_view(row: aiosqlite.Row, base: str = "", media_rows=None) -> dict:
from urllib.parse import urlparse
d = dict(row)
d["body_html"] = render_markdown(d["body"])
@ -115,6 +115,7 @@ def _billet_view(row: aiosqlite.Row, base: str = "") -> dict:
d["title"] = title
d["permalink"] = f"{base}/b/{d['slug']}" if base else f"/b/{d['slug']}"
d["share"] = _share_intents(d["permalink"], title) if base else {}
d["media"] = [dict(m) for m in (media_rows or [])]
return d
@ -137,6 +138,14 @@ def create_app(conn: aiosqlite.Connection | None = None, *, secret: str | None =
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
if STATIC_DIR.is_dir():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# User-uploaded media (re-encoded, EXIF-stripped in services.media). In prod
# nginx serves /media/ directly; this mount is the in-process fallback.
try:
media_root = media.media_dir()
media_root.mkdir(parents=True, exist_ok=True)
app.mount("/media", StaticFiles(directory=str(media_root)), name="media")
except OSError:
pass
register_admin(app, templates)
register_public(app, templates)
@ -155,9 +164,11 @@ def create_app(conn: aiosqlite.Connection | None = None, *, secret: str | None =
async def feed(request: Request, cursor: str | None = None):
rows, next_cursor = await repo.list_published(app.state.conn, limit=PAGE_SIZE, cursor=cursor)
base = _base(request)
media_map = await repo.list_media_for(app.state.conn, [r["id"] for r in rows])
resp = templates.TemplateResponse(request, "feed.html", {
"site_title": SITE_TITLE, "tagline": SITE_TAGLINE,
"billets": [_billet_view(r, base) for r in rows], "next_cursor": next_cursor,
"billets": [_billet_view(r, base, media_map.get(r["id"])) for r in rows],
"next_cursor": next_cursor,
})
# Embeds render inline in the feed too; allow any self-hosted embed hosts
# of the shown billets in frame-src (static providers already covered).
@ -179,13 +190,15 @@ def create_app(conn: aiosqlite.Connection | None = None, *, secret: str | None =
comment_views = [{**dict(c), "body_html": linkify_plain(c["body"])} for c in comments]
base = _base(request)
permalink_url = f"{base}/b/{row['slug']}"
media_rows = await repo.list_media(app.state.conn, row["id"])
resp = templates.TemplateResponse(request, "billet.html", {
"site_title": SITE_TITLE, "tagline": SITE_TAGLINE,
"billet": _billet_view(row, base), "reactions": rctx,
"billet": _billet_view(row, base, media_rows), "reactions": rctx,
"comments": comment_views, "pcsrf": pcsrf,
"ts_token": ts_token, "flash": request.query_params.get("c"),
"og": {"title": feeds.billet_title(row["body"]),
"desc": feeds.excerpt(row["body"]), "url": permalink_url},
"desc": feeds.excerpt(row["body"]), "url": permalink_url,
"image": (f"{base}/media/{media_rows[0]['filename']}" if media_rows else None)},
"oembed_url": f"{base}/oembed?url={permalink_url}&format=json",
"share": _share_intents(permalink_url, feeds.billet_title(row["body"])),
})

View File

@ -0,0 +1,19 @@
-- SPDX-License-Identifier: LicenseRef-CMSD-1.0
-- Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
-- billets :: media attachments. Images are re-encoded (EXIF/polyglot stripped)
-- and stored on disk under BILLETS_MEDIA_DIR as <id>.<ext> (+ <id>_t.<ext>
-- thumbnail); this table holds the metadata. Ordering is (position, created_at).
CREATE TABLE IF NOT EXISTS media (
id TEXT PRIMARY KEY, -- ULID
billet_id TEXT NOT NULL REFERENCES billet(id) ON DELETE CASCADE,
filename TEXT NOT NULL, -- <id>.<ext> under BILLETS_MEDIA_DIR
thumb TEXT NOT NULL, -- <id>_t.<ext> (vignette)
mime TEXT NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
alt TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_media_billet ON media(billet_id, position, created_at);

View File

@ -181,6 +181,66 @@ async def set_embed(conn: aiosqlite.Connection, billet_id: str, *, html: str,
await conn.commit()
# ── media ───────────────────────────────────────────────────────────────────
async def add_media(conn: aiosqlite.Connection, billet_id: str, *, filename: str,
thumb: str, mime: str, width: int, height: int, alt: str,
now: str, position: Optional[int] = None,
ulid: Optional[str] = None) -> str:
"""Attach a processed image to a billet. `position` defaults to append."""
mid = ulid or new_ulid()
if position is None:
async with conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM media WHERE billet_id=?",
(billet_id,)) as cur:
position = (await cur.fetchone())[0]
await conn.execute(
"INSERT INTO media(id,billet_id,filename,thumb,mime,width,height,alt,"
"position,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
(mid, billet_id, filename, thumb, mime, width, height, alt or "", position, now),
)
await conn.commit()
return mid
async def list_media(conn: aiosqlite.Connection, billet_id: str) -> list[aiosqlite.Row]:
async with conn.execute(
"SELECT * FROM media WHERE billet_id=? ORDER BY position, created_at, id",
(billet_id,)) as cur:
return list(await cur.fetchall())
async def list_media_for(conn: aiosqlite.Connection,
billet_ids: list[str]) -> dict[str, list[aiosqlite.Row]]:
"""Batch-fetch media for many billets (avoids N+1 on the feed)."""
out: dict[str, list[aiosqlite.Row]] = {bid: [] for bid in billet_ids}
if not billet_ids:
return out
placeholders = ",".join("?" for _ in billet_ids)
async with conn.execute(
f"SELECT * FROM media WHERE billet_id IN ({placeholders}) "
"ORDER BY position, created_at, id", billet_ids) as cur:
for row in await cur.fetchall():
out[row["billet_id"]].append(row)
return out
async def get_media(conn: aiosqlite.Connection, media_id: str) -> Optional[aiosqlite.Row]:
async with conn.execute("SELECT * FROM media WHERE id=?", (media_id,)) as cur:
return await cur.fetchone()
async def delete_media(conn: aiosqlite.Connection, media_id: str) -> None:
await conn.execute("DELETE FROM media WHERE id=?", (media_id,))
await conn.commit()
async def all_media(conn: aiosqlite.Connection) -> list[aiosqlite.Row]:
"""Every attachment (portable export)."""
async with conn.execute(
"SELECT * FROM media ORDER BY billet_id, position, created_at, id") as cur:
return list(await cur.fetchall())
# ── comments ────────────────────────────────────────────────────────────────
async def add_comment(conn: aiosqlite.Connection, billet_id: str, *, author_name: str,
email_hash: Optional[str], body: str, ip_hash: str, honeypot: bool,

View File

@ -6,16 +6,18 @@ commits a Gitea revision (content + style) off the event loop."""
from __future__ import annotations
import asyncio
import base64
from pathlib import Path
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pydantic import ValidationError
from .. import repo
from ..ids import new_ulid
from ..models import BilletIn
from ..services import eventlog, linkcard, revisions
from ..services import eventlog, linkcard, media, revisions
from ..services import security as sec
from ..services import ssrf as ssrf_mod
@ -171,10 +173,36 @@ def register_admin(app: FastAPI, templates: Jinja2Templates) -> None:
return None, None
return (None, url) if url_kind == "embed" else (url, None)
async def _save_uploads(request: Request, billet_id: str,
files: list[UploadFile]) -> int:
"""Process + store each valid uploaded image as a media attachment.
Invalid/oversized files are skipped (non-fatal); returns the count of
files that were rejected so the caller can flash a warning."""
conn = request.app.state.conn
skipped = 0
for up in files or []:
if up is None or not (up.filename or "").strip():
continue
raw = await up.read()
if not raw:
continue
try:
processed = await asyncio.to_thread(media.process, raw)
except media.MediaError:
skipped += 1
continue
mid = new_ulid()
fn, thumb = await asyncio.to_thread(media.store, mid, processed)
await repo.add_media(conn, billet_id, filename=fn, thumb=thumb,
mime=processed["mime"], width=processed["width"],
height=processed["height"], alt="",
now=_now(request), ulid=mid)
return skipped
@app.post("/admin/billets")
async def create(request: Request, body: str = Form(...), url: str = Form(""),
url_kind: str = Form("ref"), action: str = Form("draft"),
csrf: str = Form("")):
csrf: str = Form(""), media_files: list[UploadFile] = File(default=[])):
author = await _current_author(request)
if author is None:
return _redirect("/admin/login")
@ -190,6 +218,7 @@ def register_admin(app: FastAPI, templates: Jinja2Templates) -> None:
"csrf": request.cookies.get(CSRF_COOKIE) or ""})
return resp
billet_id = await repo.create_billet(request.app.state.conn, data, now=_now(request))
await _save_uploads(request, billet_id, media_files)
await _resolve_and_store_embed(request, billet_id, data.embed_url)
row = await repo.get_by_id(request.app.state.conn, billet_id)
event = "billet.published" if data.publish else "billet.edited"
@ -204,16 +233,19 @@ def register_admin(app: FastAPI, templates: Jinja2Templates) -> None:
row = await repo.get_by_id(request.app.state.conn, billet_id)
if row is None:
return _redirect("/admin")
media_rows = await repo.list_media(request.app.state.conn, billet_id)
token = _csrf_token(request)
resp = templates.TemplateResponse(request, "admin_edit.html",
{"billet": dict(row), "error": None, "csrf": token})
{"billet": dict(row), "error": None, "csrf": token,
"media": [dict(m) for m in media_rows]})
_set_csrf(resp, request, token)
return resp
@app.post("/admin/billets/{billet_id}")
async def update(request: Request, billet_id: str, body: str = Form(...),
url: str = Form(""), url_kind: str = Form("ref"),
action: str = Form("save"), csrf: str = Form("")):
action: str = Form("save"), csrf: str = Form(""),
media_files: list[UploadFile] = File(default=[])):
author = await _current_author(request)
if author is None:
return _redirect("/admin/login")
@ -233,6 +265,7 @@ def register_admin(app: FastAPI, templates: Jinja2Templates) -> None:
return resp
await repo.update_billet(conn, billet_id, body=body, ref_url=ref_url,
embed_url=embed_url, now=_now(request))
await _save_uploads(request, billet_id, media_files)
if action in ("publish", "archive"):
await repo.set_status(conn, billet_id,
"published" if action == "publish" else "archived",
@ -265,12 +298,54 @@ def register_admin(app: FastAPI, templates: Jinja2Templates) -> None:
conn = request.app.state.conn
row = await repo.get_by_id(conn, billet_id)
if row is not None:
for m in await repo.list_media(conn, billet_id):
await asyncio.to_thread(media.delete_files, m["filename"], m["thumb"])
await eventlog.append_event(conn, "billet.deleted",
{"id": billet_id, "slug": row["slug"],
"by": author["username"]}, ts=_now(request))
await repo.delete_billet(conn, billet_id)
await repo.delete_billet(conn, billet_id) # media rows cascade
return _redirect("/admin")
@app.post("/admin/media/{media_id}/delete")
async def delete_media_route(request: Request, media_id: str, csrf: str = Form("")):
author = await _current_author(request)
if author is None:
return _redirect("/admin/login")
if not sec.csrf_ok(request.cookies.get(CSRF_COOKIE), csrf):
return _redirect("/admin/login?error=csrf")
conn = request.app.state.conn
row = await repo.get_media(conn, media_id)
if row is None:
return _redirect("/admin")
billet_id = row["billet_id"]
await asyncio.to_thread(media.delete_files, row["filename"], row["thumb"])
await repo.delete_media(conn, media_id)
return _redirect(f"/admin/billets/{billet_id}/edit")
@app.get("/admin/export.sbxsite")
async def export_site(request: Request):
"""Portable single-file backup: every billet + its media inlined as
base64. Re-importable elsewhere; the media travels with the file."""
author = await _current_author(request)
if author is None:
return _redirect("/admin/login")
conn = request.app.state.conn
billets = [dict(r) for r in await repo.list_all(conn)]
media_out = []
for m in await repo.all_media(conn):
md = dict(m)
try:
raw = await asyncio.to_thread(media.read_bytes, m["filename"])
md["b64"] = base64.b64encode(raw).decode("ascii")
except OSError:
md["b64"] = None
media_out.append(md)
payload = {"format": "sbxsite/billets", "version": 1,
"exported_at": _now(request), "billets": billets, "media": media_out}
resp = JSONResponse(payload)
resp.headers["Content-Disposition"] = 'attachment; filename="billets.sbxsite"'
return resp
@app.get("/admin/comments", response_class=HTMLResponse)
async def comments_queue(request: Request):
author = await _current_author(request)

View File

@ -0,0 +1,142 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""
SecuBox-Deb :: billets media
CyberMind https://cybermind.fr
Image ingestion for billet attachments. Uploads are NEVER trusted or stored
as-received: every image is decoded with Pillow, verified, then FULLY
re-encoded from pixels. Re-encoding is the security property it drops EXIF
(incl. GPS, a CSPN privacy concern) and neutralises polyglot/appended-payload
files, because only the decoded raster survives. SVG is refused outright (an
XSS vector). A downscaled thumbnail (the vignette) is produced alongside.
All calls are synchronous CPU work (Pillow); callers on the async event loop
MUST run them via `asyncio.to_thread` so they never block the shared loop.
"""
from __future__ import annotations
import io
import os
from pathlib import Path
from PIL import Image, ImageOps
MAX_BYTES = 5 * 1024 * 1024 # 5 MiB hard cap per file
MAX_DIM = 4096 # refuse absurd dimensions (decompression bombs)
THUMB_MAX = (480, 480) # vignette bounding box
# Pillow format name -> (extension, mime). This is the allow-list; anything
# else (SVG, BMP, TIFF, ICO, …) is refused.
_ALLOWED: dict[str, tuple[str, str]] = {
"PNG": ("png", "image/png"),
"JPEG": ("jpg", "image/jpeg"),
"WEBP": ("webp", "image/webp"),
"GIF": ("gif", "image/gif"),
}
# Guard against decompression bombs before we allocate the full raster.
Image.MAX_IMAGE_PIXELS = MAX_DIM * MAX_DIM
class MediaError(ValueError):
"""Raised for any invalid / unsupported / oversized upload."""
def media_dir() -> Path:
# Resolved at call time so BILLETS_MEDIA_DIR is honoured after import (tests).
return Path(os.environ.get("BILLETS_MEDIA_DIR", "/var/lib/secubox/billets/media"))
def _encode(img: Image.Image, fmt: str) -> bytes:
"""Re-encode a PIL image to `fmt`, dropping all source metadata."""
out = img
if fmt == "JPEG" and img.mode not in ("RGB", "L"):
out = img.convert("RGB")
buf = io.BytesIO()
kwargs: dict = {}
if fmt == "GIF" and getattr(img, "is_animated", False):
kwargs["save_all"] = True
if fmt in ("JPEG", "WEBP"):
kwargs["quality"] = 85
out.save(buf, format=fmt, **kwargs)
return buf.getvalue()
def process(raw: bytes) -> dict:
"""Validate + re-encode + thumbnail an uploaded image.
Returns {ext, mime, width, height, data, thumb} where `data`/`thumb` are
the cleaned bytes. Raises MediaError on anything that is not an allowed,
well-formed image within limits."""
if not raw:
raise MediaError("empty upload")
if len(raw) > MAX_BYTES:
raise MediaError(f"file too large (> {MAX_BYTES // (1024 * 1024)} MiB)")
try:
probe = Image.open(io.BytesIO(raw))
probe.verify() # structural check; leaves probe unusable
img = Image.open(io.BytesIO(raw)) # reopen for actual pixel access
img.load()
except MediaError:
raise
except Exception as e: # noqa: BLE001 — Pillow raises many types
raise MediaError(f"not a valid image: {e}") from None
fmt = (img.format or "").upper()
if fmt not in _ALLOWED:
raise MediaError(f"unsupported image format: {fmt or 'unknown'}")
ext, mime = _ALLOWED[fmt]
width, height = img.size
if width <= 0 or height <= 0 or width > MAX_DIM or height > MAX_DIM:
raise MediaError(f"image dimensions out of range ({width}x{height})")
img = ImageOps.exif_transpose(img) # bake orientation in, then EXIF is gone
data = _encode(img, fmt)
# Thumbnail: first frame for animated GIFs, always a static vignette.
thumb_img = img
if getattr(thumb_img, "is_animated", False):
thumb_img.seek(0)
thumb_img = thumb_img.convert("RGBA") if fmt in ("PNG", "WEBP", "GIF") else thumb_img.convert("RGB")
thumb_img.thumbnail(THUMB_MAX, Image.LANCZOS)
thumb_fmt = "PNG" if fmt in ("PNG", "GIF") else fmt # static thumb; keep alpha via PNG
thumb = _encode(thumb_img, thumb_fmt)
return {"ext": ext, "mime": mime, "width": width, "height": height,
"data": data, "thumb": thumb}
def _thumb_ext(ext: str) -> str:
return "png" if ext in ("png", "gif") else ext
def store(media_id: str, processed: dict, directory: Path | None = None) -> tuple[str, str]:
"""Write the cleaned image + thumbnail under `directory` (default media_dir()).
Returns (filename, thumb_filename)."""
d = directory or media_dir()
d.mkdir(parents=True, exist_ok=True)
filename = f"{media_id}.{processed['ext']}"
thumb_name = f"{media_id}_t.{_thumb_ext(processed['ext'])}"
(d / filename).write_bytes(processed["data"])
(d / thumb_name).write_bytes(processed["thumb"])
return filename, thumb_name
def delete_files(filename: str, thumb: str, directory: Path | None = None) -> None:
"""Remove an attachment's files (best-effort; missing files are ignored)."""
d = directory or media_dir()
for name in (filename, thumb):
try:
(d / name).unlink()
except FileNotFoundError:
pass
except OSError:
pass
def read_bytes(filename: str, directory: Path | None = None) -> bytes:
"""Read a stored media file (for the portable .sbxsite export)."""
d = directory or media_dir()
return (d / filename).read_bytes()

View File

@ -98,3 +98,10 @@ input:focus,textarea:focus{outline:none;border-color:var(--cyan);box-shadow:0 0
.login-wrap .brand .sub{color:var(--p31-dim);font-size:.72rem;letter-spacing:.1em;text-transform:uppercase}
@media(max-width:600px){.wrap{padding:1rem .7rem}.item{grid-template-columns:auto 1fr}.item .row-actions{grid-column:1/-1;justify-content:flex-end}}
/* media gallery (editor) */
.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:.7rem}
.media-cell{position:relative;margin:0;border-radius:6px;overflow:hidden;border:1px solid var(--border);background:var(--bg-row)}
.media-cell img{display:block;width:100%;height:120px;object-fit:cover}
.media-cell form{position:absolute;top:.3rem;right:.3rem;margin:0}
.btn.sm{padding:.25rem .5rem;font-size:.8rem}

View File

@ -129,3 +129,31 @@ code{background:color-mix(in srgb,var(--p1) 14%,transparent);padding:.1em .4em;b
.editor-actions{display:flex;gap:.5rem;flex-wrap:wrap}
.error{background:color-mix(in srgb,#e11d48 14%,transparent);padding:.55rem .9rem;border-radius:12px}
@media(max-width:480px){.link-card .card-img{width:84px;height:72px}}
/* ── media gallery + lightbox ─────────────────────────────────────────── */
.gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));
gap:.5rem;margin:.75rem 0}
.gallery-item{display:block;border-radius:8px;overflow:hidden;line-height:0;
border:1px solid rgba(255,255,255,.08);background:#0d1117}
.gallery-item img{width:100%;height:150px;object-fit:cover;cursor:zoom-in;
transition:transform .2s}
.gallery-item:hover img{transform:scale(1.04)}
/* single image: let it breathe, don't crop hard */
.gallery:has(.gallery-item:only-child) .gallery-item img{height:auto;max-height:520px;object-fit:contain}
.lightbox{position:fixed;inset:0;z-index:1000;display:none;
align-items:center;justify-content:center;background:rgba(5,8,12,.94);
backdrop-filter:blur(4px)}
.lightbox.open{display:flex}
body.lb-lock{overflow:hidden}
.lb-img{max-width:92vw;max-height:88vh;object-fit:contain;border-radius:6px;
box-shadow:0 20px 60px rgba(0,0,0,.6);cursor:zoom-in;transition:transform .2s}
.lb-img.zoomed{transform:scale(1.8);cursor:zoom-out}
.lb-close,.lb-nav{position:absolute;background:rgba(20,30,45,.7);color:#e8e6d9;
border:1px solid rgba(100,150,200,.3);cursor:pointer;font-family:inherit;
border-radius:8px;transition:border-color .15s,background .15s}
.lb-close:hover,.lb-nav:hover{border-color:#00d4ff;background:rgba(0,212,255,.15)}
.lb-close{top:1rem;right:1rem;width:2.4rem;height:2.4rem;font-size:1.1rem}
.lb-nav{top:50%;transform:translateY(-50%);width:2.8rem;height:3.4rem;font-size:1.8rem}
.lb-prev{left:1rem}.lb-next{right:1rem}
@media(max-width:600px){.lb-nav{width:2.2rem;height:2.8rem;font-size:1.4rem}}

View File

@ -58,3 +58,72 @@
}
});
})();
// Lightbox: click a gallery vignette to view the full image, zoomable, with
// keyboard/arrow navigation within the same billet's gallery. With JS disabled
// the .gallery-item links open the full image directly (graceful degradation).
(function () {
"use strict";
var overlay = null, imgEl = null, items = [], idx = 0;
function build() {
overlay = document.createElement("div");
overlay.className = "lightbox";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.innerHTML =
'<button class="lb-close" aria-label="Fermer">✕</button>' +
'<button class="lb-nav lb-prev" aria-label="Précédente"></button>' +
'<img class="lb-img" alt="">' +
'<button class="lb-nav lb-next" aria-label="Suivante"></button>';
document.body.appendChild(overlay);
imgEl = overlay.querySelector(".lb-img");
overlay.addEventListener("click", function (e) {
if (e.target === overlay || e.target.classList.contains("lb-close")) close();
else if (e.target.classList.contains("lb-prev")) step(-1);
else if (e.target.classList.contains("lb-next")) step(1);
else if (e.target === imgEl) imgEl.classList.toggle("zoomed");
});
}
function show() {
var it = items[idx];
if (!it) return;
imgEl.classList.remove("zoomed");
imgEl.src = it.getAttribute("data-full");
imgEl.alt = it.getAttribute("data-alt") || "";
var multi = items.length > 1;
overlay.querySelector(".lb-prev").style.display = multi ? "" : "none";
overlay.querySelector(".lb-next").style.display = multi ? "" : "none";
}
function step(d) { idx = (idx + d + items.length) % items.length; show(); }
function open(gallery, start) {
if (!overlay) build();
items = Array.prototype.slice.call(gallery.querySelectorAll(".gallery-item"));
idx = start; show();
overlay.classList.add("open");
document.body.classList.add("lb-lock");
}
function close() {
if (!overlay) return;
overlay.classList.remove("open");
document.body.classList.remove("lb-lock");
imgEl.src = "";
}
document.addEventListener("click", function (e) {
var link = e.target.closest && e.target.closest(".gallery-item");
if (!link) return;
var gallery = link.closest("[data-lightbox]");
if (!gallery) return;
e.preventDefault();
var all = Array.prototype.slice.call(gallery.querySelectorAll(".gallery-item"));
open(gallery, all.indexOf(link));
});
document.addEventListener("keydown", function (e) {
if (!overlay || !overlay.classList.contains("open")) return;
if (e.key === "Escape") close();
else if (e.key === "ArrowLeft") step(-1);
else if (e.key === "ArrowRight") step(1);
});
})();

View File

@ -11,6 +11,7 @@
<div><h1>📮 Billets</h1><div class="sub">🔐 admin · {{ author.username }}</div></div>
<div class="btn-group">
<a class="btn" href="/" target="_blank">🌐 Voir le blog</a>
<a class="btn" href="/admin/export.sbxsite">💾 Backup .sbxsite</a>
<form method="post" action="/admin/logout"><input type="hidden" name="csrf" value="{{ csrf }}">
<button type="submit" class="btn danger">⏻ Déconnexion</button></form>
</div>

View File

@ -14,13 +14,18 @@
</header>
{% if error %}<p class="flash err">❌ {{ error }}</p>{% endif %}
<div class="card">
<form method="post" action="{{ '/admin/billets/' ~ billet.id if billet else '/admin/billets' }}">
<form method="post" enctype="multipart/form-data"
action="{{ '/admin/billets/' ~ billet.id if billet else '/admin/billets' }}">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div class="form-group"><label>📝 Billet (markdown restreint)</label>
<textarea name="body" rows="8" required>{{ billet.body if billet else '' }}</textarea></div>
<div class="form-group"><label>🔗 URL (référence ou embed)</label>
<input name="url" type="url" placeholder="https://…"
value="{{ (billet.embed_url or billet.ref_url) if billet else '' }}"></div>
<div class="form-group"><label>🖼️ Images (png · jpeg · webp · gif · ≤ 5 Mo)</label>
<input name="media_files" type="file" multiple
accept="image/png,image/jpeg,image/webp,image/gif">
<small class="muted">Vignettes cliquables + zoom sur le blog. EXIF supprimé automatiquement.</small></div>
<div class="form-group"><label>Type de lien</label>
<div class="form-actions">
<label class="muted"><input type="radio" name="url_kind" value="ref"
@ -42,5 +47,24 @@
</div>
</form>
</div>
{% if billet and media %}
<div class="card">
<h3 style="color:var(--cyan);text-transform:uppercase;letter-spacing:.08em;font-size:.8rem;margin-bottom:.7rem">🖼️ Images ({{ media|length }})</h3>
<div class="media-grid">
{% for m in media %}
<figure class="media-cell">
<img src="/media/{{ m.thumb }}" alt="{{ m.alt }}" loading="lazy"
width="120" height="120">
<form method="post" action="/admin/media/{{ m.id }}/delete"
onsubmit="return confirm('Supprimer cette image ?')">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button type="submit" class="btn danger sm">🗑️</button>
</form>
</figure>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</body></html>

View File

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ site_title }}{% endblock %}</title>
<link rel="stylesheet" href="/static/billets.css?v=2">
<link rel="stylesheet" href="/static/billets.css?v=3">
<link rel="alternate" type="application/atom+xml" title="{{ site_title }}" href="/feed.xml">
<link rel="alternate" type="application/feed+json" title="{{ site_title }}" href="/feed.json">
{% block head %}{% endblock %}
@ -20,6 +20,6 @@
<footer class="site-footer">
<a href="/feed.xml">Atom</a> · <a href="/feed.json">JSON</a> · billets
</footer>
<script src="/static/billets.js?v=2" defer></script>
<script src="/static/billets.js?v=3" defer></script>
</body>
</html>

View File

@ -8,15 +8,28 @@
<meta property="og:title" content="{{ og.title }}">
<meta property="og:description" content="{{ og.desc }}">
<meta property="og:url" content="{{ og.url }}">
<meta name="twitter:card" content="summary">
{% if og.image %}<meta property="og:image" content="{{ og.image }}">{% endif %}
<meta name="twitter:card" content="{{ 'summary_large_image' if og.image else 'summary' }}">
<meta name="twitter:title" content="{{ og.title }}">
<meta name="twitter:description" content="{{ og.desc }}">
{% if og.image %}<meta name="twitter:image" content="{{ og.image }}">{% endif %}
{% endif %}
{% if oembed_url %}<link rel="alternate" type="application/json+oembed" href="{{ oembed_url }}">{% endif %}
{% endblock %}
{% block content %}
<article class="h-entry billet billet-full">
<div class="e-content">{{ billet.body_html|safe }}</div>
{% if billet.media %}
<div class="gallery" data-lightbox>
{% for m in billet.media %}
<a class="gallery-item" href="/media/{{ m.filename }}" data-full="/media/{{ m.filename }}"
data-alt="{{ m.alt }}" aria-label="Agrandir l'image">
<img src="/media/{{ m.thumb }}" alt="{{ m.alt }}" loading="lazy"
width="{{ m.width }}" height="{{ m.height }}">
</a>
{% endfor %}
</div>
{% endif %}
{% if billet.ref_host %}<a class="ref-chip u-url" href="{{ billet.ref_url }}" target="_blank" rel="noopener nofollow ugc">{{ billet.ref_host }}</a>{% endif %}
{% if billet.embed_html %}<div class="embed">{{ billet.embed_html|safe }}</div>{% endif %}
<footer class="billet-meta">

View File

@ -4,6 +4,17 @@
{% for b in billets %}
<article class="h-entry billet">
<div class="e-content">{{ b.body_html|safe }}</div>
{% if b.media %}
<div class="gallery" data-lightbox>
{% for m in b.media %}
<a class="gallery-item" href="/media/{{ m.filename }}" data-full="/media/{{ m.filename }}"
data-alt="{{ m.alt }}" aria-label="Agrandir l'image">
<img src="/media/{{ m.thumb }}" alt="{{ m.alt }}" loading="lazy"
width="{{ m.width }}" height="{{ m.height }}">
</a>
{% endfor %}
</div>
{% endif %}
{% if b.ref_host %}<a class="ref-chip u-url" href="{{ b.ref_url }}" target="_blank" rel="noopener nofollow ugc">{{ b.ref_host }}</a>{% endif %}
{% if b.embed_html %}<div class="embed">{{ b.embed_html|safe }}</div>{% endif %}
{% with share=b.share, permalink=b.permalink, title=b.title %}{% include "_share_quick.html" %}{% endwith %}

View File

@ -16,6 +16,14 @@ server {
try_files $uri =404;
}
# User-uploaded media (re-encoded + EXIF-stripped by the app before write).
location /media/ {
alias /var/lib/secubox/billets/media/;
expires 30d;
access_log off;
try_files $uri =404;
}
location / {
proxy_pass http://unix:/run/secubox/billets.sock;
proxy_http_version 1.1;
@ -24,6 +32,6 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 30s;
client_max_body_size 1m;
client_max_body_size 6m; # images up to 5 MiB + form overhead
}
}

View File

@ -13,3 +13,4 @@ pyotp==2.9.0
itsdangerous==2.2.0
markdown-it-py==3.0.0
python-multipart==0.0.20
pillow==11.1.0

View File

@ -0,0 +1,180 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""Media attachments: image ingestion (re-encode/EXIF-strip), upload/delete
flow, public gallery render, and the portable .sbxsite export."""
import io
import json
from pathlib import Path
import httpx
import pytest
import pytest_asyncio
from PIL import Image
from api import repo
from api.main import create_app
from api.services import media
from api.services import security as sec
PW = "s3cret-pass-123"
NOW = "2026-07-11T12:00:00Z"
def _png(color=(10, 120, 200), size=(64, 48)) -> bytes:
buf = io.BytesIO()
Image.new("RGB", size, color).save(buf, format="PNG")
return buf.getvalue()
def _jpeg_with_exif() -> bytes:
buf = io.BytesIO()
img = Image.new("RGB", (32, 32), (200, 30, 30))
exif = Image.Exif()
exif[0x0112] = 6 # Orientation
exif[0x8825] = {} # a GPS IFD marker
img.save(buf, format="JPEG", exif=exif)
return buf.getvalue()
# ── unit: image ingestion ────────────────────────────────────────────────────
def test_process_png_returns_clean_and_thumb():
out = media.process(_png(size=(1000, 500)))
assert out["ext"] == "png" and out["mime"] == "image/png"
assert out["width"] == 1000 and out["height"] == 500
thumb = Image.open(io.BytesIO(out["thumb"]))
assert max(thumb.size) <= 480 # vignette bounded
def test_process_strips_exif():
out = media.process(_jpeg_with_exif())
reopened = Image.open(io.BytesIO(out["data"]))
assert not dict(reopened.getexif()) # metadata gone after re-encode
def test_process_rejects_non_image():
with pytest.raises(media.MediaError):
media.process(b"<svg xmlns='http://www.w3.org/2000/svg'></svg>")
with pytest.raises(media.MediaError):
media.process(b"not an image at all")
def test_process_rejects_oversized():
with pytest.raises(media.MediaError):
media.process(b"\x89PNG" + b"\x00" * (media.MAX_BYTES + 1))
def test_store_and_delete_roundtrip(tmp_path):
out = media.process(_png())
fn, thumb = media.store("01MEDIA0000000000000000AA", out, directory=tmp_path)
assert (tmp_path / fn).exists() and (tmp_path / thumb).exists()
assert media.read_bytes(fn, directory=tmp_path) == out["data"]
media.delete_files(fn, thumb, directory=tmp_path)
assert not (tmp_path / fn).exists() and not (tmp_path / thumb).exists()
media.delete_files(fn, thumb, directory=tmp_path) # idempotent
# ── integration: upload / render / export ────────────────────────────────────
@pytest_asyncio.fixture
async def client(conn, tmp_path, monkeypatch):
monkeypatch.setenv("BILLETS_MEDIA_DIR", str(tmp_path / "media"))
await repo.create_author(conn, "gk", sec.hash_password(PW), now=NOW,
author_id="01AUTHOR0000000000000000AA")
app = create_app(conn, secret="test-secret-xyz", revisions_dir=str(tmp_path / "revs"))
app.state.clock = lambda: NOW
app.state.http_client = httpx.AsyncClient(transport=httpx.MockTransport(
lambda r: httpx.Response(204)))
app.state.resolver = lambda host, port: [(0, 0, 0, "", ("93.184.216.34", port))]
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://t",
follow_redirects=False) as c:
yield c, conn, tmp_path
async def _login(c):
await c.get("/admin/login")
csrf = c.cookies.get("billets_csrf")
await c.post("/admin/login", data={"username": "gk", "password": PW, "csrf": csrf})
return c.cookies.get("billets_csrf")
async def test_upload_attaches_media_and_writes_file(client):
c, conn, tmp_path = client
csrf = await _login(c)
r = await c.post("/admin/billets",
data={"body": "avec image", "action": "publish", "csrf": csrf},
files={"media_files": ("shot.png", _png(), "image/png")})
assert r.status_code == 303
bid = (await repo.list_all(conn))[0]["id"]
rows = await repo.list_media(conn, bid)
assert len(rows) == 1 and rows[0]["mime"] == "image/png"
assert (tmp_path / "media" / rows[0]["filename"]).exists()
assert (tmp_path / "media" / rows[0]["thumb"]).exists()
async def test_invalid_upload_is_skipped_not_fatal(client):
c, conn, tmp_path = client
csrf = await _login(c)
r = await c.post("/admin/billets",
data={"body": "texte seul survit", "action": "publish", "csrf": csrf},
files={"media_files": ("bad.png", b"junk-not-image", "image/png")})
assert r.status_code == 303 # publish still succeeds
bid = (await repo.list_all(conn))[0]["id"]
assert await repo.list_media(conn, bid) == [] # bad file dropped
async def test_public_feed_and_permalink_show_gallery(client):
c, conn, tmp_path = client
csrf = await _login(c)
await c.post("/admin/billets",
data={"body": "galerie", "action": "publish", "csrf": csrf},
files={"media_files": ("a.png", _png(), "image/png")})
row = (await repo.list_all(conn))[0]
feed = await c.get("/")
assert 'class="gallery"' in feed.text and "/media/" in feed.text
perm = await c.get(f"/b/{row['slug']}")
assert "data-lightbox" in perm.text and "og:image" in perm.text
async def test_delete_media_removes_row_and_files(client):
c, conn, tmp_path = client
csrf = await _login(c)
await c.post("/admin/billets",
data={"body": "x", "action": "draft", "csrf": csrf},
files={"media_files": ("a.png", _png(), "image/png")})
bid = (await repo.list_all(conn))[0]["id"]
m = (await repo.list_media(conn, bid))[0]
fpath = tmp_path / "media" / m["filename"]
assert fpath.exists()
r = await c.post(f"/admin/media/{m['id']}/delete", data={"csrf": csrf})
assert r.status_code == 303
assert await repo.list_media(conn, bid) == []
assert not fpath.exists()
async def test_delete_billet_cascades_media_files(client):
c, conn, tmp_path = client
csrf = await _login(c)
await c.post("/admin/billets",
data={"body": "x", "action": "draft", "csrf": csrf},
files={"media_files": ("a.png", _png(), "image/png")})
bid = (await repo.list_all(conn))[0]["id"]
m = (await repo.list_media(conn, bid))[0]
fpath = tmp_path / "media" / m["filename"]
await c.post(f"/admin/billets/{bid}/delete", data={"csrf": csrf})
assert await repo.get_media(conn, m["id"]) is None # row cascaded
assert not fpath.exists() # file removed
async def test_export_sbxsite_embeds_media_base64(client):
c, conn, tmp_path = client
csrf = await _login(c)
await c.post("/admin/billets",
data={"body": "portable", "action": "publish", "csrf": csrf},
files={"media_files": ("a.png", _png(), "image/png")})
r = await c.get("/admin/export.sbxsite")
assert r.status_code == 200
assert 'filename="billets.sbxsite"' in r.headers.get("content-disposition", "")
payload = json.loads(r.text)
assert payload["format"] == "sbxsite/billets" and payload["version"] == 1
assert len(payload["billets"]) == 1 and len(payload["media"]) == 1
assert payload["media"][0]["b64"] # image inlined