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:
2026-05-18 12:19:15 +02:00
parent 39a41e561c
commit 3f30621630
14 changed files with 480 additions and 56 deletions

View File

@@ -3,14 +3,16 @@ import os
import requests
import yaml
from flask import Flask, current_app, render_template
from flask import Flask, current_app, render_template, url_for
from markupsafe import Markup
try:
from app.utils.asset_resolver import asset_src, resolve_asset_cache
from app.utils.cache_manager import CacheManager
from app.utils.compute_card_classes import compute_card_classes
from app.utils.configuration_resolver import ConfigurationResolver
except ImportError: # pragma: no cover - supports running from the app/ directory.
from utils.asset_resolver import asset_src, resolve_asset_cache
from utils.cache_manager import CacheManager
from utils.compute_card_classes import compute_card_classes
from utils.configuration_resolver import ConfigurationResolver
@@ -43,24 +45,16 @@ def load_config(app):
def cache_icons_and_logos(app):
"""Cache all icons and logos to local files, with a source fallback."""
"""Resolve every icon/logo/favicon to either a local cache path or
an external URL (see ``resolve_asset_cache``)."""
for card in app.config["cards"]:
icon = card.get("icon", {})
if icon.get("source"):
cached = cache_manager.cache_file(icon["source"])
icon["cache"] = cached or icon["source"]
icon = card.get("icon")
if icon:
resolve_asset_cache(icon, cache_manager)
company_logo = app.config["company"]["logo"]
cached = cache_manager.cache_file(company_logo["source"])
company_logo["cache"] = cached or company_logo["source"]
favicon = app.config["platform"]["favicon"]
cached = cache_manager.cache_file(favicon["source"])
favicon["cache"] = cached or favicon["source"]
platform_logo = app.config["platform"]["logo"]
cached = cache_manager.cache_file(platform_logo["source"])
platform_logo["cache"] = cached or platform_logo["source"]
resolve_asset_cache(app.config["company"]["logo"], cache_manager)
resolve_asset_cache(app.config["platform"]["favicon"], cache_manager)
resolve_asset_cache(app.config["platform"]["logo"], cache_manager)
# Initialize Flask app
@@ -83,7 +77,10 @@ def utility_processor():
except OSError:
return ""
return dict(include_svg=include_svg)
def template_asset_src(asset):
return asset_src(asset, lambda filename: url_for("static", filename=filename))
return dict(include_svg=include_svg, asset_src=template_asset_src)
@app.before_request

View File

@@ -198,7 +198,16 @@ accounts:
url: https://s.veen.world/cloud
cards:
- icon:
- icon:
source: https://file.infinito.nexus/assets/img/logo.png
title: Infinito.Nexus
text: Open-source self-hosting stack — one-click deployable FOSS web apps with
shared identity, asset, and observability services. The platform that hosts
this site, including the dashboard you are looking at.
url: https://infinito.nexus
link_text: infinito.nexus
iframe: true
- icon:
source: https://cloud.veen.world/s/logo_agile_coach_512x512/download
title: Agile Coach
text: I lead agile transformations and improve team dynamics through Scrum, DevOps,

View File

@@ -6,7 +6,7 @@
<link
rel="icon"
type="image/x-icon"
href="{% if platform.favicon.cache %}{{ url_for('static', filename=platform.favicon.cache) }}{% endif %}"
href="{{ asset_src(platform.favicon) }}"
>
<!-- Bootstrap CSS only -->
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
@@ -36,7 +36,7 @@
<div class="container">
<header class="header js-restore">
<img
src="{{ url_for('static', filename=platform.logo.cache) }}"
src="{{ asset_src(platform.logo) }}"
alt="logo"
/>
<h1>{{platform.titel}}</h1>

View File

@@ -4,9 +4,9 @@
<div class="card-img-top">
{% if card.icon.cache and card.icon.cache.endswith('.svg') %}
{{ include_svg(card.icon.cache) }}
{% elif card.icon.cache %}
{% elif card.icon.cache or card.icon.external_url %}
<img
src="{{ url_for('static', filename=card.icon.cache) }}"
src="{{ asset_src(card.icon) }}"
alt="{{ card.title }}"
style="width:100px; height:auto;"
onerror="this.style.display='none'; this.nextElementSibling?.style.display='inline-block';">

View File

@@ -55,9 +55,9 @@
<div class="collapse navbar-collapse" id="navbarNav{{menu_type}}">
{% if menu_type == "header" %}
<a class="navbar-brand align-items-center d-flex js-restore" id="navbar_logo" href="#">
<img
src="{{ url_for('static', filename=platform.logo.cache) }}"
alt="{{ platform.titel }}"
<img
src="{{ asset_src(platform.logo) }}"
alt="{{ platform.titel }}"
class="d-inline-block align-text-top"
style="height:2rem">
<div class="ms-2 d-flex flex-column">

View 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 ""