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

@@ -5,24 +5,61 @@ ifneq (,$(wildcard .env))
export $(shell sed 's/=.*//' .env)
endif
# Default port (can be overridden with PORT env var)
PORT ?= 5000
PYTHON ?= python3
ACT ?= act
# Default port (can be overridden with PORT env var)
# Bootstrap the local .env from the checked-in env.example template.
# Idempotent: leaves an existing .env untouched.
.PHONY: env
env:
@if [ -f .env ]; then \
echo ".env already exists — leaving it alone."; \
else \
cp env.example .env; \
echo "Created .env from env.example — review and adjust."; \
fi
# Bootstrap app/config.yaml from the checked-in app/config.sample.yaml
# template. Idempotent: leaves an existing config.yaml untouched. The
# Dockerfile COPYs the whole app/ directory at build time, so this file
# must exist before `make build` / `make up`.
.PHONY: config
config:
@if [ -f app/config.yaml ]; then \
echo "app/config.yaml already exists — leaving it alone."; \
else \
cp app/config.sample.yaml app/config.yaml; \
echo "Created app/config.yaml from app/config.sample.yaml — review and adjust."; \
fi
# Build/run recipes source .env at recipe-execution time (not at make
# parse time) so they work in the same invocation that bootstrapped
# .env via the `env` prereq. Without this, the inner $$IMAGE_NAME /
# $$PORT would be empty on the very first `make build` after a fresh
# checkout — the parse-time `include .env` happens before `env` runs.
define _require_env
if [ ! -f .env ]; then echo "ERROR: .env missing"; exit 1; fi; \
. ./.env; \
for v in $(1); do \
eval "val=\$$$$v"; \
[ -n "$$val" ] || { echo "ERROR: $$v is empty in .env (see env.example)"; exit 1; }; \
done
endef
.PHONY: build
build:
build: env config
# Build the Docker image.
docker build -t application-portfolio .
@$(call _require_env,IMAGE_NAME); \
docker build -t "$$IMAGE_NAME" .
.PHONY: build-no-cache
build-no-cache:
build-no-cache: env config
# Build the Docker image without cache.
docker build --no-cache -t application-portfolio .
@$(call _require_env,IMAGE_NAME); \
docker build --no-cache -t "$$IMAGE_NAME" .
.PHONY: up
up:
up: env config
# Start the application using docker-compose with build.
docker-compose up -d --build --force-recreate
@@ -34,23 +71,25 @@ down:
- docker-compose down
.PHONY: run-dev
run-dev:
run-dev: env config config
# Run the container in development mode (hot-reload).
@$(call _require_env,IMAGE_NAME PORT); \
docker run -d \
-p $(PORT):$(PORT) \
-p "$$PORT:$$PORT" \
--name portfolio \
-v $(PWD)/app/:/app \
-v "$(PWD)/app/:/app" \
-e FLASK_APP=app.py \
-e FLASK_ENV=development \
application-portfolio
"$$IMAGE_NAME"
.PHONY: run-prod
run-prod:
run-prod: env config config
# Run the container in production mode.
@$(call _require_env,IMAGE_NAME PORT); \
docker run -d \
-p $(PORT):$(PORT) \
-p "$$PORT:$$PORT" \
--name portfolio \
application-portfolio
"$$IMAGE_NAME"
.PHONY: logs
logs:
@@ -58,12 +97,12 @@ logs:
docker logs -f portfolio
.PHONY: dev
dev:
dev: env config
# Start the application in development mode using docker-compose.
FLASK_ENV=development docker-compose up -d
.PHONY: prod
prod:
prod: env config
# Start the application in production mode using docker-compose (with build).
docker-compose up -d --build
@@ -78,9 +117,10 @@ delete:
- docker rm -f portfolio
.PHONY: browse
browse:
# Open the application in the browser at http://localhost:$(PORT)
chromium http://localhost:$(PORT)
browse: env
# Open the application in the browser at http://localhost:$$PORT
@$(call _require_env,PORT); \
chromium "http://localhost:$$PORT"
.PHONY: install
install:

View File

@@ -4,6 +4,8 @@
A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes.
![PortUI screenshot](assets/img/screenshot.png)
> 🚀 You can also pair PortUI with JavaScript for sleek, web-based desktop-style interfaces.
> 💻 Example in action: [CyMaIS.Cloud](https://cymais.cloud/) (demo)
> 🌐 Another live example: [veen.world](https://www.veen.world/) (Kevins personal site)

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,6 +198,15 @@ accounts:
url: https://s.veen.world/cloud
cards:
- 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

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

@@ -56,7 +56,7 @@
{% 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) }}"
src="{{ asset_src(platform.logo) }}"
alt="{{ platform.titel }}"
class="d-inline-block align-text-top"
style="height:2rem">

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

BIN
assets/img/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1024 KiB

View File

@@ -1,13 +1,15 @@
version: '3.8'
services:
portfolio:
# SPOT: IMAGE_NAME comes from .env (see env.example). No default —
# an empty tag would have compose fall back to `portfolio-portfolio`
# silently, which `make run-dev` would then fail to find.
image: ${IMAGE_NAME:?IMAGE_NAME must be set in .env (see env.example)}
build:
context: .
dockerfile: Dockerfile
container_name: portfolio
ports:
- "${PORT:-5000}:${PORT:-5000}"
- "${PORT:?PORT must be set in .env (see env.example)}:${PORT:?PORT must be set in .env (see env.example)}"
env_file:
- .env
volumes:

View File

@@ -1,2 +1,6 @@
PORT=5001
FLASK_ENV=production
# Single source of truth for the Docker image tag — read by both the
# Makefile (build/run-dev/run-prod) and docker-compose.yml so every
# build path produces and consumes the same tag.
IMAGE_NAME=portfolio

View File

@@ -0,0 +1,85 @@
"""Live network verification of the probe-first asset resolution path.
The unit tests in ``tests/unit/test_asset_resolver.py`` cover every
branch with mocks. This integration test exercises the real
``https://file.infinito.nexus/assets/img/logo.png`` endpoint that
``app/config.sample.yaml`` references for the Infinito.Nexus card. A
green run proves the production resolver behaviour end-to-end:
* probe (HEAD ``image/*``) succeeds against the canonical Infinito.Nexus
asset host, so ``external_url`` is populated directly and **no**
download is performed (the cache directory stays empty).
The download-fallback branch (probe fails → ``cache_manager.cache_file``
serves the asset locally) is intentionally not exercised against a live
endpoint here — it is covered by mocked unit tests because every
candidate "HEAD-fails, GET-succeeds" public URL we tried was flaky
(Nextcloud's ``/download`` endpoint sporadically times out on HEAD even
when GET works), which would make this guard test non-deterministic.
"""
from __future__ import annotations
import os
import sys
import unittest
from tempfile import TemporaryDirectory
from urllib.parse import urlparse
import requests
from app.utils.asset_resolver import resolve_asset_cache
from app.utils.cache_manager import CacheManager
INFINITO_LOGO_URL = "https://file.infinito.nexus/assets/img/logo.png"
_SKIP_LIVE_NETWORK = os.environ.get("PORTFOLIO_SKIP_LIVE_NETWORK_TESTS") == "1"
def _reachable(url: str) -> bool:
try:
return requests.head(url, allow_redirects=True, timeout=5).ok
except requests.RequestException:
return False
@unittest.skipIf(
_SKIP_LIVE_NETWORK,
"PORTFOLIO_SKIP_LIVE_NETWORK_TESTS=1 — skipping live network tests",
)
class TestAssetResolverLive(unittest.TestCase):
@classmethod
def setUpClass(cls):
if not _reachable(INFINITO_LOGO_URL):
raise unittest.SkipTest(
f"{urlparse(INFINITO_LOGO_URL).netloc} unreachable from this runner"
)
def test_infinito_nexus_logo_resolves_via_external_url_without_download(self):
with TemporaryDirectory() as tmp:
cache_dir = os.path.join(tmp, "cache")
cache_manager = CacheManager(cache_dir)
asset = {"source": INFINITO_LOGO_URL}
resolve_asset_cache(asset, cache_manager)
self.assertIsNone(
asset["cache"],
f"Expected probe-first path to skip download for {INFINITO_LOGO_URL}",
)
self.assertEqual(
asset["external_url"],
INFINITO_LOGO_URL,
"Probe-success URL must land in external_url so the template "
"embeds it directly instead of routing through /static/.",
)
self.assertEqual(
os.listdir(cache_dir),
[],
"Cache directory must stay empty when probe succeeds — "
"downloading would waste bandwidth.",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,188 @@
import unittest
from unittest.mock import Mock, patch
import requests
from app.utils.asset_resolver import (
_probe_image_url,
asset_src,
resolve_asset_cache,
)
def _url_for_static(filename):
return f"/static/{filename}"
def _always_probes_false(_url):
return False
def _always_probes_true(_url):
return True
class TestResolveAssetCache(unittest.TestCase):
def test_probe_success_skips_cache_and_embeds_directly(self):
cache_manager = Mock()
asset = {"source": "https://example.test/logo.png"}
resolve_asset_cache(asset, cache_manager, probe=_always_probes_true)
self.assertIsNone(asset["cache"])
self.assertEqual(asset["external_url"], "https://example.test/logo.png")
cache_manager.cache_file.assert_not_called()
def test_local_cache_path_assigned_when_probe_fails_but_download_succeeds(self):
cache_manager = Mock()
cache_manager.cache_file.return_value = "cache/logo_abc.png"
asset = {"source": "https://example.test/logo.png"}
resolve_asset_cache(asset, cache_manager, probe=_always_probes_false)
self.assertEqual(asset["cache"], "cache/logo_abc.png")
self.assertIsNone(asset["external_url"])
cache_manager.cache_file.assert_called_once_with(
"https://example.test/logo.png"
)
def test_external_url_fallback_when_probe_and_download_both_fail(self):
cache_manager = Mock()
cache_manager.cache_file.return_value = None
asset = {"source": "https://file.infinito.nexus/assets/img/logo.png"}
resolve_asset_cache(asset, cache_manager, probe=_always_probes_false)
self.assertIsNone(asset["cache"])
self.assertEqual(
asset["external_url"],
"https://file.infinito.nexus/assets/img/logo.png",
)
def test_missing_source_leaves_both_fields_none(self):
cache_manager = Mock()
probe = Mock()
asset = {"class": "fa-solid fa-link"}
resolve_asset_cache(asset, cache_manager, probe=probe)
self.assertIsNone(asset["cache"])
self.assertIsNone(asset["external_url"])
cache_manager.cache_file.assert_not_called()
probe.assert_not_called()
def test_empty_source_treated_as_missing(self):
cache_manager = Mock()
probe = Mock()
asset = {"source": ""}
resolve_asset_cache(asset, cache_manager, probe=probe)
self.assertIsNone(asset["cache"])
self.assertIsNone(asset["external_url"])
cache_manager.cache_file.assert_not_called()
probe.assert_not_called()
class TestProbeImageUrl(unittest.TestCase):
def _response(self, content_type, raise_exc=None):
resp = Mock()
if raise_exc:
resp.raise_for_status.side_effect = raise_exc
else:
resp.raise_for_status.return_value = None
resp.headers = {"Content-Type": content_type}
return resp
@patch("app.utils.asset_resolver.requests.head")
def test_returns_true_for_image_content_type(self, mock_head):
mock_head.return_value = self._response("image/png")
self.assertTrue(_probe_image_url("https://example.test/logo.png"))
@patch("app.utils.asset_resolver.requests.head")
def test_returns_true_for_svg_with_charset_suffix(self, mock_head):
mock_head.return_value = self._response("image/svg+xml; charset=utf-8")
self.assertTrue(_probe_image_url("https://example.test/logo.svg"))
@patch("app.utils.asset_resolver.requests.head")
def test_returns_false_for_non_image_content_type(self, mock_head):
mock_head.return_value = self._response("text/html")
self.assertFalse(_probe_image_url("https://example.test/404"))
@patch("app.utils.asset_resolver.requests.head")
def test_returns_false_when_http_status_not_ok(self, mock_head):
mock_head.return_value = self._response(
"image/png",
raise_exc=requests.HTTPError("404 Not Found"),
)
self.assertFalse(_probe_image_url("https://example.test/missing.png"))
@patch("app.utils.asset_resolver.requests.head")
def test_returns_false_on_network_error(self, mock_head):
mock_head.side_effect = requests.ConnectionError("dns failure")
self.assertFalse(_probe_image_url("https://unreachable.test/logo.png"))
@patch("app.utils.asset_resolver.requests.head")
def test_follows_redirects_with_short_timeout(self, mock_head):
mock_head.return_value = self._response("image/png")
_probe_image_url("https://example.test/logo.png")
mock_head.assert_called_once_with(
"https://example.test/logo.png",
allow_redirects=True,
timeout=3,
)
class TestAssetSrc(unittest.TestCase):
def test_returns_static_url_for_local_cache_path(self):
asset = {"cache": "cache/logo_abc.png", "external_url": None}
self.assertEqual(
asset_src(asset, _url_for_static),
"/static/cache/logo_abc.png",
)
def test_returns_absolute_url_unchanged_for_external_url(self):
# Regression: previously the template wrapped this through
# url_for('static', ...) and produced /static/https://... — a
# URL-in-URL the browser resolved against the dashboard host.
asset = {
"cache": None,
"external_url": "https://file.infinito.nexus/assets/img/logo.png",
}
self.assertEqual(
asset_src(asset, _url_for_static),
"https://file.infinito.nexus/assets/img/logo.png",
)
def test_external_url_wins_over_cache_when_both_present(self):
asset = {
"cache": "cache/logo.png",
"external_url": "https://override.test/logo.png",
}
self.assertEqual(
asset_src(asset, _url_for_static),
"https://override.test/logo.png",
)
def test_returns_empty_string_when_neither_field_is_set(self):
asset = {"cache": None, "external_url": None}
self.assertEqual(asset_src(asset, _url_for_static), "")
def test_returns_empty_string_for_none_or_empty_asset(self):
self.assertEqual(asset_src(None, _url_for_static), "")
self.assertEqual(asset_src({}, _url_for_static), "")
if __name__ == "__main__":
unittest.main()

View File

@@ -25,6 +25,14 @@ class TestNavigationTemplate(unittest.TestCase):
environment.globals["url_for"] = lambda _endpoint, filename: (
f"/static/{filename}"
)
environment.globals["asset_src"] = lambda asset: (
(asset or {}).get("external_url")
or (
f"/static/{(asset or {}).get('cache')}"
if (asset or {}).get("cache")
else ""
)
)
rendered = environment.get_template("moduls/navigation.html.j2").render(
menu_type="header",