Source code for kstlib.monitoring.image
r"""MonitorImage render type for embedded images.
A ``MonitorImage`` renders as an HTML ``<img>`` tag with a base64-encoded
``data:`` URI, suitable for embedding logos and icons in email templates
where external image references are typically blocked.
Examples:
>>> from kstlib.monitoring.image import MonitorImage
>>> img = MonitorImage(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50, alt="Logo")
>>> "data:image/png;base64," in img.render()
True
"""
from __future__ import annotations
import base64
import html
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING
from kstlib.monitoring.exceptions import RenderError
if TYPE_CHECKING:
from pathlib import Path
# ---------------------------------------------------------------------------
# Image format detection and limits
# ---------------------------------------------------------------------------
#: Maximum allowed image size in bytes (512 KB).
IMAGE_MAX_BYTES: int = 512 * 1024
#: Supported image MIME types with their magic byte signatures.
#: Each entry maps a MIME type to a tuple of possible magic byte prefixes.
_MAGIC_SIGNATURES: dict[str, tuple[bytes, ...]] = {
"image/png": (b"\x89PNG\r\n\x1a\n",),
"image/jpeg": (b"\xff\xd8\xff",),
"image/gif": (b"GIF87a", b"GIF89a"),
"image/webp": (b"RIFF",), # RIFF....WEBP (bytes 8-11 checked separately)
"image/svg+xml": (), # detected by text heuristic, not magic bytes
}
#: Allowed MIME types for images.
ALLOWED_MIME_TYPES: frozenset[str] = frozenset(_MAGIC_SIGNATURES)
#: Pattern matching dangerous SVG content (script tags, event handlers).
_SVG_DANGEROUS_PATTERN: re.Pattern[str] = re.compile(
r"<script|on\w+\s*=",
re.IGNORECASE,
)
def _detect_mime_type(data: bytes) -> str | None:
"""Detect image MIME type from magic bytes.
Args:
data: Raw image bytes (at least the first 12 bytes).
Returns:
MIME type string if recognized, or None.
"""
for mime, signatures in _MAGIC_SIGNATURES.items():
for sig in signatures:
if data.startswith(sig):
if mime == "image/webp" and data[8:12] != b"WEBP":
continue
return mime
# SVG heuristic: XML-like text containing <svg
if len(data) > 4 and data[:4] != b"\x00\x00\x00\x00":
try:
head = data[:1024].decode("utf-8", errors="strict")
except UnicodeDecodeError:
return None
if "<svg" in head.lower():
return "image/svg+xml"
return None
def _validate_svg(data: bytes) -> None:
"""Validate SVG content for dangerous patterns.
Args:
data: Raw SVG bytes.
Raises:
RenderError: If dangerous content is detected.
"""
try:
text = data.decode("utf-8")
except UnicodeDecodeError as err:
msg = "SVG content is not valid UTF-8"
raise RenderError(msg) from err
if _SVG_DANGEROUS_PATTERN.search(text):
msg = "SVG contains dangerous content (script or event handler)"
raise RenderError(msg)
# ---------------------------------------------------------------------------
# MonitorImage
# ---------------------------------------------------------------------------
[docs]
@dataclass(frozen=True, slots=True)
class MonitorImage:
r"""An image rendered as an HTML ``<img>`` with a base64 data URI.
The image data can be provided directly as ``bytes`` or loaded from
a file ``path``. Exactly one of ``data`` or ``path`` must be given.
Attributes:
data: Raw image bytes. Mutually exclusive with ``path``.
path: Path to an image file. Mutually exclusive with ``data``.
alt: Alt text for the ``<img>`` tag (always HTML-escaped).
width: Optional width attribute (pixels).
height: Optional height attribute (pixels).
Raises:
RenderError: If both or neither of ``data``/``path`` are given,
the image exceeds size limits, or the format is unsupported.
Examples:
>>> from kstlib.monitoring.image import MonitorImage
>>> img = MonitorImage(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50, alt="Logo")
>>> "<img" in img.render()
True
"""
data: bytes | None = None
path: Path | None = None
alt: str = ""
width: int | None = None
height: int | None = None
[docs]
def __post_init__(self) -> None:
"""Validate inputs at construction time."""
if self.data is not None and self.path is not None:
msg = "Provide either data or path, not both"
raise RenderError(msg)
if self.data is None and self.path is None:
msg = "Provide either data or path"
raise RenderError(msg)
if self.width is not None and self.width <= 0:
msg = f"width must be positive, got {self.width}"
raise RenderError(msg)
if self.height is not None and self.height <= 0:
msg = f"height must be positive, got {self.height}"
raise RenderError(msg)
def _load_data(self) -> bytes:
"""Load and validate image bytes."""
if self.data is not None:
raw = self.data
else:
assert self.path is not None # guaranteed by __post_init__
if not self.path.is_file():
msg = f"Image file not found: {self.path}"
raise RenderError(msg)
raw = self.path.read_bytes()
if len(raw) > IMAGE_MAX_BYTES:
size_kb = len(raw) // 1024
limit_kb = IMAGE_MAX_BYTES // 1024
msg = f"Image too large: {size_kb} KB (limit: {limit_kb} KB)"
raise RenderError(msg)
if len(raw) < 4:
msg = "Image data too small to be valid"
raise RenderError(msg)
return raw
[docs]
def render(self, *, inline_css: bool = False) -> str:
"""Render the image as an HTML ``<img>`` with a data URI.
The ``inline_css`` parameter is accepted for protocol conformance
but has no effect on image rendering (images are always inline).
Args:
inline_css: Accepted for Renderable protocol compatibility.
Returns:
HTML ``<img>`` string with base64 data URI.
Raises:
RenderError: If the image cannot be loaded, exceeds size
limits, has an unsupported format, or (for SVG) contains
dangerous content.
"""
_ = inline_css # accepted for Renderable protocol, no effect on images
raw = self._load_data()
mime = _detect_mime_type(raw)
if mime is None:
msg = "Unsupported image format (allowed: PNG, JPEG, GIF, WebP, SVG)"
raise RenderError(msg)
if mime not in ALLOWED_MIME_TYPES:
msg = f"Image type {mime} is not allowed"
raise RenderError(msg)
if mime == "image/svg+xml":
_validate_svg(raw)
b64 = base64.b64encode(raw).decode("ascii")
escaped_alt = html.escape(self.alt)
attrs = [f'src="data:{mime};base64,{b64}"', f'alt="{escaped_alt}"']
if self.width is not None:
attrs.append(f'width="{self.width}"')
if self.height is not None:
attrs.append(f'height="{self.height}"')
return f"<img {' '.join(attrs)}>"
__all__ = [
"ALLOWED_MIME_TYPES",
"IMAGE_MAX_BYTES",
"MonitorImage",
]