← back to Paul Conrad Archive

src/conrad/web/imagehost.py

93 lines

"""Display-image policy (Steve, TK-12199): on the PRIVATE admin viewer, a cartoon may be SHOWN only when the holding
institution itself publishes a public image of it, and only by HOTLINKING that institution's own URL.

Nothing here ever fetches, proxies, caches or stores image bytes — it only rewrites/validates URL strings that the
crawlers already recorded. The browser loads the image straight from the institution, and the Content-Security-Policy
built from the same allowlist makes the browser itself refuse any other image host.
"""
from __future__ import annotations

import re
from urllib.parse import urlparse, urlunparse

# Exact hosts (no wildcards) — also the CSP img-src list. Determined from the stored URLs:
#   tile.loc.gov / cdn.loc.gov  -> Library of Congress Prints & Photographs image services
#   5008.sydneyplus.com         -> History Colorado's collections portal (ViewImage.aspx derivative)
# Web-image hosts (web_images/latimes crawlers, TK-12199 2026-09-25): added ONLY where the page that publishes the image
# was robots-allowed for our UA when crawled, the image path itself is robots-allowed, and a HEAD with our UA returned
# 200 image/* (no bytes kept). image.invaluable.com is deliberately ABSENT: its robots.txt answers 403 (unreadable ->
# treated as disallowed), so it could not be verified; those auction records stay text cards with link-outs.
WEB_IMAGE_HOSTS = (
    "ca-times.brightspotcdn.com",           # Los Angeles Times gallery CDN (Conrad's paper)
    "library.syracuse.edu",                 # Syracuse University Libraries cartoonists exhibit
    "www.original-political-cartoon.com",   # dealer gallery (original art)
    "pophistorydig.com",                    # Pop History Dig article illustrations
    "www.truthdig.com",                     # Truthdig (Conrad's post-2006 syndication outlet) article images
)
_WEB_IMAGE_PATH = re.compile(r"\.(jpe?g|gif|png|webp)(__.*)?$|/resize/\d+x\d+", re.I)
ALLOWED_IMAGE_HOSTS = ("tile.loc.gov", "cdn.loc.gov", "5008.sydneyplus.com") + WEB_IMAGE_HOSTS
LOC_HOSTS = ("tile.loc.gov", "cdn.loc.gov")

CSP = "img-src 'self' " + " ".join("https://" + h for h in ALLOWED_IMAGE_HOSTS)

_LOC_THUMB = re.compile(r"_150px\.jpg$", re.I)
_LOC_IMAGE = re.compile(r"/service/pnp/.+\.(jpe?g|gif|png)$", re.I)


def display_url(url: str | None) -> str | None:
    """Return the https URL to show for a stored image/thumbnail URL, or None if it is not on the allowlist.

    LOC: the stored value is the 150px thumbnail; LOC's standard medium "reference" derivative is <stem>r.jpg
    (present in the cached item JSON for every LOC record we hold). History Colorado: its ViewImage.aspx derivative.
    Item PAGES (www.loc.gov/pictures/item/...) are not images and are never returned.
    """
    if not url or not isinstance(url, str):
        return None
    try:
        p = urlparse(url.strip())
    except ValueError:
        return None
    host = (p.hostname or "").lower()
    if p.scheme not in ("http", "https") or host not in ALLOWED_IMAGE_HOSTS or p.username or p.password or p.port:
        return None
    if host in LOC_HOSTS:
        if not _LOC_IMAGE.search(p.path):
            return None
        path = _LOC_THUMB.sub("r.jpg", p.path)
        return urlunparse(("https", host, path, "", "", ""))
    if host == "5008.sydneyplus.com":
        if not p.path.lower().endswith("/viewimage.aspx"):
            return None
        return urlunparse(("https", host, p.path, "", p.query, ""))
    if host in WEB_IMAGE_HOSTS:
        if not _WEB_IMAGE_PATH.search(p.path):
            return None  # an article/gallery PAGE on the same host is not an image
        return urlunparse(("https", host, p.path, "", p.query, ""))
    return None  # pragma: no cover — every allowlisted host is handled above


def is_allowed(url: str | None) -> bool:
    """True iff url is an https URL on an allowlisted image host (used by tests + defence in depth)."""
    if not url:
        return False
    p = urlparse(url)
    return p.scheme == "https" and (p.hostname or "").lower() in ALLOWED_IMAGE_HOSTS


def pick(sources) -> dict | None:
    """First source (in the given order) that yields an allowlisted display URL -> display_image dict."""
    for s in sources:
        for key in ("image_url", "thumbnail_url"):
            u = display_url(s[key])
            if u and is_allowed(u):
                repo = s["repository"] or "holding institution"
                coll = s["collection_name"]
                return {
                    "url": u,
                    "repository": repo,
                    "credit": repo + ((" — " + coll) if coll and coll != "via DPLA" else ""),
                    "record_url": s["record_url"],
                    "rights_url": s["rights_url"],
                }
    return None