From a95f157eb171b7c08d57ce484fe512d80d511a25 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 14 Jul 2026 07:57:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(billets):=20communiqu=C3=A9=20style=20+=20?= =?UTF-8?q?embed=20snapshot=20vignette=20+=20portable=20HTML=20archive=20(?= =?UTF-8?q?ref=20#851)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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
" + '
Communiqué officiel
' + f"

{escape(title)}

" + '

CYBERMIND · SECUBOX-DEB · ' + "SOUVERAINETÉ PAR CONSTRUCTION

" + f'
{body_html}
' + + _embed_block(billet) + + _gallery_block(media_items) + + f'
{slogans}
' + + '
CYBERMIND — NOTRE-DAME-DU-CRUET · SAVOIE' + + f"{escape(published)} · {escape(host)}
" + + "
" + ) diff --git a/packages/secubox-billets/api/services/linkcard.py b/packages/secubox-billets/api/services/linkcard.py index 6ad1759d..ad04d32b 100644 --- a/packages/secubox-billets/api/services/linkcard.py +++ b/packages/secubox-billets/api/services/linkcard.py @@ -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. diff --git a/packages/secubox-billets/api/services/snapshot.py b/packages/secubox-billets/api/services/snapshot.py new file mode 100644 index 00000000..66c50115 --- /dev/null +++ b/packages/secubox-billets/api/services/snapshot.py @@ -0,0 +1,139 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +""" +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) diff --git a/packages/secubox-billets/api/static/billets-communique.css b/packages/secubox-billets/api/static/billets-communique.css new file mode 100644 index 00000000..98872cbe --- /dev/null +++ b/packages/secubox-billets/api/static/billets-communique.css @@ -0,0 +1,69 @@ +/* SPDX-License-Identifier: LicenseRef-CMSD-1.0 */ +/* billets :: communiqué (affiche) look — loaded ONLY on communiqué permalinks + (page-specific ), so body-level rules here are safe. Palette + layout + mirror reference/communique-mockup.html: ROOT→MESH→MIND 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}} diff --git a/packages/secubox-billets/api/static/billets.css b/packages/secubox-billets/api/static/billets.css index cb4a922b..83a220b7 100644 --- a/packages/secubox-billets/api/static/billets.css +++ b/packages/secubox-billets/api/static/billets.css @@ -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} diff --git a/packages/secubox-billets/api/static/billets.js b/packages/secubox-billets/api/static/billets.js index 1646b4bb..c2a8f485 100644 --- a/packages/secubox-billets/api/static/billets.js +++ b/packages/secubox-billets/api/static/billets.js @@ -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