feat(billets): communiqué style + embed snapshot vignette + portable HTML archive (ref #851)

- Per-billet 'style' (default|communique). Communiqué permalink = the poster
  mockup (Space Grotesk/JetBrains Mono, ROUTE side-band, TRANSMISSION frame,
  slogans/footer); funky feed unchanged.
- Embed snapshot vignette: api/services/snapshot.py tries headless Chromium
  (guarded/optional), falls back to SSRF-guarded og:image, re-encoded via the
  media service. Captured off-loop on save (communiqué billets, cached per
  embed_url). Public render shows the vignette; click-to-load the live iframe.
- Portable single-HTML archive: GET /admin/billets/{id}/archive.html — self-
  contained (inlined CSS, system fonts, media+snapshot as base64 data-URIs,
  embed = snapshot linking to original, no iframe). Off-loop render.
- migration 0003 (style + embed_snapshot); models/repo/admin/main wired.
- CSP: style-src adds 'unsafe-inline' so the WAF-injected health-banner's
  dynamic <style> renders (was blocked → banner fell unstyled to page bottom);
  script-src stays 'self'. Google-Fonts relaxation page-scoped to communiqué.
- 118 tests (11 new: snapshot fallback/SSRF, communiqué render, archive self-containment).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-14 07:57:54 +02:00
parent bb3c56c2cd
commit a95f157eb1
21 changed files with 1096 additions and 20 deletions

View File

@ -58,6 +58,35 @@ HAProxy (TLS 1.3) → sbxwaf → nginx → the socket.
| `BILLETS_SECRET_FILE` | `/etc/secubox/secrets/billets` | signing secret (0600 secubox) |
| `BILLETS_SECRET` | — | secret override (takes precedence) |
| `BILLETS_SITE_URL` | derived from request | absolute origin for feeds/oEmbed |
| `BILLETS_MEDIA_DIR` | `/var/lib/secubox/billets/media` | re-encoded media + embed snapshots |
| `BILLETS_SNAPSHOT_BROWSER` | `1` | set `0` to disable the headless-Chromium capture |
## Communiqué style + embed snapshot
Each billet has a `style`: **`default`** (feed look) or **`communique`** (a
poster/affiche layout — ROOT→MESH→MIND side-band, a framed "TRANSMISSION · CANAL
PUBLIC" embed, stamp/kicker/slogans/footer). Pick it in the editor.
For communiqué billets with an embed, the module captures a still **vignette** of
the embed (stored like any media file, shown in the poster frame as a
click-to-load thumbnail and in the feed as a compact framed image):
1. **Headless Chromium** (a real full-page screenshot) — **OPTIONAL**. Enable it
with `pip install playwright && playwright install chromium` (or install the
`snapshot` extra). Any missing package / browser / timeout degrades silently.
2. **og:image fallback** — the page's `og:image` fetched through the same SSRF
guard (https-only, must resolve to a public IP, redirects re-validated,
size-capped). This is the default path when Chromium is not installed, so the
feature works fully without a browser.
The capture runs OFF the shared event loop (`asyncio.to_thread`) and only
re-captures when the `embed_url` changes.
**`GET /admin/billets/{id}/archive.html`** (JWT-gated) exports a portable,
fully self-contained single `.html` of a billet in the communiqué layout: CSS
inlined, system fonts (no network), media + the embed vignette inlined as
`data:` URIs, and the embed rendered as its snapshot image **linking to the
original** (no live iframe, so it opens offline). Button: "💾 Archive HTML".
To push revisions to Gitea, add an `origin` remote in the revisions repo as the
`secubox` user (else commits stay local — still a full history).

View File

@ -77,11 +77,18 @@ def _frame_src(extra_hosts: tuple[str, ...] = ()) -> str:
return " ".join(parts)
def _csp(frame_src: str) -> str:
def _csp(frame_src: str, *, fonts: bool = False) -> str:
# style-src allows 'unsafe-inline': the SecuBox health-banner is injected by
# the WAF (sbxmitm sub_filter) and self-styles via a dynamic <style> element;
# a strict style-src blocked it, so the banner fell to the page bottom
# unstyled. script-src stays 'self' (the real XSS lever); user content is
# nh3-sanitized. `fonts` also adds Google Fonts for the communiqué permalink.
style_src = "style-src 'self' 'unsafe-inline'" + (" https://fonts.googleapis.com" if fonts else "")
font_src = " font-src https://fonts.gstatic.com;" if fonts else ""
return (
"default-src 'self'; img-src 'self' https: data:; "
"style-src 'self'; script-src 'self'; base-uri 'none'; "
f"form-action 'self'; frame-ancestors 'none'; frame-src {frame_src}"
f"{style_src}; script-src 'self'; base-uri 'none'; "
f"form-action 'self'; frame-ancestors 'none';{font_src} frame-src {frame_src}"
)
@ -116,6 +123,9 @@ def _billet_view(row: aiosqlite.Row, base: str = "", media_rows=None) -> dict:
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 [])]
d["style"] = d.get("style") or "default"
snap = d.get("embed_snapshot")
d["embed_snapshot_url"] = f"/media/{snap}" if snap else None
return d
@ -191,7 +201,8 @@ def create_app(conn: aiosqlite.Connection | None = None, *, secret: str | None =
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", {
tmpl = "billet_communique.html" if row["style"] == "communique" else "billet.html"
resp = templates.TemplateResponse(request, tmpl, {
"site_title": SITE_TITLE, "tagline": SITE_TAGLINE,
"billet": _billet_view(row, base, media_rows), "reactions": rctx,
"comments": comment_views, "pcsrf": pcsrf,
@ -210,12 +221,17 @@ def create_app(conn: aiosqlite.Connection | None = None, *, secret: str | None =
secure=(request.headers.get("x-forwarded-proto", request.url.scheme) == "https"),
max_age=31536000, path="/")
# A self-hosted embed (Mastodon/PeerTube) needs its instance host in
# frame-src; add it for this page only.
# frame-src; add it for this page only. The communiqué look also needs
# Google Fonts in style-src/font-src (page-scoped relaxation).
is_comm = row["style"] == "communique"
extra_hosts: tuple[str, ...] = ()
if row["embed_html"] and row["embed_url"]:
from urllib.parse import urlparse
host = urlparse(row["embed_url"]).hostname
if host and not any(host == d or host.endswith("." + d) for d in _FRAME_HOSTS):
resp.headers["Content-Security-Policy"] = _csp(_frame_src((host,)))
extra_hosts = (host,)
if extra_hosts or is_comm:
resp.headers["Content-Security-Policy"] = _csp(_frame_src(extra_hosts), fonts=is_comm)
return resp
async def _feed_rows() -> list[aiosqlite.Row]:

View File

@ -0,0 +1,13 @@
-- SPDX-License-Identifier: LicenseRef-CMSD-1.0
-- Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
-- billets :: per-billet presentation style + embed snapshot vignette.
-- style : 'default' (feed look) or 'communique' (poster/affiche look).
-- embed_snapshot : filename of a captured vignette of the embed (headless
-- screenshot, or og:image fallback), stored under
-- BILLETS_MEDIA_DIR like any media file. Nullable — no snapshot
-- yet (or capture failed) => render the live embed instead.
-- SQLite ALTER ADD COLUMN with a constant default is a metadata-only operation.
ALTER TABLE billet ADD COLUMN style TEXT NOT NULL DEFAULT 'default'
CHECK (style IN ('default','communique'));
ALTER TABLE billet ADD COLUMN embed_snapshot TEXT;

View File

@ -10,7 +10,7 @@ from __future__ import annotations
import enum
import re
import unicodedata
from typing import Annotated, Optional
from typing import Annotated, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, field_validator
@ -73,6 +73,7 @@ class BilletIn(BaseModel):
body: Annotated[str, StringConstraints(min_length=1, max_length=BODY_MAX)]
ref_url: Optional[_HttpsUrl] = None
embed_url: Optional[_HttpsUrl] = None
style: Literal["default", "communique"] = "default"
publish: bool = False
@field_validator("ref_url", "embed_url")
@ -120,6 +121,8 @@ class Billet(BaseModel):
embed_fetched_at: Optional[str] = None
slug: str
status: BilletStatus
style: str = "default"
embed_snapshot: Optional[str] = None
view_count: int = 0

View File

@ -16,7 +16,8 @@ from .ids import new_ulid
from .models import BilletIn, slugify
_FEED_COLUMNS = ("id,created_at,updated_at,published_at,body,ref_url,embed_url,"
"embed_html,embed_provider,embed_fetched_at,slug,status,view_count")
"embed_html,embed_provider,embed_fetched_at,slug,status,"
"style,embed_snapshot,view_count")
def encode_cursor(published_at: str, billet_id: str) -> str:
@ -43,9 +44,9 @@ async def create_billet(conn: aiosqlite.Connection, data: BilletIn, *, now: str,
published_at = now if data.publish else None
await conn.execute(
"INSERT INTO billet(id,created_at,updated_at,published_at,body,ref_url,"
"embed_url,slug,status) VALUES (?,?,?,?,?,?,?,?,?)",
"embed_url,slug,status,style) VALUES (?,?,?,?,?,?,?,?,?,?)",
(billet_id, now, now, published_at, data.body, data.ref_url,
data.embed_url, slug, status),
data.embed_url, slug, status, data.style),
)
await conn.commit()
return billet_id
@ -152,15 +153,24 @@ async def list_all(conn: aiosqlite.Connection, *, status: Optional[str] = None,
async def update_billet(conn: aiosqlite.Connection, billet_id: str, *, body: str,
ref_url: Optional[str], embed_url: Optional[str], now: str) -> None:
ref_url: Optional[str], embed_url: Optional[str], now: str,
style: str = "default") -> None:
"""Update content (slug/permalink stays stable) + bump updated_at."""
await conn.execute(
"UPDATE billet SET body=?, ref_url=?, embed_url=?, updated_at=? WHERE id=?",
(body, ref_url, embed_url, now, billet_id),
"UPDATE billet SET body=?, ref_url=?, embed_url=?, style=?, updated_at=? WHERE id=?",
(body, ref_url, embed_url, style, now, billet_id),
)
await conn.commit()
async def set_embed_snapshot(conn: aiosqlite.Connection, billet_id: str,
filename: Optional[str]) -> None:
"""Record (or clear) the captured embed-vignette filename for a billet."""
await conn.execute(
"UPDATE billet SET embed_snapshot=? WHERE id=?", (filename, billet_id))
await conn.commit()
async def set_status(conn: aiosqlite.Connection, billet_id: str, status: str, *, now: str) -> None:
"""Change status. Publishing sets published_at once (never overwrites it)."""
if status == "published":

View File

@ -20,7 +20,7 @@ from pydantic import ValidationError
from .. import repo
from ..ids import new_ulid
from ..models import BilletIn
from ..services import eventlog, linkcard, media, revisions
from ..services import archive, eventlog, linkcard, media, revisions, snapshot
from ..services import security as sec
from ..services import ssrf as ssrf_mod
@ -81,7 +81,7 @@ async def _log_and_revision(request: Request, event_type: str, billet_row, *, ac
"created_at": billet_row["created_at"], "updated_at": billet_row["updated_at"],
"published_at": billet_row["published_at"], "ref_url": billet_row["ref_url"],
"embed_url": billet_row["embed_url"], "embed_provider": billet_row["embed_provider"],
"style": "default",
"style": billet_row["style"],
}
msg = f"{event_type} {billet_row['slug']} by {actor}"
await asyncio.to_thread(revisions.commit_revision, repo_dir, billet_row["id"],
@ -100,6 +100,38 @@ async def _resolve_and_store_embed(request: Request, billet_id: str, embed_url:
provider=res["provider"], fetched_at=_now(request))
async def _maybe_capture_snapshot(request: Request, billet_id: str, embed_url: str | None,
style: str, *, prev_embed_url: str | None = None,
prev_snapshot: str | None = None) -> None:
"""Capture a still vignette of the embed for communiqué billets, OFF-loop.
Only communiqué-styled billets with an embed need the poster vignette. The
capture (headless Chromium, else the SSRF-guarded og:image) is CPU/IO + a
browser, so it runs via `asyncio.to_thread`; the fetch of the og:image URL
also happens in that thread inside `snapshot.capture`. Cache: skip when the
embed_url is unchanged and a snapshot already exists. Never raises a
snapshot must not break a save."""
if style != "communique" or not embed_url:
return
if prev_snapshot and prev_embed_url == embed_url:
return
try:
conn = request.app.state.conn
client = request.app.state.http_client
resolver = getattr(request.app.state, "resolver", ssrf_mod._default_resolver)
og_image = await linkcard.fetch_og_image(embed_url, client=client, resolver=resolver)
media_id = new_ulid()
result = await asyncio.to_thread(snapshot.capture, embed_url, og_image,
media_id, resolver=resolver)
if result:
await repo.set_embed_snapshot(conn, billet_id, result[0])
elif prev_embed_url != embed_url:
# Embed changed but capture failed → drop any now-stale vignette.
await repo.set_embed_snapshot(conn, billet_id, None)
except Exception: # noqa: BLE001 — embedding/snapshot must not break a publish
pass
def register_admin(app: FastAPI, templates: Jinja2Templates) -> None:
login_limiter = sec.RateLimiter(max_events=5, window_s=3600)
app.state.login_limiter = login_limiter
@ -250,7 +282,8 @@ def register_admin(app: FastAPI, templates: Jinja2Templates) -> None:
@app.post("/admin/billets")
async def create(request: Request, body: str = Form(...), url: str = Form(""),
url_kind: str = Form("ref"), action: str = Form("draft"),
url_kind: str = Form("ref"), style: str = Form("default"),
action: str = Form("draft"),
csrf: str = Form(""), media_files: list[UploadFile] = File(default=[])):
author = await _current_author(request)
if author is None:
@ -260,7 +293,7 @@ def register_admin(app: FastAPI, templates: Jinja2Templates) -> None:
ref_url, embed_url = _urls(url, url_kind)
try:
data = BilletIn(body=body, ref_url=ref_url, embed_url=embed_url,
publish=(action == "publish"))
style=style, publish=(action == "publish"))
except ValidationError:
resp = templates.TemplateResponse(request, "admin_edit.html",
{"billet": None, "error": "Entrée invalide (corps ou URL https).",
@ -269,6 +302,7 @@ def register_admin(app: FastAPI, templates: Jinja2Templates) -> None:
billet_id = await repo.create_billet(request.app.state.conn, data, now=_now(request))
skipped = await _save_uploads(request, billet_id, media_files)
await _resolve_and_store_embed(request, billet_id, data.embed_url)
await _maybe_capture_snapshot(request, billet_id, data.embed_url, data.style)
row = await repo.get_by_id(request.app.state.conn, billet_id)
event = "billet.published" if data.publish else "billet.edited"
await _log_and_revision(request, event, row, actor=author["username"])
@ -293,6 +327,7 @@ def register_admin(app: FastAPI, templates: Jinja2Templates) -> None:
@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"),
style: str = Form("default"),
action: str = Form("save"), csrf: str = Form(""),
media_files: list[UploadFile] = File(default=[])):
author = await _current_author(request)
@ -304,22 +339,26 @@ def register_admin(app: FastAPI, templates: Jinja2Templates) -> None:
row = await repo.get_by_id(conn, billet_id)
if row is None:
return _redirect("/admin")
prev_embed_url = row["embed_url"]
prev_snapshot = row["embed_snapshot"]
ref_url, embed_url = _urls(url, url_kind)
try:
BilletIn(body=body, ref_url=ref_url, embed_url=embed_url)
data = BilletIn(body=body, ref_url=ref_url, embed_url=embed_url, style=style)
except ValidationError:
resp = templates.TemplateResponse(request, "admin_edit.html",
{"billet": dict(row), "error": "Entrée invalide.",
"csrf": request.cookies.get(CSRF_COOKIE) or ""})
return resp
await repo.update_billet(conn, billet_id, body=body, ref_url=ref_url,
embed_url=embed_url, now=_now(request))
embed_url=embed_url, style=data.style, now=_now(request))
skipped = 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",
now=_now(request))
await _resolve_and_store_embed(request, billet_id, embed_url)
await _maybe_capture_snapshot(request, billet_id, embed_url, data.style,
prev_embed_url=prev_embed_url, prev_snapshot=prev_snapshot)
row = await repo.get_by_id(conn, billet_id)
event = "billet.published" if action == "publish" else "billet.edited"
await _log_and_revision(request, event, row, actor=author["username"])
@ -401,6 +440,26 @@ def register_admin(app: FastAPI, templates: Jinja2Templates) -> None:
return Response(content=body, media_type="application/json",
headers={"Content-Disposition": 'attachment; filename="billets.sbxsite"'})
@app.get("/admin/billets/{billet_id}/archive.html")
async def archive_html(request: Request, billet_id: str):
"""Portable single-file .html of one billet in the communiqué layout:
CSS inlined, media + embed vignette inlined as data: URIs, embed rendered
as its snapshot image linking to the original (no live iframe offline).
The base64/render is CPU/IO kept OFF the shared loop."""
author = await _current_author(request)
if author is None:
return _redirect("/admin/login")
conn = request.app.state.conn
row = await repo.get_by_id(conn, billet_id)
if row is None:
return _redirect("/admin")
billet = dict(row)
media_rows = [dict(m) for m in await repo.list_media(conn, billet_id)]
html = await asyncio.to_thread(archive.render, billet, media_rows)
fname = f"billet-{billet['slug']}.html"
return Response(content=html, media_type="text/html",
headers={"Content-Disposition": f'attachment; filename="{fname}"'})
@app.get("/admin/comments", response_class=HTMLResponse)
async def comments_queue(request: Request):
author = await _current_author(request)

View File

@ -0,0 +1,161 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""
SecuBox-Deb :: billets portable archive
CyberMind https://cybermind.fr
Render a single, fully self-contained .html snapshot of a billet in the
communiqué layout no external requests at all, so it opens offline forever:
* all CSS is inlined; fonts are a system stack (no Google Fonts fetch);
* every attached image and the embed vignette are inlined as `data:` base64
URIs (bytes read off the event loop by the caller);
* the embed is rendered as its snapshot image LINKING to the original
embed_url (a live <iframe> cannot work offline);
* the body is the same sanitized markdown render as the public page, and
every dynamic value is HTML-escaped.
`render()` does blocking file IO (media reads); the caller runs it in a thread.
"""
from __future__ import annotations
import base64
from html import escape
from urllib.parse import urlparse
from . import media
from .feeds import billet_title
from .render import render_markdown
# System-font stack — deliberately no webfont so the file is truly offline.
_ARCHIVE_CSS = """
:root{--bg:#FFFFFF;--surf:#FAFAF8;--surf2:#F4F3EF;--border:#E2E0D8;
--text:#1A1A18;--muted:#6B6963;--auth:#C04E24;--wall:#9A6010;--boot:#803018;
--mind:#3D35A0;--root:#0A5840;--mesh:#104A88;
--sans:'Space Grotesk',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
--mono:'JetBrains Mono',ui-monospace,'SFMono-Regular',Menlo,Consolas,'Liberation Mono',monospace}
*{margin:0;padding:0;box-sizing:border-box}
html,body{background:var(--surf);color:var(--text);font-family:var(--sans);
-webkit-font-smoothing:antialiased}
body{position:relative;min-height:100vh;padding-left:6px}
body::before{content:"";position:fixed;left:0;top:0;bottom:0;width:6px;
background:linear-gradient(180deg,var(--root),var(--mesh),var(--mind));z-index:10}
.wrap{max-width:720px;margin:0 auto;padding:48px 24px 64px}
.stamp{display:inline-block;border:2px solid var(--auth);color:var(--auth);
font-family:var(--mono);font-size:11px;font-weight:700;letter-spacing:.28em;
text-transform:uppercase;padding:6px 14px;transform:rotate(-2deg);margin-bottom:24px}
h1{font-size:clamp(28px,5.5vw,48px);font-weight:700;line-height:1.04;
letter-spacing:-.02em;text-transform:uppercase;margin-bottom:12px}
h1 .accent{color:var(--mind)}
.kicker{font-family:var(--mono);font-size:12px;color:var(--muted);
letter-spacing:.14em;text-transform:uppercase;margin-bottom:32px}
.kicker b{color:var(--root);font-weight:700}
.body{font-size:17px;line-height:1.6;margin-bottom:32px}
.body p{margin-bottom:14px}.body a{color:var(--mesh)}
.frame{background:var(--bg);border:1px solid var(--border);padding:14px;
position:relative;box-shadow:8px 8px 0 var(--surf2),8px 8px 0 1px var(--border);
margin-bottom:16px}
.frame::before{content:"TRANSMISSION · CANAL PUBLIC";position:absolute;top:-9px;
left:16px;background:var(--surf);padding:0 8px;font-family:var(--mono);
font-size:10px;letter-spacing:.2em;color:var(--mesh);font-weight:700}
.frame img{display:block;width:100%;height:auto;margin:0 auto;border:0}
.frame a{display:block}
.frame .noshot{font-family:var(--mono);font-size:13px;color:var(--mesh);
padding:24px 8px;text-align:center;word-break:break-all}
.gallery{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:16px}
.gallery img{max-width:220px;height:auto;border:1px solid var(--border)}
.slogans{margin-top:40px;display:grid;gap:0;border-top:1px solid var(--border)}
.slogans p{font-family:var(--mono);font-size:12px;padding:12px 0;
border-bottom:1px solid var(--border);letter-spacing:.06em;color:var(--muted)}
.slogans p::before{content:"\\25B8 ";font-weight:700}
.slogans p:nth-child(1)::before{color:var(--auth)}
.slogans p:nth-child(2)::before{color:var(--wall)}
.slogans p:nth-child(3)::before{color:var(--boot)}
.slogans p:nth-child(4)::before{color:var(--mind)}
.slogans p:nth-child(5)::before{color:var(--root)}
.slogans p:nth-child(6)::before{color:var(--mesh)}
.slogans p strong{color:var(--text)}
footer{margin-top:32px;font-family:var(--mono);font-size:10px;color:var(--muted);
letter-spacing:.18em;text-transform:uppercase;display:flex;
justify-content:space-between;flex-wrap:wrap;gap:8px}
@media (max-width:560px){.wrap{padding:32px 14px 48px}}
""".strip()
_SLOGANS = [
("AUTH", "l'identité ne se délègue pas."),
("WALL", "la défense agit hors du chemin."),
("BOOT", "la confiance commence au démarrage."),
("MIND", "l'appareil observe, l'humain décide."),
("ROOT", "le socle est disclosed, jamais dilué."),
("MESH", "chaque nœud tient la ligne."),
]
def _data_uri(mime: str, raw: bytes) -> str:
return f"data:{mime};base64,{base64.b64encode(raw).decode('ascii')}"
def _embed_block(billet: dict) -> str:
"""The poster frame: the embed snapshot inlined as a data: image, linking to
the original embed_url. Falls back to a plain link when there is no snapshot."""
embed_url = billet.get("embed_url")
snap = billet.get("embed_snapshot")
inner = ""
if snap:
try:
raw = media.read_bytes(snap)
mime = "image/png" if snap.lower().endswith((".png", ".gif")) else "image/jpeg"
if snap.lower().endswith(".webp"):
mime = "image/webp"
img = f'<img src="{_data_uri(mime, raw)}" alt="Aperçu de la transmission">'
inner = (f'<a href="{escape(embed_url, quote=True)}" rel="noopener nofollow ugc">{img}</a>'
if embed_url else img)
except OSError:
inner = ""
if not inner:
if not embed_url:
return ""
inner = (f'<a class="noshot" href="{escape(embed_url, quote=True)}" '
f'rel="noopener nofollow ugc">{escape(embed_url)}</a>')
return f'<div class="frame">{inner}</div>'
def _gallery_block(media_items: list[dict]) -> str:
imgs = []
for m in media_items:
try:
raw = media.read_bytes(m["filename"])
except OSError:
continue
imgs.append(f'<img src="{_data_uri(m.get("mime") or "image/png", raw)}" '
f'alt="{escape(m.get("alt") or "")}">')
return f'<div class="gallery">{"".join(imgs)}</div>' if imgs else ""
def render(billet: dict, media_items: list[dict]) -> str:
"""Build the self-contained communiqué HTML string for one billet.
Blocking (reads media files); run it in a thread from an async handler."""
title = billet_title(billet.get("body") or "")
body_html = render_markdown(billet.get("body") or "")
published = (billet.get("published_at") or billet.get("updated_at") or "")[:19].replace("T", " ")
host = urlparse(billet.get("embed_url") or "").hostname or ""
slogans = "".join(f"<p><strong>{escape(k)}</strong> — {escape(v)}</p>" for k, v in _SLOGANS)
return (
"<!DOCTYPE html>\n"
'<html lang="fr"><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
f"<title>{escape(title)} · Communiqué</title>"
f"<style>{_ARCHIVE_CSS}</style></head><body><div class=\"wrap\">"
'<div class="stamp">Communiqué officiel</div>'
f"<h1>{escape(title)}</h1>"
'<p class="kicker">CYBERMIND · SECUBOX-DEB · '
"<b>SOUVERAINETÉ PAR CONSTRUCTION</b></p>"
f'<div class="body">{body_html}</div>'
+ _embed_block(billet)
+ _gallery_block(media_items)
+ f'<div class="slogans">{slogans}</div>'
+ '<footer><span>CYBERMIND — NOTRE-DAME-DU-CRUET · SAVOIE</span>'
+ f"<span>{escape(published)} · {escape(host)}</span></footer>"
+ "</div></body></html>"
)

View File

@ -76,6 +76,20 @@ async def fetch_card(url: str, *, client: httpx.AsyncClient, resolver=ssrf._defa
return {"provider": "link", "html": html, "kind": "linkcard"}
async def fetch_og_image(url: str, *, client: httpx.AsyncClient,
resolver=ssrf._default_resolver) -> str | None:
"""Return the page's og:image (an https URL) or None. SSRF-guarded, never
raises used to seed the embed-snapshot fallback vignette."""
try:
_, ctype, body = await ssrf.fetch(url, client=client, resolver=resolver)
except ssrf.SSRFError:
return None
if "html" not in ctype and body[:1] != b"<":
return None
og = _og(body.decode("utf-8", "replace"))
return _https(og.get("og:image") or og.get("twitter:image"))
async def resolve_embed(url: str, *, client: httpx.AsyncClient, resolver=ssrf._default_resolver
) -> dict:
"""Full pipeline: oEmbed (allowlist/discovery) then link-card fallback.

View File

@ -0,0 +1,139 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""
SecuBox-Deb :: billets embed snapshot
CyberMind https://cybermind.fr
Capture a still "vignette" of a billet's embed for the communiqué poster look
and the portable offline archive.
Two sources, tried in order:
1. a headless-Chromium full-page screenshot (real render), IF `playwright` is
importable AND a browser binary is installed. This is OPTIONAL enable it
with `pip install playwright && playwright install chromium`. Any import
error, launch failure, navigation timeout or exception falls through.
2. the page's og:image (an https URL), fetched through the SAME SSRF policy as
the rest of the module (host must resolve to a public unicast IP; https
only; redirects re-validated; size-capped).
Whatever bytes come back are fed through `services.media.process` (full
re-encode EXIF-strip + polyglot-neutralise, plus a bounded vignette) and
written with `services.media.store`. Returns (filename, thumb_filename) or None
if BOTH sources failed / produced nothing usable.
`capture()` is SYNCHRONOUS and does blocking IO + (optionally) drives a browser.
It MUST be run OFF the shared event loop (the caller uses `asyncio.to_thread`).
Never call it directly inside an async handler.
"""
from __future__ import annotations
import os
from pathlib import Path
from urllib.parse import urljoin
import httpx
from . import media
from . import ssrf
# Bounds for the whole capture. The browser goto/wait budget (~22s) keeps the
# to_thread worker from lingering; the og:image fetch is size- and time-capped.
GOTO_TIMEOUT_MS = 20000
SETTLE_MS = 2000
IMAGE_MAX_BYTES = media.MAX_BYTES # 5 MiB, same ceiling as uploads
IMAGE_TIMEOUT = 10.0
def _browser_enabled(explicit: bool | None) -> bool:
if explicit is not None:
return explicit
# Off by default only if the operator disables it; the import/launch guard
# below makes "enabled but unavailable" degrade silently to og:image.
return os.environ.get("BILLETS_SNAPSHOT_BROWSER", "1") != "0"
def _screenshot_via_browser(embed_url: str) -> bytes | None:
"""Full-page PNG screenshot via headless Chromium, or None if unavailable.
Guarded so a missing `playwright` package OR a missing browser binary OR any
navigation error simply returns None (the caller then tries og:image)."""
try:
from playwright.sync_api import sync_playwright
except Exception: # noqa: BLE001 — ImportError or a broken partial install
return None
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
try:
page = browser.new_page()
page.set_default_timeout(GOTO_TIMEOUT_MS)
page.goto(embed_url, timeout=GOTO_TIMEOUT_MS, wait_until="networkidle")
page.wait_for_timeout(SETTLE_MS)
return page.screenshot(full_page=True)
finally:
browser.close()
except Exception: # noqa: BLE001 — never let a browser hiccup break a save
return None
def _fetch_public_bytes(url: str, *, client: httpx.Client, resolver,
max_bytes: int = IMAGE_MAX_BYTES,
timeout: float = IMAGE_TIMEOUT) -> bytes:
"""SSRF-guarded synchronous GET (mirror of services.ssrf.fetch, sync).
Validates https + all-public-IP before every hop, does not auto-follow
redirects (each Location is re-validated), and caps the body. Raises
ssrf.SSRFError on any policy violation."""
current = url
for _ in range(ssrf.MAX_REDIRECTS + 1):
ssrf.validate_url(current, resolver=resolver)
with client.stream("GET", current, follow_redirects=False,
timeout=timeout) as resp:
if resp.status_code in (301, 302, 303, 307, 308):
loc = resp.headers.get("location")
if not loc:
raise ssrf.SSRFError("redirect without Location")
current = urljoin(current, loc)
continue
body = b""
for chunk in resp.iter_bytes():
body += chunk
if len(body) > max_bytes:
raise ssrf.SSRFError(f"response exceeds {max_bytes} bytes")
return body
raise ssrf.SSRFError("too many redirects")
def _og_image_bytes(og_image_url: str, *, client: httpx.Client | None, resolver) -> bytes | None:
own_client = client is None
c = client or httpx.Client(headers={"user-agent": "billets/0.1 (+secubox)"})
try:
return _fetch_public_bytes(og_image_url, client=c, resolver=resolver)
except (ssrf.SSRFError, httpx.HTTPError, OSError):
return None
finally:
if own_client:
c.close()
def capture(embed_url: str, og_image_url: str | None, media_id: str, *,
client: httpx.Client | None = None,
resolver=ssrf._default_resolver,
directory: Path | None = None,
enable_browser: bool | None = None) -> tuple[str, str] | None:
"""Capture a vignette of `embed_url`; store it via services.media.
Returns (filename, thumb_filename) on success, or None if neither the
headless screenshot nor the og:image fallback yielded a usable image."""
raw: bytes | None = None
if _browser_enabled(enable_browser):
raw = _screenshot_via_browser(embed_url)
if raw is None and og_image_url:
raw = _og_image_bytes(og_image_url, client=client, resolver=resolver)
if not raw:
return None
try:
processed = media.process(raw)
except media.MediaError:
return None
return media.store(media_id, processed, directory)

View File

@ -0,0 +1,69 @@
/* SPDX-License-Identifier: LicenseRef-CMSD-1.0 */
/* billets :: communiqué (affiche) look loaded ONLY on communiqué permalinks
(page-specific <link>), so body-level rules here are safe. Palette + layout
mirror reference/communique-mockup.html: ROOTMESHMIND side-band, poster
frame labelled "TRANSMISSION · CANAL PUBLIC", stamp/kicker/slogans/footer. */
:root{
--c-bg:#FFFFFF; --c-surf:#FAFAF8; --c-surf2:#F4F3EF; --c-border:#E2E0D8;
--c-text:#1A1A18; --c-muted:#6B6963;
--c-auth:#C04E24; --c-wall:#9A6010; --c-boot:#803018;
--c-mind:#3D35A0; --c-root:#0A5840; --c-mesh:#104A88;
--c-sans:'Space Grotesk',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
--c-mono:'JetBrains Mono',ui-monospace,'SFMono-Regular',Menlo,Consolas,monospace;
}
body{background:var(--c-surf);color:var(--c-text);font-family:var(--c-sans);
position:relative;padding-left:6px;-webkit-font-smoothing:antialiased}
body::before{content:"";position:fixed;left:0;top:0;bottom:0;width:6px;
background:linear-gradient(180deg,var(--c-root),var(--c-mesh),var(--c-mind));z-index:10}
.site-header,.site-footer{background:transparent;border-color:var(--c-border)}
.site-title{color:var(--c-text)}
.tagline{color:var(--c-muted);font-family:var(--c-mono)}
.communique{max-width:720px;margin:0 auto;padding:24px 8px 8px}
.communique .stamp{display:inline-block;border:2px solid var(--c-auth);color:var(--c-auth);
font-family:var(--c-mono);font-size:11px;font-weight:700;letter-spacing:.28em;
text-transform:uppercase;padding:6px 14px;transform:rotate(-2deg);margin-bottom:24px}
.communique h1{font-size:clamp(28px,5.5vw,48px);font-weight:700;line-height:1.04;
letter-spacing:-.02em;text-transform:uppercase;margin-bottom:12px;color:var(--c-text)}
.communique .kicker{font-family:var(--c-mono);font-size:12px;color:var(--c-muted);
letter-spacing:.14em;text-transform:uppercase;margin-bottom:28px}
.communique .kicker b{color:var(--c-root);font-weight:700}
.communique .comm-body{font-size:17px;line-height:1.6;margin-bottom:28px;color:var(--c-text)}
.communique .comm-body a{color:var(--c-mesh)}
/* poster frame around the embed / vignette */
.communique .frame{background:var(--c-bg);border:1px solid var(--c-border);
padding:14px;position:relative;margin:8px 0 8px;
box-shadow:8px 8px 0 var(--c-surf2),8px 8px 0 1px var(--c-border)}
.communique .frame-legend{position:absolute;top:-9px;left:16px;background:var(--c-surf);
padding:0 8px;font-family:var(--c-mono);font-size:10px;letter-spacing:.2em;
color:var(--c-mesh);font-weight:700}
.communique .frame iframe{display:block;width:100%;max-width:504px;margin:0 auto;border:0}
.communique .comm-vignette-btn{display:block;width:100%;padding:0;border:0;
background:none;cursor:pointer;position:relative;line-height:0}
.communique .comm-vignette{display:block;width:100%;height:auto;margin:0 auto}
.communique .comm-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
font-family:var(--c-mono);font-size:12px;font-weight:700;letter-spacing:.14em;
color:#fff;background:rgba(16,74,136,.88);padding:10px 16px;line-height:1.2}
.communique .comm-vignette-btn:hover .comm-play{background:rgba(61,53,160,.92)}
.communique .comm-embed-live iframe{display:block;width:100%;max-width:504px;margin:0 auto;border:0}
.communique .slogans{margin-top:36px;display:grid;gap:0;border-top:1px solid var(--c-border)}
.communique .slogans p{font-family:var(--c-mono);font-size:12px;padding:12px 0;
border-bottom:1px solid var(--c-border);letter-spacing:.06em;color:var(--c-muted)}
.communique .slogans p::before{content:"▸ ";font-weight:700}
.communique .slogans p:nth-child(1)::before{color:var(--c-auth)}
.communique .slogans p:nth-child(2)::before{color:var(--c-wall)}
.communique .slogans p:nth-child(3)::before{color:var(--c-boot)}
.communique .slogans p:nth-child(4)::before{color:var(--c-mind)}
.communique .slogans p:nth-child(5)::before{color:var(--c-root)}
.communique .slogans p:nth-child(6)::before{color:var(--c-mesh)}
.communique .slogans p strong{color:var(--c-text)}
.communique .comm-footer{margin-top:28px;font-family:var(--c-mono);font-size:10px;
color:var(--c-muted);letter-spacing:.18em;text-transform:uppercase;display:flex;
justify-content:space-between;flex-wrap:wrap;gap:8px}
.communique .billet-meta{margin-top:16px;font-family:var(--c-mono);font-size:11px}
.communique .billet-meta a{color:var(--c-mesh)}
.communique .ref-chip{display:inline-block;font-family:var(--c-mono);font-size:11px;
color:var(--c-mesh);border:1px solid var(--c-border);padding:3px 8px;margin:4px 0}
@media (max-width:560px){.communique{padding:16px 4px 4px}}

View File

@ -157,3 +157,12 @@ body.lb-lock{overflow:hidden}
.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}}
/* ── communiqué feed vignette (compact framed snapshot) ───────────────── */
.embed-vignette{display:block;margin:.75rem 0;padding:10px;line-height:0;
border:1px solid rgba(255,255,255,.10);background:#0d1117;border-radius:8px;
box-shadow:5px 5px 0 rgba(255,255,255,.04);position:relative;max-width:520px}
.embed-vignette::before{content:"TRANSMISSION · CANAL PUBLIC";position:absolute;
top:-8px;left:14px;background:#0d1117;padding:0 6px;font-size:9px;
letter-spacing:.2em;color:#00d4ff;font-weight:700}
.embed-vignette img{width:100%;height:auto;display:block;border-radius:4px}

View File

@ -59,6 +59,27 @@
});
})();
// Communiqué embed: the poster shows a still snapshot vignette; clicking it
// swaps in the real (already-sanitized) embed iframe held in a <template>. CSP
// is script-src 'self' — this delegated handler lives here, no inline JS. With
// JS off the vignette stays (still a valid poster); the permalink still links out.
(function () {
"use strict";
document.addEventListener("click", function (e) {
var btn = e.target.closest && e.target.closest("[data-load-embed]");
if (!btn) return;
e.preventDefault();
var frame = btn.closest(".comm-embed");
if (!frame) return;
var tpl = frame.querySelector("template.comm-embed-html");
if (!tpl || !("content" in tpl)) return;
var holder = document.createElement("div");
holder.className = "comm-embed-live";
holder.appendChild(tpl.content.cloneNode(true));
btn.replaceWith(holder);
});
})();
// 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).

View File

@ -48,6 +48,7 @@
<div class="row-actions">
<span class="meta">👁️ {{ b.view_count }}</span>
<a class="btn sm" href="/b/{{ b.slug }}" target="_blank">🌐</a>
<a class="btn sm" href="/admin/billets/{{ b.id }}/archive.html" title="Archive HTML autonome">💾</a>
<form method="post" action="/admin/billets/{{ b.id }}/delete" onsubmit="return confirm('🗑️ Supprimer ce billet ?')">
<input type="hidden" name="csrf" value="{{ csrf }}"><button type="submit" class="btn danger sm">🗑️</button></form>
</div>

View File

@ -33,6 +33,14 @@
<label class="muted"><input type="radio" name="url_kind" value="embed"
{{ 'checked' if billet and billet.embed_url else '' }}> 🎬 embed média</label>
</div></div>
<div class="form-group"><label>Style d'affichage</label>
<div class="form-actions">
<label class="muted"><input type="radio" name="style" value="default"
{{ 'checked' if not billet or billet.style != 'communique' else '' }}> 🗒️ Default</label>
<label class="muted"><input type="radio" name="style" value="communique"
{{ 'checked' if billet and billet.style == 'communique' else '' }}> 📡 Communiqué</label>
</div>
<small class="muted">Communiqué = look affiche/poster avec vignette de l'embed (capture ou og:image).</small></div>
<div class="form-actions">
{% if billet %}
<button type="submit" name="action" value="save" class="btn">💾 Enregistrer</button>
@ -40,6 +48,7 @@
<button type="submit" name="action" value="archive" class="btn">🗄️ Archiver</button>
{% if billet.embed_url %}
<button type="submit" name="action" value="save" class="btn" formaction="/admin/billets/{{ billet.id }}/refetch">🔄 Re-fetch embed</button>{% endif %}
<a class="btn" href="/admin/billets/{{ billet.id }}/archive.html">💾 Archive HTML</a>
{% else %}
<button type="submit" name="action" value="draft" class="btn">📄 Brouillon</button>
<button type="submit" name="action" value="publish" class="btn primary">🚀 Publier</button>

View File

@ -0,0 +1,112 @@
{% extends "base.html" %}
{% block title %}{{ site_title }} — {{ billet.title }}{% endblock %}
{% block head %}
<link rel="canonical" href="/b/{{ billet.slug }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/billets-communique.css?v=1">
{% if og %}
<meta property="og:type" content="article">
<meta property="og:site_name" content="{{ site_title }}">
<meta property="og:title" content="{{ og.title }}">
<meta property="og:description" content="{{ og.desc }}">
<meta property="og:url" content="{{ og.url }}">
{% 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 communique">
<div class="stamp">Communiqué officiel</div>
<h1 class="p-name">{{ billet.title }}</h1>
<p class="kicker">CYBERMIND · SECUBOX-DEB · <b>SOUVERAINETÉ PAR CONSTRUCTION</b></p>
<div class="e-content comm-body">{{ 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 %}
{% if billet.embed_snapshot_url %}
<div class="frame comm-embed">
<span class="frame-legend">TRANSMISSION · CANAL PUBLIC</span>
<button type="button" class="comm-vignette-btn" data-load-embed
aria-label="Charger la transmission en direct">
<img class="comm-vignette" src="{{ billet.embed_snapshot_url }}"
alt="Aperçu de la transmission — cliquer pour charger" loading="lazy">
<span class="comm-play">▶ CHARGER LA TRANSMISSION</span>
</button>
<template class="comm-embed-html">{{ billet.embed_html|safe }}</template>
</div>
{% else %}
<div class="frame comm-embed">
<span class="frame-legend">TRANSMISSION · CANAL PUBLIC</span>
{{ billet.embed_html|safe }}
</div>
{% endif %}
{% endif %}
<div class="slogans">
<p><strong>AUTH</strong> — l'identité ne se délègue pas.</p>
<p><strong>WALL</strong> — la défense agit hors du chemin.</p>
<p><strong>BOOT</strong> — la confiance commence au démarrage.</p>
<p><strong>MIND</strong> — l'appareil observe, l'humain décide.</p>
<p><strong>ROOT</strong> — le socle est disclosed, jamais dilué.</p>
<p><strong>MESH</strong> — chaque nœud tient la ligne.</p>
</div>
<footer class="comm-footer">
<span>CYBERMIND — NOTRE-DAME-DU-CRUET · SAVOIE</span>
{% if billet.published_at %}<span>{{ billet.published_at[:19]|replace('T',' ') }} UTC</span>{% endif %}
</footer>
<footer class="billet-meta">
<a class="u-url" href="/b/{{ billet.slug }}">permalien</a>
</footer>
</article>
{% if reactions %}{% include "_reactions.html" %}{% endif %}
{% if billet.share %}
{% with share=billet.share, permalink=billet.permalink, title=billet.title %}{% include "_share_quick.html" %}{% endwith %}
{% endif %}
<section id="comments" class="comments">
<h2>Commentaires</h2>
{% if flash == 'pending' %}<p class="note">Merci — votre commentaire est en attente de modération.</p>{% endif %}
{% if flash == 'ok' %}<p class="note">Commentaire publié.</p>{% endif %}
{% if flash == 'rate' %}<p class="note err">Trop de commentaires — réessayez plus tard.</p>{% endif %}
{% for c in comments %}
<article class="comment">
<p class="c-meta"><strong>{{ c.author_name }}</strong>
<time datetime="{{ c.created_at }}">{{ c.created_at[:10] }}</time></p>
<p class="c-body">{{ c.body_html|safe }}</p>
</article>
{% else %}<p class="empty">Aucun commentaire pour l'instant.</p>{% endfor %}
<form method="post" action="/b/{{ billet.slug }}/comment" class="comment-form">
<input type="hidden" name="csrf" value="{{ pcsrf }}">
<input type="hidden" name="ts_token" value="{{ ts_token }}">
<p class="hp"><label>Laissez ce champ vide
<input name="website" tabindex="-1" autocomplete="off"></label></p>
<label>Nom<input name="author_name" required minlength="2" maxlength="40"></label>
<label>Email (optionnel, jamais affiché)<input name="author_email" type="email" maxlength="254"></label>
<label>Commentaire<textarea name="body" rows="4" required minlength="2" maxlength="2000"></textarea></label>
<button type="submit">Envoyer</button>
</form>
</section>
{% endblock %}

View File

@ -16,7 +16,11 @@
</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 %}
{% if b.style == 'communique' and b.embed_snapshot_url %}
<a class="embed-vignette" href="/b/{{ b.slug }}" aria-label="Voir le communiqué">
<img src="{{ b.embed_snapshot_url }}" alt="Aperçu de la transmission" loading="lazy">
</a>
{% elif 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 %}
<footer class="billet-meta">
{% if b.published_at %}<time class="dt-published" datetime="{{ b.published_at }}">{{ b.published_at[:10] }}</time>{% endif %}

View File

@ -20,6 +20,10 @@ dependencies = [
[project.optional-dependencies]
dev = ["pytest==8.3.4", "pytest-asyncio==0.25.0", "anyio==4.7.0"]
# OPTIONAL: real headless-Chromium embed screenshots. Without it, the embed
# snapshot vignette falls back to the page's og:image (see services/snapshot.py).
# After install, fetch the browser binary: `playwright install chromium`.
snapshot = ["playwright==1.58.0"]
[tool.pytest.ini_options]
asyncio_mode = "auto"

View File

@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>COMMUNIQUÉ · CyberMind / SecuBox</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root{
--bg:#FFFFFF; --surf:#FAFAF8; --surf2:#F4F3EF; --border:#E2E0D8;
--text:#1A1A18; --muted:#6B6963;
--auth:#C04E24; --wall:#9A6010; --boot:#803018;
--mind:#3D35A0; --root:#0A5840; --mesh:#104A88;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{background:var(--surf);color:var(--text);
font-family:'Space Grotesk',sans-serif;-webkit-font-smoothing:antialiased}
body{position:relative;min-height:100vh;padding-left:6px}
/* bande latérale ROOT→MESH→MIND */
body::before{content:"";position:fixed;left:0;top:0;bottom:0;width:6px;
background:linear-gradient(180deg,var(--root),var(--mesh),var(--mind));z-index:10}
.wrap{max-width:720px;margin:0 auto;padding:48px 24px 64px}
/* en-tête communiqué */
.stamp{display:inline-block;border:2px solid var(--auth);color:var(--auth);
font-family:'JetBrains Mono',monospace;font-size:11px;font-weight:700;
letter-spacing:.28em;text-transform:uppercase;padding:6px 14px;
transform:rotate(-2deg);margin-bottom:24px}
h1{font-size:clamp(30px,6vw,52px);font-weight:700;line-height:1.02;
letter-spacing:-.02em;text-transform:uppercase;margin-bottom:12px}
h1 .accent{color:var(--mind)}
.kicker{font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--muted);
letter-spacing:.14em;text-transform:uppercase;margin-bottom:40px}
.kicker b{color:var(--root);font-weight:700}
/* cadre affiche autour de l'embed */
.frame{background:var(--bg);border:1px solid var(--border);
padding:14px;position:relative;
box-shadow:8px 8px 0 var(--surf2), 8px 8px 0 1px var(--border)}
.frame::before{content:"TRANSMISSION · CANAL PUBLIC";
position:absolute;top:-9px;left:16px;background:var(--surf);
padding:0 8px;font-family:'JetBrains Mono',monospace;font-size:10px;
letter-spacing:.2em;color:var(--mesh);font-weight:700}
.frame iframe{display:block;width:100%;max-width:504px;height:1149px;
margin:0 auto;border:0}
/* pied de propagande */
.slogans{margin-top:40px;display:grid;gap:0;border-top:1px solid var(--border)}
.slogans p{font-family:'JetBrains Mono',monospace;font-size:12px;
padding:12px 0;border-bottom:1px solid var(--border);
letter-spacing:.06em;color:var(--muted)}
.slogans p::before{content:"▸ ";font-weight:700}
.slogans p:nth-child(1)::before{color:var(--auth)}
.slogans p:nth-child(2)::before{color:var(--wall)}
.slogans p:nth-child(3)::before{color:var(--boot)}
.slogans p:nth-child(4)::before{color:var(--mind)}
.slogans p:nth-child(5)::before{color:var(--root)}
.slogans p:nth-child(6)::before{color:var(--mesh)}
.slogans p strong{color:var(--text)}
footer{margin-top:32px;font-family:'JetBrains Mono',monospace;
font-size:10px;color:var(--muted);letter-spacing:.18em;text-transform:uppercase;
display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
@media (max-width:560px){
.frame iframe{height:1149px}
.wrap{padding:32px 14px 48px}
}
@media (prefers-reduced-motion:no-preference){
.frame{transition:transform .25s ease}
.frame:hover{transform:translate(-2px,-2px)}
}
</style>
</head>
<body>
<div class="wrap">
<div class="stamp">Communiqué officiel</div>
<h1>La transmission<br>est <span class="accent">en ligne</span></h1>
<p class="kicker">CYBERMIND · SECUBOX-DEB · <b>SOUVERAINETÉ PAR CONSTRUCTION</b></p>
<div class="frame">
<iframe src="https://www.linkedin.com/embed/feed/update/urn:li:ugcPost:7480024356081938432"
height="1149" width="504" frameborder="0" allowfullscreen
title="Embedded post"></iframe>
</div>
<div class="slogans">
<p><strong>AUTH</strong> — l'identité ne se délègue pas.</p>
<p><strong>WALL</strong> — la défense agit hors du chemin.</p>
<p><strong>BOOT</strong> — la confiance commence au démarrage.</p>
<p><strong>MIND</strong> — l'appareil observe, l'humain décide.</p>
<p><strong>ROOT</strong> — le socle est disclosed, jamais dilué.</p>
<p><strong>MESH</strong> — chaque nœud tient la ligne.</p>
</div>
<footer>
<span>CYBERMIND — NOTRE-DAME-DU-CRUET · SAVOIE</span>
<span>CMSD-1.0 · SOURCE DISCLOSED</span>
</footer>
</div>
</body>
</html>

View File

@ -0,0 +1,97 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""Portable single-file .html archive: JWT-gated, fully self-contained (CSS
inlined, media + embed vignette as data: URIs), the embed rendered as its
snapshot image LINKING to the original (no live iframe opens offline)."""
import io
import httpx
import pytest_asyncio
from PIL import Image
from api import repo
from api.main import create_app
from api.services import security as sec
PW = "s3cret-pass-123"
NOW = "2026-07-11T12:00:00Z"
def _png() -> bytes:
buf = io.BytesIO()
Image.new("RGB", (48, 48), (200, 78, 36)).save(buf, format="PNG")
return buf.getvalue()
@pytest_asyncio.fixture
async def client(conn, tmp_path, monkeypatch):
monkeypatch.setenv("BILLETS_MEDIA_DIR", str(tmp_path / "media"))
monkeypatch.setenv("BILLETS_SNAPSHOT_BROWSER", "0")
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 _seed_billet(c, conn):
csrf = await _login(c)
await c.post("/admin/billets",
data={"body": "Communiqué archivable", "style": "communique",
"action": "publish", "csrf": csrf},
files={"media_files": ("shot.png", _png(), "image/png")})
row = (await repo.list_all(conn))[0]
# reuse the stored media file as the (pretend) embed snapshot vignette
media_row = (await repo.list_media(conn, row["id"]))[0]
await conn.execute("UPDATE billet SET embed_url=? WHERE id=?",
("https://youtube.com/watch?v=z", row["id"]))
await conn.commit()
await repo.set_embed_snapshot(conn, row["id"], media_row["filename"])
return row
async def test_archive_requires_auth(client):
c, conn, _ = client
row = await _seed_billet(c, conn)
# drop the session cookie → must be redirected to login
c.cookies.delete("billets_session")
r = await c.get(f"/admin/billets/{row['id']}/archive.html")
assert r.status_code == 303 and r.headers["location"] == "/admin/login"
async def test_archive_is_self_contained_html(client):
c, conn, _ = client
row = await _seed_billet(c, conn)
r = await c.get(f"/admin/billets/{row['id']}/archive.html")
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/html")
assert f'filename="billet-{row["slug"]}.html"' in r.headers.get("content-disposition", "")
body = r.text
assert body.lstrip().startswith("<!DOCTYPE html")
assert "<style>" in body # CSS inlined, no external sheet
assert "fonts.googleapis.com" not in body # offline: system fonts only
assert "data:image/png;base64," in body # media + vignette inlined
# embed = snapshot image LINKING to the original; never a live iframe
assert "<iframe" not in body
assert 'href="https://youtube.com/watch?v=z"' in body
assert "TRANSMISSION" in body # communiqué frame label
async def test_archive_missing_billet_redirects(client):
c, conn, _ = client
await _login(c)
r = await c.get("/admin/billets/01DOESNOTEXIST00000000000A/archive.html")
assert r.status_code == 303 and r.headers["location"] == "/admin"

View File

@ -0,0 +1,108 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""Per-billet communiqué style: stored on create, permalink picks the poster
template (with the vignette when a snapshot exists); default billets unchanged."""
import httpx
import pytest_asyncio
from api import repo
from api.main import create_app
from api.services import security as sec
PW = "s3cret-pass-123"
NOW = "2026-07-11T12:00:00Z"
@pytest_asyncio.fixture
async def client(conn, tmp_path, monkeypatch):
monkeypatch.setenv("BILLETS_MEDIA_DIR", str(tmp_path / "media"))
monkeypatch.setenv("BILLETS_SNAPSHOT_BROWSER", "0") # no browser launch in tests
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
# 204 → fetch_og_image finds no og:image → capture yields no snapshot (fast).
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_create_communique_stores_style(client):
c, conn, _ = client
csrf = await _login(c)
r = await c.post("/admin/billets",
data={"body": "Communiqué de presse", "style": "communique",
"action": "publish", "csrf": csrf})
assert r.status_code == 303
row = (await repo.list_all(conn))[0]
assert row["style"] == "communique"
async def test_permalink_renders_communique_template(client):
c, conn, _ = client
csrf = await _login(c)
await c.post("/admin/billets",
data={"body": "La transmission est en ligne", "style": "communique",
"action": "publish", "csrf": csrf})
row = (await repo.list_all(conn))[0]
# attach a resolved embed + a (pretend) snapshot vignette so the poster frame
# shows the click-to-load image
await conn.execute(
"UPDATE billet SET embed_html=?, embed_url=?, embed_snapshot=? WHERE id=?",
('<iframe src="https://www.youtube.com/embed/z" sandbox="allow-scripts"></iframe>',
"https://youtube.com/watch?v=z", "01SNAP0000000000000000000A.png", row["id"]))
await conn.commit()
r = await c.get(f"/b/{row['slug']}")
assert r.status_code == 200
assert "TRANSMISSION" in r.text # poster frame legend
assert "Communiqué officiel" in r.text # stamp
assert "billets-communique.css" in r.text # poster stylesheet
assert "01SNAP0000000000000000000A.png" in r.text # vignette img
assert 'class="comm-vignette"' in r.text
assert "data-load-embed" in r.text # click-to-load hook
# communiqué CSP relaxes fonts only (no script widening)
csp = r.headers["Content-Security-Policy"]
assert "fonts.googleapis.com" in csp and "fonts.gstatic.com" in csp
async def test_communique_without_snapshot_renders_live_embed(client):
c, conn, _ = client
csrf = await _login(c)
await c.post("/admin/billets",
data={"body": "direct", "style": "communique",
"action": "publish", "csrf": csrf})
row = (await repo.list_all(conn))[0]
await conn.execute(
"UPDATE billet SET embed_html=?, embed_url=? WHERE id=?",
('<iframe src="https://www.youtube.com/embed/z" sandbox="allow-scripts"></iframe>',
"https://youtube.com/watch?v=z", row["id"]))
await conn.commit()
r = await c.get(f"/b/{row['slug']}")
assert r.status_code == 200
assert "youtube.com/embed/z" in r.text # live iframe rendered
assert "comm-vignette" not in r.text # no vignette (none stored)
async def test_default_billet_renders_default_template(client):
c, conn, _ = client
csrf = await _login(c)
await c.post("/admin/billets",
data={"body": "billet ordinaire", "action": "publish", "csrf": csrf})
row = (await repo.list_all(conn))[0]
assert row["style"] == "default"
r = await c.get(f"/b/{row['slug']}")
assert r.status_code == 200
assert "Communiqué officiel" not in r.text
assert "billets-communique.css" not in r.text
assert "permalien" in r.text # normal billet chrome

View File

@ -0,0 +1,93 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""Embed snapshot capture: headless-Chromium path is OPTIONAL, so with the
browser simulated absent it must fall back to the SSRF-guarded og:image, and
must never fetch a private/non-public og:image."""
import io
import httpx
from PIL import Image
from api.services import snapshot
def _png_bytes(size=(320, 240)) -> bytes:
buf = io.BytesIO()
Image.new("RGB", size, (12, 74, 136)).save(buf, format="PNG")
return buf.getvalue()
def _resolver(mapping=None):
mapping = mapping or {}
def r(host, port):
return [(0, 0, 0, "", (mapping.get(host, "93.184.216.34"), port))]
return r
def test_capture_falls_back_to_og_image(tmp_path, monkeypatch):
# Simulate playwright absent / no browser binary → screenshot returns None.
monkeypatch.setattr(snapshot, "_screenshot_via_browser", lambda url: None)
png = _png_bytes()
def handler(request):
assert request.url.host == "cdn.example"
return httpx.Response(200, content=png, headers={"content-type": "image/png"})
client = httpx.Client(transport=httpx.MockTransport(handler))
res = snapshot.capture(
"https://site.example/post", "https://cdn.example/og.png",
"01SNAP0000000000000000000A", client=client, resolver=_resolver(),
directory=tmp_path, enable_browser=True)
client.close()
assert res is not None
filename, thumb = res
assert (tmp_path / filename).exists() and (tmp_path / thumb).exists()
# stored file is a valid, re-encoded image (EXIF-stripped by media.process)
Image.open(tmp_path / filename).verify()
def test_capture_returns_none_when_both_fail(tmp_path, monkeypatch):
monkeypatch.setattr(snapshot, "_screenshot_via_browser", lambda url: None)
def handler(request):
return httpx.Response(200, content=b"not an image at all",
headers={"content-type": "text/plain"})
client = httpx.Client(transport=httpx.MockTransport(handler))
res = snapshot.capture(
"https://site.example/post", "https://cdn.example/og.png",
"01SNAP0000000000000000000B", client=client, resolver=_resolver(),
directory=tmp_path, enable_browser=True)
client.close()
assert res is None
assert list(tmp_path.iterdir()) == []
def test_capture_returns_none_without_og_and_no_browser(tmp_path, monkeypatch):
monkeypatch.setattr(snapshot, "_screenshot_via_browser", lambda url: None)
res = snapshot.capture(
"https://site.example/post", None, "01SNAP0000000000000000000D",
resolver=_resolver(), directory=tmp_path, enable_browser=True)
assert res is None
def test_capture_ssrf_rejects_private_og(tmp_path, monkeypatch):
monkeypatch.setattr(snapshot, "_screenshot_via_browser", lambda url: None)
calls = {"n": 0}
def handler(request):
calls["n"] += 1
return httpx.Response(200, content=_png_bytes(),
headers={"content-type": "image/png"})
client = httpx.Client(transport=httpx.MockTransport(handler))
res = snapshot.capture(
"https://site.example/post", "https://intra.example/og.png",
"01SNAP0000000000000000000C", client=client,
resolver=_resolver({"intra.example": "10.0.0.5"}),
directory=tmp_path, enable_browser=True)
client.close()
assert res is None
assert calls["n"] == 0 # SSRF blocked BEFORE any network fetch
assert list(tmp_path.iterdir()) == []