Files
rengine/reports/render/document.py
T
Yogesh OjhaandClaude Opus 5 0522444245 feat(reports): white-labelling, and a renderer that cannot be made to fetch
Uploaded typefaces and instance-wide branding defaults, which together make a
report genuinely white-labellable: a client's own font, a logo set once, and a
theme that names both.

The security half came first, because the logo field was a live SSRF. It was a
free-text string rendered into an <img src>, and WeasyPrint would have fetched
whatever it pointed at from inside the worker. Nothing fetches now, at four
layers. The load-bearing one is a WeasyPrint url_fetcher that allows data: URIs
and files under three known roots and refuses everything else; it was checked
against cloud metadata, internal https, loopback, /etc/passwd, a UNC host, ftp
and protocol-relative references. Above it sit three input guards: a logo must
be embedded data of a known image type, an uploaded theme may not use @import or
a url() that is not data:, and authored markdown loses any image that is not
embedded. A new feature that lets a report name a resource goes through the
fetcher, not around it.

A typeface is bytes, never a link. The family row records files named by us, so
a hostile filename never reaches disk, and the family directory refuses anything
resolving outside itself. A face is accepted only when the bytes agree: the
signature is sniffed and then fontTools opens it and requires outlines, which is
why fonttools is now an api dependency rather than only a WeasyPrint one. A
5-byte file with a valid TTF header and a 4 KB file of padding are both refused.
The family name is banned from carrying quotes or braces because it is written
into a quoted CSS font-family string.

The standalone HTML export now embeds only the families the theme actually
names, which halves it, and no longer carries file:// references that would have
been dead outside the container.

Instance defaults fill only what a template leaves blank, so setting a logo once
never overrides a per-client deliverable that set its own.

Verified: a real TTF family uploaded and embedded in a 53-page PDF under an
uploaded theme with an uploaded logo, and every probe above refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017f2Mf2HGwFaTRnui4UxpRd
2026-09-06 12:53:18 +05:30

212 lines
6.4 KiB
Python

"""Run the sections, number them, build the contents list, emit the document."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from reports.base import RenderContext, Section
from reports.fonts import families, font_faces
from reports.registry import SectionSpec
from reports.registry import section as lookup_section
from reports.render.css import stylesheet
from reports.render.env import environment
from shared.definitions.report_theme import HeadingStyle
from shared.logging import get_logger
logger = get_logger(__name__)
_SLUG_RE = re.compile(r"[^a-z0-9]+")
_TOC_SECTION = "contents"
@dataclass
class TocEntry:
title: str
anchor: str
number: str = ""
level: int = 1
@dataclass
class RenderedDocument:
html: str
faces: str = ""
entries: list[TocEntry] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
rendered: list[str] = field(default_factory=list)
skipped: list[str] = field(default_factory=list)
def slug(value: str) -> str:
return _SLUG_RE.sub("-", (value or "").lower()).strip("-") or "section"
def _wrap(
env,
ctx: RenderContext,
spec: SectionSpec,
body: str,
*,
title: str,
anchor: str,
number: str,
kicker: str,
lede: str,
) -> str:
heading = ctx.theme.layout.heading
if heading == HeadingStyle.NUMBERED.value and not ctx.style.section_numbering:
heading = HeadingStyle.PLAIN.value
return env.get_template("section.html").render(
body=body,
title=title,
anchor=anchor,
number=number if heading == HeadingStyle.NUMBERED.value else "",
kicker=kicker if heading == HeadingStyle.KICKER.value else "",
lede=lede,
heading_style=heading,
flow=spec.page_break == "flow",
)
def _build_one(
ctx: RenderContext, spec: SectionSpec, entry, index: int, doc: RenderedDocument
):
"""Returns (html, title, anchor) or None when the section has nothing to say."""
instance: Section = spec.instance()
if not instance.available(ctx):
doc.skipped.append(spec.name)
return None
try:
config = spec.config(entry.config)
payload = instance.build(ctx, config)
except Exception as exc:
logger.warning("section failed", section=spec.name, error=str(exc)[:300])
doc.warnings.append(f"{spec.title} could not be built: {exc}")
return None
if payload is None:
doc.skipped.append(spec.name)
return None
title = (entry.title or payload.pop("title", "") or spec.title).strip()
return payload, config, title, f"s{index}-{slug(spec.name)}"
def _fill_contents(
env, ctx: RenderContext, doc: RenderedDocument, blocks: list[str], slots: list[int]
) -> None:
spec = lookup_section(_TOC_SECTION)
if spec is None:
return
for slot in slots:
payload = spec.instance().build(ctx, spec.config({})) or {}
blocks[slot] = env.get_template(spec.section_cls.template_name()).render(
ctx=ctx, entries=doc.entries, **payload
)
doc.rendered.append(spec.name)
def render_html(ctx: RenderContext) -> RenderedDocument:
env = environment()
doc = RenderedDocument(html="")
blocks: list[str] = []
toc_slots: list[int] = []
counter = 0
for index, entry in enumerate(ctx.spec.sections):
if not entry.enabled:
continue
spec = lookup_section(entry.section)
if spec is None:
doc.warnings.append(f"Unknown section '{entry.section}'.")
continue
instance: Section = spec.instance()
if not instance.available(ctx):
doc.skipped.append(spec.name)
continue
try:
config = spec.config(entry.config)
payload = instance.build(ctx, config)
except Exception as exc:
logger.warning("section failed", section=spec.name, error=str(exc)[:300])
doc.warnings.append(f"{spec.title} could not be built: {exc}")
continue
if payload is None:
doc.skipped.append(spec.name)
continue
title = (entry.title or payload.pop("title", "") or spec.title).strip()
anchor = f"s{index}-{slug(spec.name)}"
number = ""
if spec.in_toc:
counter += 1
number = str(counter)
doc.entries.append(TocEntry(title=title, anchor=anchor, number=number))
for sub_title, sub_anchor in payload.pop("toc_subs", []) or []:
doc.entries.append(
TocEntry(title=sub_title, anchor=sub_anchor, level=2)
)
if spec.name == _TOC_SECTION:
blocks.append("")
toc_slots.append(len(blocks) - 1)
continue
bare = bool(payload.pop("bare", False))
kicker = payload.pop("kicker", "") or spec.group.replace("_", " ")
lede = payload.pop("lede", "")
body = env.get_template(spec.section_cls.template_name()).render(
ctx=ctx, cfg=config, **payload
)
blocks.append(
body
if bare
else _wrap(
env,
ctx,
spec,
body,
title=title,
anchor=anchor,
number=number,
kicker=kicker,
lede=lede,
)
)
doc.rendered.append(spec.name)
_fill_contents(env, ctx, doc, blocks, toc_slots)
doc.warnings.extend(ctx.warnings)
doc.faces = font_faces(ctx.data.session)
doc.html = env.get_template("document.html").render(
spec=ctx.spec,
blocks=blocks,
stylesheet=stylesheet(
ctx.theme,
ctx.style,
slot_values(ctx),
faces=doc.faces,
families={f.slug: f.name for f in families(ctx.data.session)},
),
)
return doc
def slot_values(ctx: RenderContext) -> dict[str, str]:
branding = ctx.branding
return {
"title": ctx.spec.title,
"subtitle": ctx.spec.subtitle,
"target": ctx.data.subject,
"client": branding.client_name,
"company": branding.company_name,
"classification": branding.classification,
"date": ctx.now.strftime("%d %B %Y"),
"scan_date": ctx.data.observed_label,
"document_id": branding.document_id,
"version": branding.version,
}