mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-05-19 19:44:14 +00:00
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>
This commit is contained in:
89
app/utils/asset_resolver.py
Normal file
89
app/utils/asset_resolver.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""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 ""
|
||||
Reference in New Issue
Block a user