"""Rendering a post's stored HTML body for display — sanitized, and pointing at our own copies of the images rather than the platform's. ## Why this is one function and not two Two surfaces render a post body: the post detail view (`PostFeedService.get_post`) and the provenance panel (`ProvenanceService`). Until issue #3965 the detail view did `_localize_inline_images(sanitize(...))` and provenance did `sanitize(...)` alone — so the provenance panel hotlinked the platform CDN for images FC had already downloaded, which is exactly what #830 Phase 2 set out to stop. That bug was available because the two halves were separately callable and only one of them looked mandatory. `render_post_body` is therefore the whole pipeline in a single call, and it is the only thing callers are meant to reach for: sanitizing without localizing is not a supported operation, so it should not be a reachable one. `localize_inline_images` stays public only because a caller that already holds sanitized HTML needs it. ## Cost `localize_inline_images` issues ZERO queries for a body with no inline ``, which is most of them — it returns before touching the session if the body is empty, has no image tags, or has none carrying a parseable CDN filehash. That early exit is why callers may run this per post in a loop rather than needing a batched form; do not "optimize" it into one without a measurement saying the loop is actually hot. """ from __future__ import annotations from html import unescape from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from ..models import ImageRecord from ..utils.html_sanitize import extract_img_srcs, rewrite_img_srcs, sanitize_post_html from ..utils.paths import filehash_from_url from .gallery_service import image_url async def render_post_body( session: AsyncSession, description: str | None, artist_id: int | None, ) -> str | None: """A post's body, ready to put in front of someone. Sanitize, then repoint inline images at local copies. Use this rather than calling either half on its own — see the module docstring. """ return await localize_inline_images( session, sanitize_post_html(description), artist_id, ) async def localize_inline_images( session: AsyncSession, html: str | None, artist_id: int | None, ) -> str | None: """Rewrite a post body's inline `` to locally-served copies. The join key is the CDN filehash the downloader persisted on each ImageRecord (source_filehash): for every body image whose filehash maps to a stored image of THIS artist, swap the src to /images/. Images we never captured (or pre-Phase-2 rows with no filehash) are left as-is — they keep hotlinking, which is the prior behavior. Scoped to the post's artist so one creator's body never resolves to another's file. """ if not html or artist_id is None: return html srcs = extract_img_srcs(html) if not srcs: return html # filehash -> the raw (as-in-HTML) src strings carrying it. A body can # repeat the same image; keep every raw form so each is substituted. by_hash: dict[str, list[str]] = {} for raw in srcs: fh = filehash_from_url(unescape(raw)) if fh: by_hash.setdefault(fh, []).append(raw) if not by_hash: return html rows = (await session.execute( select(ImageRecord.source_filehash, ImageRecord.path) .where( ImageRecord.artist_id == artist_id, ImageRecord.source_filehash.in_(list(by_hash)), ) )).all() replace: dict[str, str] = {} for fh, path in rows: for raw in by_hash.get(fh, ()): replace[raw] = image_url(path) return rewrite_img_srcs(html, replace)