mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-05-19 19:44:14 +00:00
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>
90 lines
3.0 KiB
Python
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 ""
|