mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
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:
parent
0d50cf6a51
commit
db74e02ffc
|
|
@ -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"])),
|
||||
})
|
||||
|
|
|
|||
19
packages/secubox-billets/api/migrations/0002_media.sql
Normal file
19
packages/secubox-billets/api/migrations/0002_media.sql
Normal 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);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
142
packages/secubox-billets/api/services/media.py
Normal file
142
packages/secubox-billets/api/services/media.py
Normal 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()
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
180
packages/secubox-billets/tests/test_media.py
Normal file
180
packages/secubox-billets/tests/test_media.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user