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:
92
Makefile
92
Makefile
@@ -5,24 +5,61 @@ ifneq (,$(wildcard .env))
|
|||||||
export $(shell sed 's/=.*//' .env)
|
export $(shell sed 's/=.*//' .env)
|
||||||
endif
|
endif
|
||||||
|
|
||||||
# Default port (can be overridden with PORT env var)
|
|
||||||
PORT ?= 5000
|
|
||||||
PYTHON ?= python3
|
PYTHON ?= python3
|
||||||
ACT ?= act
|
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
|
.PHONY: build
|
||||||
build:
|
build: env config
|
||||||
# Build the Docker image.
|
# Build the Docker image.
|
||||||
docker build -t application-portfolio .
|
@$(call _require_env,IMAGE_NAME); \
|
||||||
|
docker build -t "$$IMAGE_NAME" .
|
||||||
|
|
||||||
.PHONY: build-no-cache
|
.PHONY: build-no-cache
|
||||||
build-no-cache:
|
build-no-cache: env config
|
||||||
# Build the Docker image without cache.
|
# 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
|
.PHONY: up
|
||||||
up:
|
up: env config
|
||||||
# Start the application using docker-compose with build.
|
# Start the application using docker-compose with build.
|
||||||
docker-compose up -d --build --force-recreate
|
docker-compose up -d --build --force-recreate
|
||||||
|
|
||||||
@@ -34,23 +71,25 @@ down:
|
|||||||
- docker-compose down
|
- docker-compose down
|
||||||
|
|
||||||
.PHONY: run-dev
|
.PHONY: run-dev
|
||||||
run-dev:
|
run-dev: env config config
|
||||||
# Run the container in development mode (hot-reload).
|
# Run the container in development mode (hot-reload).
|
||||||
docker run -d \
|
@$(call _require_env,IMAGE_NAME PORT); \
|
||||||
-p $(PORT):$(PORT) \
|
docker run -d \
|
||||||
--name portfolio \
|
-p "$$PORT:$$PORT" \
|
||||||
-v $(PWD)/app/:/app \
|
--name portfolio \
|
||||||
-e FLASK_APP=app.py \
|
-v "$(PWD)/app/:/app" \
|
||||||
-e FLASK_ENV=development \
|
-e FLASK_APP=app.py \
|
||||||
application-portfolio
|
-e FLASK_ENV=development \
|
||||||
|
"$$IMAGE_NAME"
|
||||||
|
|
||||||
.PHONY: run-prod
|
.PHONY: run-prod
|
||||||
run-prod:
|
run-prod: env config config
|
||||||
# Run the container in production mode.
|
# Run the container in production mode.
|
||||||
docker run -d \
|
@$(call _require_env,IMAGE_NAME PORT); \
|
||||||
-p $(PORT):$(PORT) \
|
docker run -d \
|
||||||
--name portfolio \
|
-p "$$PORT:$$PORT" \
|
||||||
application-portfolio
|
--name portfolio \
|
||||||
|
"$$IMAGE_NAME"
|
||||||
|
|
||||||
.PHONY: logs
|
.PHONY: logs
|
||||||
logs:
|
logs:
|
||||||
@@ -58,12 +97,12 @@ logs:
|
|||||||
docker logs -f portfolio
|
docker logs -f portfolio
|
||||||
|
|
||||||
.PHONY: dev
|
.PHONY: dev
|
||||||
dev:
|
dev: env config
|
||||||
# Start the application in development mode using docker-compose.
|
# Start the application in development mode using docker-compose.
|
||||||
FLASK_ENV=development docker-compose up -d
|
FLASK_ENV=development docker-compose up -d
|
||||||
|
|
||||||
.PHONY: prod
|
.PHONY: prod
|
||||||
prod:
|
prod: env config
|
||||||
# Start the application in production mode using docker-compose (with build).
|
# Start the application in production mode using docker-compose (with build).
|
||||||
docker-compose up -d --build
|
docker-compose up -d --build
|
||||||
|
|
||||||
@@ -78,9 +117,10 @@ delete:
|
|||||||
- docker rm -f portfolio
|
- docker rm -f portfolio
|
||||||
|
|
||||||
.PHONY: browse
|
.PHONY: browse
|
||||||
browse:
|
browse: env
|
||||||
# Open the application in the browser at http://localhost:$(PORT)
|
# Open the application in the browser at http://localhost:$$PORT
|
||||||
chromium http://localhost:$(PORT)
|
@$(call _require_env,PORT); \
|
||||||
|
chromium "http://localhost:$$PORT"
|
||||||
|
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
install:
|
install:
|
||||||
|
|||||||
@@ -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.
|
A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
> 🚀 You can also pair PortUI with JavaScript for sleek, web-based desktop-style interfaces.
|
> 🚀 You can also pair PortUI with JavaScript for sleek, web-based desktop-style interfaces.
|
||||||
> 💻 Example in action: [CyMaIS.Cloud](https://cymais.cloud/) (demo)
|
> 💻 Example in action: [CyMaIS.Cloud](https://cymais.cloud/) (demo)
|
||||||
> 🌐 Another live example: [veen.world](https://www.veen.world/) (Kevin’s personal site)
|
> 🌐 Another live example: [veen.world](https://www.veen.world/) (Kevin’s personal site)
|
||||||
|
|||||||
33
app/app.py
33
app/app.py
@@ -3,14 +3,16 @@ import os
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
import yaml
|
import yaml
|
||||||
from flask import Flask, current_app, render_template
|
from flask import Flask, current_app, render_template, url_for
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
from app.utils.asset_resolver import asset_src, resolve_asset_cache
|
||||||
from app.utils.cache_manager import CacheManager
|
from app.utils.cache_manager import CacheManager
|
||||||
from app.utils.compute_card_classes import compute_card_classes
|
from app.utils.compute_card_classes import compute_card_classes
|
||||||
from app.utils.configuration_resolver import ConfigurationResolver
|
from app.utils.configuration_resolver import ConfigurationResolver
|
||||||
except ImportError: # pragma: no cover - supports running from the app/ directory.
|
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.cache_manager import CacheManager
|
||||||
from utils.compute_card_classes import compute_card_classes
|
from utils.compute_card_classes import compute_card_classes
|
||||||
from utils.configuration_resolver import ConfigurationResolver
|
from utils.configuration_resolver import ConfigurationResolver
|
||||||
@@ -43,24 +45,16 @@ def load_config(app):
|
|||||||
|
|
||||||
|
|
||||||
def cache_icons_and_logos(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"]:
|
for card in app.config["cards"]:
|
||||||
icon = card.get("icon", {})
|
icon = card.get("icon")
|
||||||
if icon.get("source"):
|
if icon:
|
||||||
cached = cache_manager.cache_file(icon["source"])
|
resolve_asset_cache(icon, cache_manager)
|
||||||
icon["cache"] = cached or icon["source"]
|
|
||||||
|
|
||||||
company_logo = app.config["company"]["logo"]
|
resolve_asset_cache(app.config["company"]["logo"], cache_manager)
|
||||||
cached = cache_manager.cache_file(company_logo["source"])
|
resolve_asset_cache(app.config["platform"]["favicon"], cache_manager)
|
||||||
company_logo["cache"] = cached or company_logo["source"]
|
resolve_asset_cache(app.config["platform"]["logo"], cache_manager)
|
||||||
|
|
||||||
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"]
|
|
||||||
|
|
||||||
|
|
||||||
# Initialize Flask app
|
# Initialize Flask app
|
||||||
@@ -83,7 +77,10 @@ def utility_processor():
|
|||||||
except OSError:
|
except OSError:
|
||||||
return ""
|
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
|
@app.before_request
|
||||||
|
|||||||
@@ -198,7 +198,16 @@ accounts:
|
|||||||
url: https://s.veen.world/cloud
|
url: https://s.veen.world/cloud
|
||||||
|
|
||||||
cards:
|
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
|
source: https://cloud.veen.world/s/logo_agile_coach_512x512/download
|
||||||
title: Agile Coach
|
title: Agile Coach
|
||||||
text: I lead agile transformations and improve team dynamics through Scrum, DevOps,
|
text: I lead agile transformations and improve team dynamics through Scrum, DevOps,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<link
|
<link
|
||||||
rel="icon"
|
rel="icon"
|
||||||
type="image/x-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 -->
|
<!-- Bootstrap CSS only -->
|
||||||
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
|
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<header class="header js-restore">
|
<header class="header js-restore">
|
||||||
<img
|
<img
|
||||||
src="{{ url_for('static', filename=platform.logo.cache) }}"
|
src="{{ asset_src(platform.logo) }}"
|
||||||
alt="logo"
|
alt="logo"
|
||||||
/>
|
/>
|
||||||
<h1>{{platform.titel}}</h1>
|
<h1>{{platform.titel}}</h1>
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
<div class="card-img-top">
|
<div class="card-img-top">
|
||||||
{% if card.icon.cache and card.icon.cache.endswith('.svg') %}
|
{% if card.icon.cache and card.icon.cache.endswith('.svg') %}
|
||||||
{{ include_svg(card.icon.cache) }}
|
{{ include_svg(card.icon.cache) }}
|
||||||
{% elif card.icon.cache %}
|
{% elif card.icon.cache or card.icon.external_url %}
|
||||||
<img
|
<img
|
||||||
src="{{ url_for('static', filename=card.icon.cache) }}"
|
src="{{ asset_src(card.icon) }}"
|
||||||
alt="{{ card.title }}"
|
alt="{{ card.title }}"
|
||||||
style="width:100px; height:auto;"
|
style="width:100px; height:auto;"
|
||||||
onerror="this.style.display='none'; this.nextElementSibling?.style.display='inline-block';">
|
onerror="this.style.display='none'; this.nextElementSibling?.style.display='inline-block';">
|
||||||
|
|||||||
@@ -55,9 +55,9 @@
|
|||||||
<div class="collapse navbar-collapse" id="navbarNav{{menu_type}}">
|
<div class="collapse navbar-collapse" id="navbarNav{{menu_type}}">
|
||||||
{% if menu_type == "header" %}
|
{% if menu_type == "header" %}
|
||||||
<a class="navbar-brand align-items-center d-flex js-restore" id="navbar_logo" href="#">
|
<a class="navbar-brand align-items-center d-flex js-restore" id="navbar_logo" href="#">
|
||||||
<img
|
<img
|
||||||
src="{{ url_for('static', filename=platform.logo.cache) }}"
|
src="{{ asset_src(platform.logo) }}"
|
||||||
alt="{{ platform.titel }}"
|
alt="{{ platform.titel }}"
|
||||||
class="d-inline-block align-text-top"
|
class="d-inline-block align-text-top"
|
||||||
style="height:2rem">
|
style="height:2rem">
|
||||||
<div class="ms-2 d-flex flex-column">
|
<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 ""
|
||||||
BIN
assets/img/screenshot.png
Normal file
BIN
assets/img/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1024 KiB |
@@ -1,13 +1,15 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
portfolio:
|
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:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: portfolio
|
container_name: portfolio
|
||||||
ports:
|
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_file:
|
||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
PORT=5001
|
PORT=5001
|
||||||
FLASK_ENV=production
|
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
|
||||||
85
tests/integration/test_asset_resolver_live.py
Normal file
85
tests/integration/test_asset_resolver_live.py
Normal 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()
|
||||||
188
tests/unit/test_asset_resolver.py
Normal file
188
tests/unit/test_asset_resolver.py
Normal 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()
|
||||||
@@ -25,6 +25,14 @@ class TestNavigationTemplate(unittest.TestCase):
|
|||||||
environment.globals["url_for"] = lambda _endpoint, filename: (
|
environment.globals["url_for"] = lambda _endpoint, filename: (
|
||||||
f"/static/{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(
|
rendered = environment.get_template("moduls/navigation.html.j2").render(
|
||||||
menu_type="header",
|
menu_type="header",
|
||||||
|
|||||||
Reference in New Issue
Block a user