mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
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:
parent
bb3c56c2cd
commit
a95f157eb1
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
13
packages/secubox-billets/api/migrations/0003_communique.sql
Normal file
13
packages/secubox-billets/api/migrations/0003_communique.sql
Normal 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;
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
161
packages/secubox-billets/api/services/archive.py
Normal file
161
packages/secubox-billets/api/services/archive.py
Normal 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>"
|
||||
)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
139
packages/secubox-billets/api/services/snapshot.py
Normal file
139
packages/secubox-billets/api/services/snapshot.py
Normal 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)
|
||||
69
packages/secubox-billets/api/static/billets-communique.css
Normal file
69
packages/secubox-billets/api/static/billets-communique.css
Normal 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: 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}}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
112
packages/secubox-billets/api/templates/billet_communique.html
Normal file
112
packages/secubox-billets/api/templates/billet_communique.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
105
packages/secubox-billets/reference/communique-mockup.html
Normal file
105
packages/secubox-billets/reference/communique-mockup.html
Normal 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>
|
||||
97
packages/secubox-billets/tests/test_archive.py
Normal file
97
packages/secubox-billets/tests/test_archive.py
Normal 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"
|
||||
108
packages/secubox-billets/tests/test_communique.py
Normal file
108
packages/secubox-billets/tests/test_communique.py
Normal 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
|
||||
93
packages/secubox-billets/tests/test_snapshot.py
Normal file
93
packages/secubox-billets/tests/test_snapshot.py
Normal 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()) == []
|
||||
Loading…
Reference in New Issue
Block a user