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:
33
app/app.py
33
app/app.py
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';">
|
||||
|
||||
@@ -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">
|
||||
|
||||
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