diff --git a/backend/app/services/post_feed_service.py b/backend/app/services/post_feed_service.py index f83d0f0..07d5b68 100644 --- a/backend/app/services/post_feed_service.py +++ b/backend/app/services/post_feed_service.py @@ -22,6 +22,7 @@ from ..models import ( PostAttachment, Source, ) +from ..utils.html_sanitize import sanitize_post_html from ..utils.text import html_to_plain, truncate_at_word from .gallery_service import thumbnail_url from .pagination import decode_cursor, encode_cursor @@ -191,6 +192,10 @@ class PostFeedService: atts_map = await self._attachments_for([post.id]) item = self._to_dict(post, artist, source, thumbs_map, atts_map) item["description_full"] = html_to_plain(post.description) + # Sanitized HTML body for faithful (semantic) rendering in the post view; + # detail-only (the feed list stays lightweight plain text). None when the + # post has no body. + item["description_html"] = sanitize_post_html(post.description) return item # --- composition helpers --------------------------------------------- diff --git a/backend/app/utils/html_sanitize.py b/backend/app/utils/html_sanitize.py index d660a5c..5748a5e 100644 --- a/backend/app/utils/html_sanitize.py +++ b/backend/app/utils/html_sanitize.py @@ -8,10 +8,23 @@ bleach is deprecated upstream and intentionally not used. import nh3 +# A faithful-but-safe set: enough to reproduce the SEMANTIC look of a scraped +# post body (headings, lists, quotes, code, inline images, links, line breaks + +# inline emphasis) without layout-injection wrappers. `div`/`span` are +# deliberately NOT allowed — their text is preserved by nh3 and they only carry +# source-site layout/styling we don't want to import (a test pins this). ALLOWED_TAGS = { - "p", "a", "br", "em", "strong", "b", "i", "ul", "ol", "li", "blockquote", + "p", "a", "br", "em", "strong", "b", "i", "u", "s", + "ul", "ol", "li", "blockquote", + "h1", "h2", "h3", "h4", "h5", "h6", "hr", + "pre", "code", "img", "figure", "figcaption", } -ALLOWED_ATTRIBUTES = {"a": {"href", "title"}} +ALLOWED_ATTRIBUTES = { + "a": {"href", "title", "target"}, + "img": {"src", "alt", "title", "width", "height"}, +} +# http/https for hotlinked source images today; relative URLs (e.g. a local +# `/api/...` src once inline images are captured) pass through nh3 untouched. ALLOWED_URL_SCHEMES = {"http", "https"} diff --git a/frontend/src/components/posts/PostCard.vue b/frontend/src/components/posts/PostCard.vue index c38c6da..0a3728f 100644 --- a/frontend/src/components/posts/PostCard.vue +++ b/frontend/src/components/posts/PostCard.vue @@ -63,8 +63,16 @@ Post {{ post.external_post_id }} + +

{{ descText }}

@@ -190,6 +198,9 @@ const descEl = ref(null) const hasDescription = computed(() => !!props.post.description_plain) const fullDescription = computed(() => detail.value?.description_full || null) +// Backend-sanitized HTML body (detail-only). Drives the faithful render once +// expanded; falls back to plain text when detail isn't loaded. +const descHtml = computed(() => detail.value?.description_html || null) const descText = computed(() => descExpanded.value ? (fullDescription.value || props.post.description_plain) @@ -219,7 +230,9 @@ onBeforeUnmount(() => { if (ro) { ro.disconnect(); ro = null } }) async function toggleDesc () { if (!descExpanded.value) { - if (props.post.description_truncated && !fullDescription.value && !detail.value) { + // Fetch detail on first expand to get the sanitized HTML body (and the full + // plain text) — render the formatted body, not just the truncated preview. + if (!detail.value) { try { detail.value = await postsStore.getPostFull(props.post.id) } catch { /* render the truncated text rather than nothing */ } @@ -369,6 +382,58 @@ function formatBytes (n) { @container (min-width: 800px) { .fc-post-card__desc--clamped { -webkit-line-clamp: 5; } } +/* Faithful HTML body (expanded). Drop the plain-text pre-wrap and style the + semantic tags the backend sanitizer allows. :deep() pierces scoped CSS to + reach the v-html subtree. */ +.fc-post-card__desc--html { white-space: normal; } +.fc-post-card__desc--html :deep(p) { margin: 0 0 0.6em; } +.fc-post-card__desc--html :deep(p:last-child) { margin-bottom: 0; } +.fc-post-card__desc--html :deep(h1), +.fc-post-card__desc--html :deep(h2), +.fc-post-card__desc--html :deep(h3), +.fc-post-card__desc--html :deep(h4), +.fc-post-card__desc--html :deep(h5), +.fc-post-card__desc--html :deep(h6) { + margin: 0.8em 0 0.4em; + line-height: 1.25; + font-weight: 700; +} +.fc-post-card__desc--html :deep(h1) { font-size: 1.3rem; } +.fc-post-card__desc--html :deep(h2) { font-size: 1.2rem; } +.fc-post-card__desc--html :deep(h3) { font-size: 1.1rem; } +.fc-post-card__desc--html :deep(h4), +.fc-post-card__desc--html :deep(h5), +.fc-post-card__desc--html :deep(h6) { font-size: 1rem; } +.fc-post-card__desc--html :deep(a) { + color: rgb(var(--v-theme-accent)); + text-decoration: underline; + word-break: break-word; +} +.fc-post-card__desc--html :deep(img) { + max-width: 100%; + height: auto; + border-radius: 6px; + margin: 0.4em 0; +} +.fc-post-card__desc--html :deep(ul), +.fc-post-card__desc--html :deep(ol) { margin: 0 0 0.6em; padding-left: 1.4em; } +.fc-post-card__desc--html :deep(li) { margin: 0.15em 0; } +.fc-post-card__desc--html :deep(blockquote) { + margin: 0.6em 0; + padding-left: 0.8em; + border-left: 3px solid rgb(var(--v-theme-on-surface-variant)); + color: rgb(var(--v-theme-on-surface-variant)); +} +.fc-post-card__desc--html :deep(pre) { + background: rgba(var(--v-theme-on-surface), 0.06); + padding: 0.6em; border-radius: 6px; overflow-x: auto; +} +.fc-post-card__desc--html :deep(code) { font-family: monospace; font-size: 0.85em; } +.fc-post-card__desc--html :deep(hr) { + border: 0; border-top: 1px solid rgb(var(--v-theme-on-surface-variant)); + margin: 0.8em 0; opacity: 0.4; +} +.fc-post-card__desc--html :deep(figure) { margin: 0.4em 0; } .fc-post-card__desc--missing { font-style: italic; color: rgb(var(--v-theme-on-surface-variant)); diff --git a/tests/test_html_sanitize.py b/tests/test_html_sanitize.py index f6a9139..63eeeee 100644 --- a/tests/test_html_sanitize.py +++ b/tests/test_html_sanitize.py @@ -36,3 +36,19 @@ def test_javascript_href_dropped(): out = sanitize_post_html('x') assert "javascript:" not in out assert "x" in out # text preserved, dangerous href removed + + +def test_headings_lists_and_images_survive(): + out = sanitize_post_html( + '

Title

x

' + 'art' + '
q
' + ) + assert "

" in out + assert '" in out and "
" in out + + +def test_image_javascript_src_dropped(): + out = sanitize_post_html('x') + assert "javascript:" not in out diff --git a/tests/test_post_feed_service.py b/tests/test_post_feed_service.py index eb70f55..064b000 100644 --- a/tests/test_post_feed_service.py +++ b/tests/test_post_feed_service.py @@ -419,6 +419,24 @@ async def test_get_post_returns_full_description(db): assert len(item["description_full"]) > 280 +@pytest.mark.asyncio +async def test_get_post_returns_sanitized_html_body(db): + artist = await _seed_artist(db, "alice-html") + src = await _seed_source(db, artist.id, "patreon", "https://p/alice-html") + p = await _seed_post( + db, src.id, external_id="HTMLBODY", post_date=datetime.now(UTC), + description='

Heading

body

', + ) + await db.commit() + + item = await PostFeedService(db).get_post(p.id) + # Detail carries the sanitized HTML body for faithful rendering;