mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 09:14:33 +00:00
feat(billets): funky/party public webapp — inline media + link chips + quick-share
Public feed + permalink redesigned funky/party (theme-aware gradients, emoji
accents, playful cards). Embeds now render INLINE in the feed too (responsive
16:9-ish media, CSP frame-src augmented for self-hosted hosts). Inline embedded
reference links shown as 🌐 chips. Prominent emoji quick-share row (Bluesky/X/
WhatsApp/Telegram/Reddit/LinkedIn/email + Mastodon/copy via billets.js) on every
billet in the feed and permalink (replaces the hidden Republier details).
Asset URLs versioned (?v=2) to bust the sbxwaf media cache. 90 tests.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
parent
5e77b9ebbd
commit
ac6df22258
|
|
@ -85,6 +85,19 @@ def _csp(frame_src: str) -> str:
|
|||
)
|
||||
|
||||
|
||||
def _extra_frame_hosts(rows) -> tuple[str, ...]:
|
||||
"""Self-hosted embed hosts (Mastodon/PeerTube) among the given billet rows,
|
||||
to add to frame-src beyond the static provider allowlist."""
|
||||
from urllib.parse import urlparse
|
||||
hosts: list[str] = []
|
||||
for r in rows:
|
||||
if r["embed_html"] and r["embed_url"]:
|
||||
h = urlparse(r["embed_url"]).hostname
|
||||
if h and h not in hosts and not any(h == d or h.endswith("." + d) for d in _FRAME_HOSTS):
|
||||
hosts.append(h)
|
||||
return tuple(hosts)
|
||||
|
||||
|
||||
_SECURITY_HEADERS = {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
|
|
@ -93,9 +106,15 @@ _SECURITY_HEADERS = {
|
|||
}
|
||||
|
||||
|
||||
def _billet_view(row: aiosqlite.Row) -> dict:
|
||||
def _billet_view(row: aiosqlite.Row, base: str = "") -> dict:
|
||||
from urllib.parse import urlparse
|
||||
d = dict(row)
|
||||
d["body_html"] = render_markdown(d["body"])
|
||||
d["ref_host"] = (urlparse(d["ref_url"]).hostname if d.get("ref_url") else None)
|
||||
title = feeds.billet_title(d["body"])
|
||||
d["title"] = title
|
||||
d["permalink"] = f"{base}/b/{d['slug']}" if base else f"/b/{d['slug']}"
|
||||
d["share"] = _share_intents(d["permalink"], title) if base else {}
|
||||
return d
|
||||
|
||||
|
||||
|
|
@ -135,10 +154,15 @@ def create_app(conn: aiosqlite.Connection | None = None, *, secret: str | None =
|
|||
@app.get("/", response_class=HTMLResponse)
|
||||
async def feed(request: Request, cursor: str | None = None):
|
||||
rows, next_cursor = await repo.list_published(app.state.conn, limit=PAGE_SIZE, cursor=cursor)
|
||||
return templates.TemplateResponse(request, "feed.html", {
|
||||
base = _base(request)
|
||||
resp = templates.TemplateResponse(request, "feed.html", {
|
||||
"site_title": SITE_TITLE, "tagline": SITE_TAGLINE,
|
||||
"billets": [_billet_view(r) for r in rows], "next_cursor": next_cursor,
|
||||
"billets": [_billet_view(r, base) for r in rows], "next_cursor": next_cursor,
|
||||
})
|
||||
# Embeds render inline in the feed too; allow any self-hosted embed hosts
|
||||
# of the shown billets in frame-src (static providers already covered).
|
||||
resp.headers["Content-Security-Policy"] = _csp(_frame_src(_extra_frame_hosts(rows)))
|
||||
return resp
|
||||
|
||||
@app.get("/b/{slug}", response_class=HTMLResponse)
|
||||
async def permalink(request: Request, slug: str):
|
||||
|
|
@ -157,7 +181,7 @@ def create_app(conn: aiosqlite.Connection | None = None, *, secret: str | None =
|
|||
permalink_url = f"{base}/b/{row['slug']}"
|
||||
resp = templates.TemplateResponse(request, "billet.html", {
|
||||
"site_title": SITE_TITLE, "tagline": SITE_TAGLINE,
|
||||
"billet": _billet_view(row), "reactions": rctx,
|
||||
"billet": _billet_view(row, base), "reactions": rctx,
|
||||
"comments": comment_views, "pcsrf": pcsrf,
|
||||
"ts_token": ts_token, "flash": request.query_params.get("c"),
|
||||
"og": {"title": feeds.billet_title(row["body"]),
|
||||
|
|
|
|||
|
|
@ -1,76 +1,131 @@
|
|||
/* SPDX-License-Identifier: LicenseRef-CMSD-1.0 */
|
||||
/* billets — funky / party public theme, theme-aware, emoji-friendly */
|
||||
:root{
|
||||
--bg:#fbfbfa; --fg:#1a1a1f; --muted:#6b6b7a; --accent:#0a5840; --border:#e6e6e0;
|
||||
--card:#ffffff; --link:#0a5840;
|
||||
--bg:#fff7fb; --fg:#1c1330; --muted:#7a6a90; --card:#ffffff;
|
||||
--border:#f0e2f2; --link:#c0246b;
|
||||
--p1:#7c3aed; --p2:#ec4899; --p3:#06b6d4; --p4:#f59e0b;
|
||||
--grad:linear-gradient(120deg,#7c3aed,#ec4899 45%,#f59e0b);
|
||||
}
|
||||
@media (prefers-color-scheme:dark){
|
||||
:root{ --bg:#0b0f14; --fg:#e8ecf2; --muted:#8592a3; --accent:#2fae86;
|
||||
--border:#1e2a38; --card:#141a24; --link:#4fd1a5; }
|
||||
:root{ --bg:#0d0a18; --fg:#f0ecff; --muted:#9a8ec2; --card:#171226;
|
||||
--border:#271d3f; --link:#ff8ac4;
|
||||
--grad:linear-gradient(120deg,#a855f7,#ec4899 45%,#22d3ee); }
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);
|
||||
font:16px/1.6 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
|
||||
max-width:44rem;margin-inline:auto;padding:0 1rem}
|
||||
a{color:var(--link)}
|
||||
.site-header{display:flex;align-items:baseline;gap:.6rem;padding:1.4rem 0 .8rem;
|
||||
border-bottom:1px solid var(--border);margin-bottom:1.4rem}
|
||||
.site-title{font-weight:700;font-size:1.35rem;text-decoration:none;letter-spacing:-.01em}
|
||||
font:16px/1.65 "Segoe UI",system-ui,-apple-system,Roboto,sans-serif;
|
||||
max-width:46rem;margin-inline:auto;padding:0 1rem;overflow-x:hidden}
|
||||
body::before{content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;
|
||||
background:
|
||||
radial-gradient(60vw 40vw at 90% -5%,rgba(236,72,153,.16),transparent 60%),
|
||||
radial-gradient(55vw 40vw at -10% 10%,rgba(124,58,237,.16),transparent 60%),
|
||||
radial-gradient(50vw 40vw at 50% 110%,rgba(6,182,212,.13),transparent 60%)}
|
||||
a{color:var(--link);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
|
||||
/* header */
|
||||
.site-header{display:flex;align-items:center;gap:.7rem;padding:1.5rem 0 1rem;flex-wrap:wrap}
|
||||
.site-title{font-weight:900;font-size:1.7rem;letter-spacing:-.02em;
|
||||
background:var(--grad);-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.site-title::before{content:"📮 "}
|
||||
.tagline{color:var(--muted);font-size:.85rem}
|
||||
.billet{background:var(--card);border:1px solid var(--border);border-radius:12px;
|
||||
padding:1rem 1.2rem;margin-bottom:1rem}
|
||||
.tagline::after{content:" ✨"}
|
||||
|
||||
/* billet cards */
|
||||
.billet{position:relative;background:var(--card);border:1px solid var(--border);
|
||||
border-radius:20px;padding:1.15rem 1.3rem;margin-bottom:1.15rem;
|
||||
box-shadow:0 10px 30px rgba(124,58,237,.07);transition:transform .16s,box-shadow .16s}
|
||||
.billet:hover{transform:translateY(-3px);box-shadow:0 16px 40px rgba(236,72,153,.13)}
|
||||
.billet::before{content:"";position:absolute;left:0;top:16px;bottom:16px;width:4px;
|
||||
border-radius:4px;background:var(--grad)}
|
||||
.billet .e-content>:first-child{margin-top:0}
|
||||
.billet .e-content>:last-child{margin-bottom:0}
|
||||
.billet-meta{display:flex;gap:.8rem;align-items:center;color:var(--muted);
|
||||
font-size:.8rem;margin-top:.6rem}
|
||||
.billet-full{padding:1.4rem 1.6rem}
|
||||
blockquote{border-left:3px solid var(--border);margin:.6rem 0;padding:.2rem 0 .2rem 1rem;color:var(--muted)}
|
||||
code{background:color-mix(in srgb,var(--border) 60%,transparent);padding:.1em .35em;border-radius:5px;font-size:.9em}
|
||||
.pager{margin:1.2rem 0 2rem}
|
||||
.pager a{text-decoration:none;font-weight:600}
|
||||
.empty{color:var(--muted)}
|
||||
.embed{margin:.8rem 0}
|
||||
.embed iframe{max-width:100%;border:0;border-radius:10px}
|
||||
.site-footer{border-top:1px solid var(--border);margin-top:2rem;padding:1rem 0 2rem;
|
||||
color:var(--muted);font-size:.8rem}
|
||||
.billet-full{padding:1.5rem 1.7rem}
|
||||
blockquote{border-left:3px solid var(--p2);margin:.6rem 0;padding:.2rem 0 .2rem 1rem;color:var(--muted)}
|
||||
code{background:color-mix(in srgb,var(--p1) 14%,transparent);padding:.1em .4em;border-radius:6px;font-size:.9em}
|
||||
.billet-meta{display:flex;gap:.8rem;align-items:center;color:var(--muted);font-size:.8rem;margin-top:.7rem;flex-wrap:wrap}
|
||||
.billet-meta .u-url::before{content:"🔗 "}
|
||||
|
||||
/* inline embedded link chip */
|
||||
.ref-chip{display:inline-flex;align-items:center;gap:.4rem;margin:.5rem 0;padding:.4rem .8rem;
|
||||
border-radius:999px;background:color-mix(in srgb,var(--p3) 12%,transparent);
|
||||
border:1px solid color-mix(in srgb,var(--p3) 40%,transparent);font-size:.85rem;font-weight:600}
|
||||
.ref-chip::before{content:"🌐"}
|
||||
|
||||
/* inline embedded media (oEmbed / link card) */
|
||||
.embed{margin:.9rem 0}
|
||||
.embed iframe{width:100%;max-width:100%;min-height:320px;border:0;border-radius:16px;
|
||||
box-shadow:0 8px 26px rgba(0,0,0,.18);background:#000}
|
||||
.embed blockquote{background:var(--card);border:1px solid var(--border);border-left:3px solid var(--p2);
|
||||
border-radius:14px;padding:1rem;margin:0}
|
||||
.link-card{display:flex;gap:.9rem;align-items:center;border:1px solid var(--border);
|
||||
border-radius:16px;overflow:hidden;background:var(--card);text-decoration:none;color:var(--fg)}
|
||||
.link-card:hover{border-color:var(--p2);text-decoration:none}
|
||||
.link-card .card-img{width:120px;height:90px;object-fit:cover;flex:none}
|
||||
.link-card .card-body{display:flex;flex-direction:column;gap:.15rem;padding:.6rem .3rem}
|
||||
.link-card .card-title{font-weight:700}
|
||||
.link-card .card-desc{color:var(--muted);font-size:.85rem}
|
||||
.link-card .card-host{color:var(--p3);font-size:.75rem}
|
||||
|
||||
/* reactions */
|
||||
.reactions{display:flex;flex-wrap:wrap;gap:.4rem;margin:.6rem 0}
|
||||
.reactions{display:flex;flex-wrap:wrap;gap:.45rem;margin:.8rem 0}
|
||||
.reactions form.react{margin:0}
|
||||
.react-btn{cursor:pointer;background:var(--card);border:1px solid var(--border);
|
||||
border-radius:999px;padding:.2rem .6rem;font-size:.95rem;color:var(--fg)}
|
||||
.react-btn.on{border-color:var(--accent);color:var(--accent);font-weight:600}
|
||||
.react-btn .count{color:var(--muted);font-size:.8rem;margin-left:.2rem}
|
||||
.react-btn.on .count{color:var(--accent)}
|
||||
.react-btn{cursor:pointer;background:var(--card);border:1.5px solid var(--border);border-radius:999px;
|
||||
padding:.3rem .75rem;font-size:1.05rem;color:var(--fg);transition:transform .12s,border-color .12s}
|
||||
.react-btn:hover{transform:scale(1.12) rotate(-4deg)}
|
||||
.react-btn.on{border-color:var(--p2);background:color-mix(in srgb,var(--p2) 12%,transparent);font-weight:700}
|
||||
.react-btn .count{color:var(--muted);font-size:.78rem;margin-left:.25rem}
|
||||
.react-btn.on .count{color:var(--p2)}
|
||||
|
||||
/* quick share */
|
||||
.share-quick{display:flex;flex-wrap:wrap;gap:.4rem;align-items:center;margin:.7rem 0}
|
||||
.share-quick .lbl{color:var(--muted);font-size:.8rem;font-weight:700;margin-right:.1rem}
|
||||
.share-quick a,.share-quick button{cursor:pointer;text-decoration:none;font-size:1.05rem;line-height:1;
|
||||
border:1px solid var(--border);background:var(--card);color:var(--fg);border-radius:999px;
|
||||
padding:.32rem .55rem;transition:transform .12s,border-color .12s}
|
||||
.share-quick a:hover,.share-quick button:hover{transform:translateY(-2px) scale(1.06);border-color:var(--p1)}
|
||||
|
||||
/* comments */
|
||||
.comments{margin-top:1.6rem;border-top:1px solid var(--border);padding-top:1rem}
|
||||
.comment{background:var(--card);border:1px solid var(--border);border-radius:10px;
|
||||
padding:.6rem .9rem;margin-bottom:.6rem}
|
||||
.comments{margin-top:1.8rem;border-top:2px dashed var(--border);padding-top:1.1rem}
|
||||
.comments h2::before{content:"💬 "}
|
||||
.comment{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:.7rem 1rem;margin-bottom:.6rem}
|
||||
.comment .c-meta{margin:0 0 .3rem;font-size:.85rem;color:var(--muted)}
|
||||
.comment .c-body{margin:0;white-space:normal}
|
||||
.comment-form{display:flex;flex-direction:column;gap:.5rem;margin-top:1rem}
|
||||
.comment-form label{display:flex;flex-direction:column;gap:.2rem;font-size:.85rem;color:var(--muted)}
|
||||
.comment-form input,.comment-form textarea{padding:.5rem;border:1px solid var(--border);
|
||||
border-radius:8px;background:var(--bg);color:var(--fg);font:inherit}
|
||||
.comment-form button,.mod-actions button,.admin-form button,.editor-actions button,
|
||||
.row-actions button{cursor:pointer;padding:.4rem .8rem;border:1px solid var(--border);
|
||||
border-radius:8px;background:var(--accent);color:#fff;font:inherit}
|
||||
.danger{background:var(--cinnabar,#c0392b)!important;border-color:transparent!important}
|
||||
.comment .c-meta strong::before{content:"🙂 "}
|
||||
.comment .c-body{margin:0}
|
||||
.comment-form{display:flex;flex-direction:column;gap:.55rem;margin-top:1rem}
|
||||
.comment-form label{display:flex;flex-direction:column;gap:.25rem;font-size:.85rem;color:var(--muted)}
|
||||
.comment-form input,.comment-form textarea{padding:.6rem;border:1.5px solid var(--border);border-radius:12px;
|
||||
background:var(--bg);color:var(--fg);font:inherit}
|
||||
.comment-form input:focus,.comment-form textarea:focus{outline:none;border-color:var(--p2)}
|
||||
.comment-form button,.mod-actions button,.admin-form button,.editor-actions button,.row-actions button{
|
||||
cursor:pointer;padding:.55rem 1rem;border:none;border-radius:12px;background:var(--grad);color:#fff;
|
||||
font:inherit;font-weight:700}
|
||||
.comment-form button:hover{transform:translateY(-2px)}
|
||||
.danger{background:#e11d48!important}
|
||||
.hp{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}
|
||||
.note{background:color-mix(in srgb,var(--accent) 12%,transparent);padding:.5rem .8rem;border-radius:8px}
|
||||
.note.err{background:color-mix(in srgb,#c0392b 14%,transparent)}
|
||||
.note{background:color-mix(in srgb,var(--p1) 13%,transparent);padding:.55rem .9rem;border-radius:12px}
|
||||
.note::before{content:"🎉 "}
|
||||
.note.err{background:color-mix(in srgb,#e11d48 15%,transparent)}
|
||||
.note.err::before{content:"😅 "}
|
||||
|
||||
/* admin */
|
||||
.admin-form{display:flex;flex-direction:column;gap:.6rem;max-width:32rem}
|
||||
.admin-form label{display:flex;flex-direction:column;gap:.2rem}
|
||||
.admin-form input,.admin-form textarea{padding:.5rem;border:1px solid var(--border);
|
||||
border-radius:8px;background:var(--bg);color:var(--fg);font:inherit}
|
||||
/* pager / footer / misc */
|
||||
.pager{margin:1.4rem 0 2.5rem}
|
||||
.pager a{font-weight:800;font-size:1.02rem}
|
||||
.pager a::after{content:" 🕰️"}
|
||||
.empty{color:var(--muted)}
|
||||
.site-footer{border-top:1px solid var(--border);margin-top:2.2rem;padding:1rem 0 2.5rem;color:var(--muted);font-size:.8rem}
|
||||
|
||||
/* admin (kept compact) */
|
||||
.admin-form{display:flex;flex-direction:column;gap:.6rem;max-width:34rem}
|
||||
.admin-form label{display:flex;flex-direction:column;gap:.25rem}
|
||||
.admin-form input,.admin-form textarea{padding:.55rem;border:1.5px solid var(--border);border-radius:12px;background:var(--bg);color:var(--fg);font:inherit}
|
||||
.admin-list{width:100%;border-collapse:collapse}
|
||||
.admin-list th,.admin-list td{text-align:left;padding:.4rem;border-bottom:1px solid var(--border);font-size:.9rem}
|
||||
.admin-list th,.admin-list td{text-align:left;padding:.45rem;border-bottom:1px solid var(--border);font-size:.9rem}
|
||||
.row-actions{display:flex;gap:.4rem;align-items:center}
|
||||
.mod-actions{display:flex;gap:.5rem;margin-top:.4rem}
|
||||
.pill{font-size:.7rem;padding:.1rem .5rem;border-radius:999px;border:1px solid var(--border);text-transform:uppercase}
|
||||
.pill.published{color:var(--accent);border-color:var(--accent)}
|
||||
.pill{font-size:.68rem;padding:.12rem .55rem;border-radius:999px;border:1px solid var(--border);text-transform:uppercase;font-weight:700}
|
||||
.pill.published{color:#fff;background:var(--p1);border-color:transparent}
|
||||
.pill.draft{color:var(--muted)}
|
||||
.editor-actions{display:flex;gap:.5rem;flex-wrap:wrap}
|
||||
.error{background:color-mix(in srgb,#c0392b 14%,transparent);padding:.5rem .8rem;border-radius:8px}
|
||||
.error{background:color-mix(in srgb,#e11d48 14%,transparent);padding:.55rem .9rem;border-radius:12px}
|
||||
@media(max-width:480px){.link-card .card-img{width:84px;height:72px}}
|
||||
|
|
|
|||
12
packages/secubox-billets/api/templates/_share_quick.html
Normal file
12
packages/secubox-billets/api/templates/_share_quick.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<div class="share-quick share-menu" data-url="{{ permalink }}" data-title="{{ title }}">
|
||||
<span class="lbl">↗ Partager</span>
|
||||
<a href="{{ share.bluesky }}" target="_blank" rel="noopener nofollow" title="Bluesky">🦋</a>
|
||||
<a href="{{ share.x }}" target="_blank" rel="noopener nofollow" title="X">✖️</a>
|
||||
<a href="{{ share.whatsapp }}" target="_blank" rel="noopener nofollow" title="WhatsApp">💬</a>
|
||||
<a href="{{ share.telegram }}" target="_blank" rel="noopener nofollow" title="Telegram">✈️</a>
|
||||
<a href="{{ share.reddit }}" target="_blank" rel="noopener nofollow" title="Reddit">👽</a>
|
||||
<a href="{{ share.linkedin }}" target="_blank" rel="noopener nofollow" title="LinkedIn">💼</a>
|
||||
<a href="{{ share.email }}" title="Email">📧</a>
|
||||
<button type="button" class="share-mastodon" title="Mastodon">🐘</button>
|
||||
<button type="button" class="share-copy" title="Copier le lien">🔗</button>
|
||||
</div>
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{{ site_title }}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/billets.css">
|
||||
<link rel="stylesheet" href="/static/billets.css?v=2">
|
||||
<link rel="alternate" type="application/atom+xml" title="{{ site_title }}" href="/feed.xml">
|
||||
<link rel="alternate" type="application/feed+json" title="{{ site_title }}" href="/feed.json">
|
||||
{% block head %}{% endblock %}
|
||||
|
|
@ -20,6 +20,6 @@
|
|||
<footer class="site-footer">
|
||||
<a href="/feed.xml">Atom</a> · <a href="/feed.json">JSON</a> · billets
|
||||
</footer>
|
||||
<script src="/static/billets.js" defer></script>
|
||||
<script src="/static/billets.js?v=2" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
{% block content %}
|
||||
<article class="h-entry billet billet-full">
|
||||
<div class="e-content">{{ billet.body_html|safe }}</div>
|
||||
{% 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 %}<div class="embed">{{ billet.embed_html|safe }}</div>{% endif %}
|
||||
<footer class="billet-meta">
|
||||
{% if billet.published_at %}<time class="dt-published" datetime="{{ billet.published_at }}">{{ billet.published_at[:19]|replace('T',' ') }} UTC</time>{% endif %}
|
||||
|
|
@ -25,22 +26,8 @@
|
|||
</article>
|
||||
{% if reactions %}{% include "_reactions.html" %}{% endif %}
|
||||
|
||||
{% if share %}
|
||||
<details class="share">
|
||||
<summary>↗ Republier</summary>
|
||||
<div class="share-menu" data-url="{{ og.url }}" data-title="{{ og.title }}">
|
||||
<a href="{{ share.bluesky }}" target="_blank" rel="noopener nofollow">Bluesky</a>
|
||||
<a href="{{ share.x }}" target="_blank" rel="noopener nofollow">X</a>
|
||||
<a href="{{ share.facebook }}" target="_blank" rel="noopener nofollow">Facebook</a>
|
||||
<a href="{{ share.linkedin }}" target="_blank" rel="noopener nofollow">LinkedIn</a>
|
||||
<a href="{{ share.whatsapp }}" target="_blank" rel="noopener nofollow">WhatsApp</a>
|
||||
<a href="{{ share.telegram }}" target="_blank" rel="noopener nofollow">Telegram</a>
|
||||
<a href="{{ share.reddit }}" target="_blank" rel="noopener nofollow">Reddit</a>
|
||||
<a href="{{ share.email }}">Email</a>
|
||||
<button type="button" class="share-mastodon">Mastodon</button>
|
||||
<button type="button" class="share-copy">Copier le lien</button>
|
||||
</div>
|
||||
</details>
|
||||
{% if billet.share %}
|
||||
{% with share=billet.share, permalink=billet.permalink, title=billet.title %}{% include "_share_quick.html" %}{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
<section id="comments" class="comments">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
{% for b in billets %}
|
||||
<article class="h-entry billet">
|
||||
<div class="e-content">{{ b.body_html|safe }}</div>
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
<a class="u-url" href="/b/{{ b.slug }}">permalien</a>
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ async def test_og_and_oembed_discovery_on_page(client):
|
|||
assert '<meta property="og:title" content="Titre du billet">' in r.text
|
||||
assert 'name="twitter:card"' in r.text
|
||||
assert 'type="application/json+oembed"' in r.text
|
||||
assert "↗ Republier" in r.text and "bsky.app/intent" in r.text
|
||||
assert "↗ Partager" in r.text and "bsky.app/intent" in r.text
|
||||
|
||||
|
||||
async def test_stats_json_cors(client):
|
||||
|
|
|
|||
|
|
@ -87,3 +87,18 @@ async def test_permalink_increments_view(client, conn):
|
|||
await client.get(f"/b/{row['slug']}")
|
||||
after = await repo.get_by_id(conn, ids[0])
|
||||
assert after["view_count"] == 1
|
||||
|
||||
|
||||
async def test_feed_renders_embed_inline(client, conn):
|
||||
await repo.create_billet(conn, BilletIn(body="ma video", publish=True),
|
||||
now="2026-07-11T12:30:00Z", ulid="01EMBED00000000000000000AA")
|
||||
await conn.execute(
|
||||
"UPDATE billet SET embed_html=?, embed_url=? WHERE id='01EMBED00000000000000000AA'",
|
||||
('<iframe src="https://www.youtube.com/embed/z" sandbox="allow-scripts" loading="lazy"></iframe>',
|
||||
'https://youtube.com/watch?v=z'))
|
||||
await conn.commit()
|
||||
r = await client.get("/")
|
||||
assert 'class="embed"' in r.text
|
||||
assert 'youtube.com/embed/z' in r.text # real iframe inline in the feed
|
||||
csp = r.headers["Content-Security-Policy"]
|
||||
assert "frame-src" in csp and "youtube.com" in csp
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user