Files
homepage.veen.world/app/utils/asset_resolver.py
Kevin Veen-Birkenbach 3f30621630 feat(assets): probe-first resolver + SPOT for IMAGE_NAME/PORT + README screenshot
Probe-first asset resolution (regression fix)
---------------------------------------------

cache_manager.cache_file() returned either a relative cache path
(success) or None (failure). The previous app.py fallback
asset['cache'] = cached or asset['source'] mixed both types into
one field, which the template wrapped in url_for('static', ...)
regardless — producing broken
/static/https://file.infinito.nexus/.../logo.png URLs whenever the
source couldn't be downloaded.

- New app/utils/asset_resolver.py: HEAD-probes the URL (3 s
  timeout, image/* content type). On hit, embed directly via a
  new external_url field — no download required. On miss, fall
  back to cache_manager.cache_file. If that also fails, expose
  the source URL via external_url so the browser shows the alt
  text instead of an empty src.
- app.py exposes an asset_src(asset) context processor that
  picks external_url first, then url_for('static', cache),
  so the template never wraps an absolute URL in a static prefix.
- Templates (base, navigation, card) switch to asset_src(...) and
  gate the card image branch on cache or external_url.
- 16 unit tests cover every probe/cache/fallback branch; one live
  integration test exercises the canonical
  https://file.infinito.nexus/assets/img/logo.png to prove the
  probe-first path works end-to-end (cache dir stays empty).
- config.sample.yaml: new Infinito.Nexus card driven by the same
  canonical asset URL.

Single source of truth for IMAGE_NAME and PORT
----------------------------------------------

- env.example is now the only place the literal values live.
- Makefile and docker-compose.yml reference \$(IMAGE_NAME) /
  \${IMAGE_NAME:?…} (same for PORT); no defaults, no silent
  fallbacks.
- New make env / make config bootstrap .env / app/config.yaml
  from their checked-in templates. Idempotent.
- All container-using targets depend on the two bootstrap targets
  so a fresh checkout runs in a single invocation.
- Recipes source .env at recipe-execution time so they pick up a
  freshly bootstrapped .env in the same make invocation.

README
------

- Screenshot added under the title.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 12:19:15 +02:00

90 lines
3.0 KiB
Python

"""Split asset resolution into distinct fields so the template can
render the right URL.
Strategy
--------
1. **Probe** the source URL with a HEAD request. If it responds with an
``image/*`` content type, embed it directly via ``external_url``
(no download necessary — the browser fetches it itself).
2. **Cache** as a fallback: when the probe fails (404, non-image,
network error, …) try ``cache_manager.cache_file()`` to download
and serve via ``/static/cache/<file>``.
3. **External fallback**: if the download fails too, still expose the
source URL via ``external_url`` so the browser shows at least the
alt-text or broken-image icon instead of an empty ``src``.
Type safety
-----------
``cache`` only ever holds a relative path the static handler can serve;
absolute URLs land in ``external_url`` and are rendered as-is. The
previous fallback ``asset['cache'] = cached or asset['source']`` mixed
both shapes — and the template's ``url_for('static', filename=...)``
wrapper produced broken ``/static/https://…`` URLs when the source
was an absolute URL the container couldn't fetch.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Optional
import requests
_PROBE_TIMEOUT_SECONDS = 3
def _probe_image_url(url: str) -> bool:
"""Return True if a HEAD request to ``url`` succeeds with an
``image/*`` content type, suggesting the URL is safe to embed
directly as ``<img src>``."""
try:
resp = requests.head(url, allow_redirects=True, timeout=_PROBE_TIMEOUT_SECONDS)
resp.raise_for_status()
except requests.RequestException:
return False
content_type = resp.headers.get("Content-Type", "").lower()
return content_type.split(";", 1)[0].strip().startswith("image/")
def resolve_asset_cache(
asset: dict,
cache_manager,
probe: Callable[[str], bool] = _probe_image_url,
) -> None:
"""Set ``asset['cache']`` (local, served via /static/) or
``asset['external_url']`` (rendered as-is). Never both.
Tries the cheap direct-embed path first (``probe``) before falling
back to downloading via ``cache_manager``. See module docstring."""
source = asset.get("source")
asset["cache"] = None
asset["external_url"] = None
if not source:
return
if probe(source):
asset["external_url"] = source
return
cached = cache_manager.cache_file(source)
if cached:
asset["cache"] = cached
else:
asset["external_url"] = source
def asset_src(asset: Optional[dict], url_for_static: Callable[[str], str]) -> str:
"""Return the final URL for an asset dict prepared by
:func:`resolve_asset_cache`. ``url_for_static`` is a callable that
maps a relative cache path to its served URL (typically a partial
of Flask's ``url_for('static', filename=...)``)."""
if not asset:
return ""
external = asset.get("external_url")
if external:
return external
cached = asset.get("cache")
if cached:
return url_for_static(cached)
return ""