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_FILE` | `/etc/secubox/secrets/billets` | signing secret (0600 secubox) |
|
||||||
| `BILLETS_SECRET` | — | secret override (takes precedence) |
|
| `BILLETS_SECRET` | — | secret override (takes precedence) |
|
||||||
| `BILLETS_SITE_URL` | derived from request | absolute origin for feeds/oEmbed |
|
| `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
|
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).
|
`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)
|
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 (
|
return (
|
||||||
"default-src 'self'; img-src 'self' https: data:; "
|
"default-src 'self'; img-src 'self' https: data:; "
|
||||||
"style-src 'self'; script-src 'self'; base-uri 'none'; "
|
f"{style_src}; script-src 'self'; base-uri 'none'; "
|
||||||
f"form-action 'self'; frame-ancestors 'none'; frame-src {frame_src}"
|
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["permalink"] = f"{base}/b/{d['slug']}" if base else f"/b/{d['slug']}"
|
||||||
d["share"] = _share_intents(d["permalink"], title) if base else {}
|
d["share"] = _share_intents(d["permalink"], title) if base else {}
|
||||||
d["media"] = [dict(m) for m in (media_rows or [])]
|
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
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -191,7 +201,8 @@ def create_app(conn: aiosqlite.Connection | None = None, *, secret: str | None =
|
||||||
base = _base(request)
|
base = _base(request)
|
||||||
permalink_url = f"{base}/b/{row['slug']}"
|
permalink_url = f"{base}/b/{row['slug']}"
|
||||||
media_rows = await repo.list_media(app.state.conn, row["id"])
|
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,
|
"site_title": SITE_TITLE, "tagline": SITE_TAGLINE,
|
||||||
"billet": _billet_view(row, base, media_rows), "reactions": rctx,
|
"billet": _billet_view(row, base, media_rows), "reactions": rctx,
|
||||||
"comments": comment_views, "pcsrf": pcsrf,
|
"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"),
|
secure=(request.headers.get("x-forwarded-proto", request.url.scheme) == "https"),
|
||||||
max_age=31536000, path="/")
|
max_age=31536000, path="/")
|
||||||
# A self-hosted embed (Mastodon/PeerTube) needs its instance host in
|
# 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"]:
|
if row["embed_html"] and row["embed_url"]:
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
host = urlparse(row["embed_url"]).hostname
|
host = urlparse(row["embed_url"]).hostname
|
||||||
if host and not any(host == d or host.endswith("." + d) for d in _FRAME_HOSTS):
|
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
|
return resp
|
||||||
|
|
||||||
async def _feed_rows() -> list[aiosqlite.Row]:
|
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 enum
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated, Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, field_validator
|
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)]
|
body: Annotated[str, StringConstraints(min_length=1, max_length=BODY_MAX)]
|
||||||
ref_url: Optional[_HttpsUrl] = None
|
ref_url: Optional[_HttpsUrl] = None
|
||||||
embed_url: Optional[_HttpsUrl] = None
|
embed_url: Optional[_HttpsUrl] = None
|
||||||
|
style: Literal["default", "communique"] = "default"
|
||||||
publish: bool = False
|
publish: bool = False
|
||||||
|
|
||||||
@field_validator("ref_url", "embed_url")
|
@field_validator("ref_url", "embed_url")
|
||||||
|
|
@ -120,6 +121,8 @@ class Billet(BaseModel):
|
||||||
embed_fetched_at: Optional[str] = None
|
embed_fetched_at: Optional[str] = None
|
||||||
slug: str
|
slug: str
|
||||||
status: BilletStatus
|
status: BilletStatus
|
||||||
|
style: str = "default"
|
||||||
|
embed_snapshot: Optional[str] = None
|
||||||
view_count: int = 0
|
view_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ from .ids import new_ulid
|
||||||
from .models import BilletIn, slugify
|
from .models import BilletIn, slugify
|
||||||
|
|
||||||
_FEED_COLUMNS = ("id,created_at,updated_at,published_at,body,ref_url,embed_url,"
|
_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:
|
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
|
published_at = now if data.publish else None
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"INSERT INTO billet(id,created_at,updated_at,published_at,body,ref_url,"
|
"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,
|
(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()
|
await conn.commit()
|
||||||
return billet_id
|
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,
|
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."""
|
"""Update content (slug/permalink stays stable) + bump updated_at."""
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"UPDATE billet SET body=?, ref_url=?, embed_url=?, updated_at=? WHERE id=?",
|
"UPDATE billet SET body=?, ref_url=?, embed_url=?, style=?, updated_at=? WHERE id=?",
|
||||||
(body, ref_url, embed_url, now, billet_id),
|
(body, ref_url, embed_url, style, now, billet_id),
|
||||||
)
|
)
|
||||||
await conn.commit()
|
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:
|
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)."""
|
"""Change status. Publishing sets published_at once (never overwrites it)."""
|
||||||
if status == "published":
|
if status == "published":
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ from pydantic import ValidationError
|
||||||
from .. import repo
|
from .. import repo
|
||||||
from ..ids import new_ulid
|
from ..ids import new_ulid
|
||||||
from ..models import BilletIn
|
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 security as sec
|
||||||
from ..services import ssrf as ssrf_mod
|
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"],
|
"created_at": billet_row["created_at"], "updated_at": billet_row["updated_at"],
|
||||||
"published_at": billet_row["published_at"], "ref_url": billet_row["ref_url"],
|
"published_at": billet_row["published_at"], "ref_url": billet_row["ref_url"],
|
||||||
"embed_url": billet_row["embed_url"], "embed_provider": billet_row["embed_provider"],
|
"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}"
|
msg = f"{event_type} {billet_row['slug']} by {actor}"
|
||||||
await asyncio.to_thread(revisions.commit_revision, repo_dir, billet_row["id"],
|
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))
|
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:
|
def register_admin(app: FastAPI, templates: Jinja2Templates) -> None:
|
||||||
login_limiter = sec.RateLimiter(max_events=5, window_s=3600)
|
login_limiter = sec.RateLimiter(max_events=5, window_s=3600)
|
||||||
app.state.login_limiter = login_limiter
|
app.state.login_limiter = login_limiter
|
||||||
|
|
@ -250,7 +282,8 @@ def register_admin(app: FastAPI, templates: Jinja2Templates) -> None:
|
||||||
|
|
||||||
@app.post("/admin/billets")
|
@app.post("/admin/billets")
|
||||||
async def create(request: Request, body: str = Form(...), url: str = Form(""),
|
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=[])):
|
csrf: str = Form(""), media_files: list[UploadFile] = File(default=[])):
|
||||||
author = await _current_author(request)
|
author = await _current_author(request)
|
||||||
if author is None:
|
if author is None:
|
||||||
|
|
@ -260,7 +293,7 @@ def register_admin(app: FastAPI, templates: Jinja2Templates) -> None:
|
||||||
ref_url, embed_url = _urls(url, url_kind)
|
ref_url, embed_url = _urls(url, url_kind)
|
||||||
try:
|
try:
|
||||||
data = BilletIn(body=body, ref_url=ref_url, embed_url=embed_url,
|
data = BilletIn(body=body, ref_url=ref_url, embed_url=embed_url,
|
||||||
publish=(action == "publish"))
|
style=style, publish=(action == "publish"))
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
resp = templates.TemplateResponse(request, "admin_edit.html",
|
resp = templates.TemplateResponse(request, "admin_edit.html",
|
||||||
{"billet": None, "error": "Entrée invalide (corps ou URL https).",
|
{"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))
|
billet_id = await repo.create_billet(request.app.state.conn, data, now=_now(request))
|
||||||
skipped = await _save_uploads(request, billet_id, media_files)
|
skipped = await _save_uploads(request, billet_id, media_files)
|
||||||
await _resolve_and_store_embed(request, billet_id, data.embed_url)
|
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)
|
row = await repo.get_by_id(request.app.state.conn, billet_id)
|
||||||
event = "billet.published" if data.publish else "billet.edited"
|
event = "billet.published" if data.publish else "billet.edited"
|
||||||
await _log_and_revision(request, event, row, actor=author["username"])
|
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}")
|
@app.post("/admin/billets/{billet_id}")
|
||||||
async def update(request: Request, billet_id: str, body: str = Form(...),
|
async def update(request: Request, billet_id: str, body: str = Form(...),
|
||||||
url: str = Form(""), url_kind: str = Form("ref"),
|
url: str = Form(""), url_kind: str = Form("ref"),
|
||||||
|
style: str = Form("default"),
|
||||||
action: str = Form("save"), csrf: str = Form(""),
|
action: str = Form("save"), csrf: str = Form(""),
|
||||||
media_files: list[UploadFile] = File(default=[])):
|
media_files: list[UploadFile] = File(default=[])):
|
||||||
author = await _current_author(request)
|
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)
|
row = await repo.get_by_id(conn, billet_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
return _redirect("/admin")
|
return _redirect("/admin")
|
||||||
|
prev_embed_url = row["embed_url"]
|
||||||
|
prev_snapshot = row["embed_snapshot"]
|
||||||
ref_url, embed_url = _urls(url, url_kind)
|
ref_url, embed_url = _urls(url, url_kind)
|
||||||
try:
|
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:
|
except ValidationError:
|
||||||
resp = templates.TemplateResponse(request, "admin_edit.html",
|
resp = templates.TemplateResponse(request, "admin_edit.html",
|
||||||
{"billet": dict(row), "error": "Entrée invalide.",
|
{"billet": dict(row), "error": "Entrée invalide.",
|
||||||
"csrf": request.cookies.get(CSRF_COOKIE) or ""})
|
"csrf": request.cookies.get(CSRF_COOKIE) or ""})
|
||||||
return resp
|
return resp
|
||||||
await repo.update_billet(conn, billet_id, body=body, ref_url=ref_url,
|
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)
|
skipped = await _save_uploads(request, billet_id, media_files)
|
||||||
if action in ("publish", "archive"):
|
if action in ("publish", "archive"):
|
||||||
await repo.set_status(conn, billet_id,
|
await repo.set_status(conn, billet_id,
|
||||||
"published" if action == "publish" else "archived",
|
"published" if action == "publish" else "archived",
|
||||||
now=_now(request))
|
now=_now(request))
|
||||||
await _resolve_and_store_embed(request, billet_id, embed_url)
|
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)
|
row = await repo.get_by_id(conn, billet_id)
|
||||||
event = "billet.published" if action == "publish" else "billet.edited"
|
event = "billet.published" if action == "publish" else "billet.edited"
|
||||||
await _log_and_revision(request, event, row, actor=author["username"])
|
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",
|
return Response(content=body, media_type="application/json",
|
||||||
headers={"Content-Disposition": 'attachment; filename="billets.sbxsite"'})
|
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)
|
@app.get("/admin/comments", response_class=HTMLResponse)
|
||||||
async def comments_queue(request: Request):
|
async def comments_queue(request: Request):
|
||||||
author = await _current_author(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"}
|
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
|
async def resolve_embed(url: str, *, client: httpx.AsyncClient, resolver=ssrf._default_resolver
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Full pipeline: oEmbed (allowlist/discovery) then link-card fallback.
|
"""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-nav{top:50%;transform:translateY(-50%);width:2.8rem;height:3.4rem;font-size:1.8rem}
|
||||||
.lb-prev{left:1rem}.lb-next{right:1rem}
|
.lb-prev{left:1rem}.lb-next{right:1rem}
|
||||||
@media(max-width:600px){.lb-nav{width:2.2rem;height:2.8rem;font-size:1.4rem}}
|
@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
|
// Lightbox: click a gallery vignette to view the full image, zoomable, with
|
||||||
// keyboard/arrow navigation within the same billet's gallery. With JS disabled
|
// keyboard/arrow navigation within the same billet's gallery. With JS disabled
|
||||||
// the .gallery-item links open the full image directly (graceful degradation).
|
// the .gallery-item links open the full image directly (graceful degradation).
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<span class="meta">👁️ {{ b.view_count }}</span>
|
<span class="meta">👁️ {{ b.view_count }}</span>
|
||||||
<a class="btn sm" href="/b/{{ b.slug }}" target="_blank">🌐</a>
|
<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 ?')">
|
<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>
|
<input type="hidden" name="csrf" value="{{ csrf }}"><button type="submit" class="btn danger sm">🗑️</button></form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,14 @@
|
||||||
<label class="muted"><input type="radio" name="url_kind" value="embed"
|
<label class="muted"><input type="radio" name="url_kind" value="embed"
|
||||||
{{ 'checked' if billet and billet.embed_url else '' }}> 🎬 embed média</label>
|
{{ 'checked' if billet and billet.embed_url else '' }}> 🎬 embed média</label>
|
||||||
</div></div>
|
</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">
|
<div class="form-actions">
|
||||||
{% if billet %}
|
{% if billet %}
|
||||||
<button type="submit" name="action" value="save" class="btn">💾 Enregistrer</button>
|
<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>
|
<button type="submit" name="action" value="archive" class="btn">🗄️ Archiver</button>
|
||||||
{% if billet.embed_url %}
|
{% if billet.embed_url %}
|
||||||
<button type="submit" name="action" value="save" class="btn" formaction="/admin/billets/{{ billet.id }}/refetch">🔄 Re-fetch embed</button>{% endif %}
|
<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 %}
|
{% else %}
|
||||||
<button type="submit" name="action" value="draft" class="btn">📄 Brouillon</button>
|
<button type="submit" name="action" value="draft" class="btn">📄 Brouillon</button>
|
||||||
<button type="submit" name="action" value="publish" class="btn primary">🚀 Publier</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>
|
</div>
|
||||||
{% endif %}
|
{% 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.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 %}
|
{% with share=b.share, permalink=b.permalink, title=b.title %}{% include "_share_quick.html" %}{% endwith %}
|
||||||
<footer class="billet-meta">
|
<footer class="billet-meta">
|
||||||
{% if b.published_at %}<time class="dt-published" datetime="{{ b.published_at }}">{{ b.published_at[:10] }}</time>{% endif %}
|
{% 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]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest==8.3.4", "pytest-asyncio==0.25.0", "anyio==4.7.0"]
|
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]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
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