"""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)