diff --git a/packages/secubox-billets/README.md b/packages/secubox-billets/README.md index ab4be748..3554733b 100644 --- a/packages/secubox-billets/README.md +++ b/packages/secubox-billets/README.md @@ -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). diff --git a/packages/secubox-billets/api/main.py b/packages/secubox-billets/api/main.py index c0602822..ba930010 100644 --- a/packages/secubox-billets/api/main.py +++ b/packages/secubox-billets/api/main.py @@ -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
" + '
Communiqué officiel
' + f"

{escape(title)}

" + '

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

" + f'
{body_html}
' + + _embed_block(billet) + + _gallery_block(media_items) + + f'
{slogans}
' + + '" + + "
" + ) 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