From 3f306216306743439f2d17191f55ebe75988c102 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Mon, 18 May 2026 12:19:15 +0200 Subject: [PATCH] feat(assets): probe-first resolver + SPOT for IMAGE_NAME/PORT + README screenshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Makefile | 92 ++++++--- README.md | 2 + app/app.py | 33 ++- app/config.sample.yaml | 11 +- app/templates/moduls/base.html.j2 | 4 +- app/templates/moduls/card.html.j2 | 4 +- app/templates/moduls/navigation.html.j2 | 6 +- app/utils/asset_resolver.py | 89 +++++++++ assets/img/screenshot.png | Bin 0 -> 1048230 bytes docker-compose.yml | 8 +- env.example | 6 +- tests/integration/test_asset_resolver_live.py | 85 ++++++++ tests/unit/test_asset_resolver.py | 188 ++++++++++++++++++ tests/unit/test_navigation_template.py | 8 + 14 files changed, 480 insertions(+), 56 deletions(-) create mode 100644 app/utils/asset_resolver.py create mode 100644 assets/img/screenshot.png create mode 100644 tests/integration/test_asset_resolver_live.py create mode 100644 tests/unit/test_asset_resolver.py diff --git a/Makefile b/Makefile index 282c13f..9dc9a2b 100644 --- a/Makefile +++ b/Makefile @@ -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). - docker run -d \ - -p $(PORT):$(PORT) \ - --name portfolio \ - -v $(PWD)/app/:/app \ - -e FLASK_APP=app.py \ - -e FLASK_ENV=development \ - application-portfolio + @$(call _require_env,IMAGE_NAME PORT); \ + docker run -d \ + -p "$$PORT:$$PORT" \ + --name portfolio \ + -v "$(PWD)/app/:/app" \ + -e FLASK_APP=app.py \ + -e FLASK_ENV=development \ + "$$IMAGE_NAME" .PHONY: run-prod -run-prod: +run-prod: env config config # Run the container in production mode. - docker run -d \ - -p $(PORT):$(PORT) \ - --name portfolio \ - application-portfolio + @$(call _require_env,IMAGE_NAME PORT); \ + docker run -d \ + -p "$$PORT:$$PORT" \ + --name 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: diff --git a/README.md b/README.md index ea1c6f1..cabcad0 100644 --- a/README.md +++ b/README.md @@ -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/) (Kevin’s personal site) diff --git a/app/app.py b/app/app.py index b3a0db6..8d3cbc2 100644 --- a/app/app.py +++ b/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 diff --git a/app/config.sample.yaml b/app/config.sample.yaml index 4c4338f..cb21afa 100644 --- a/app/config.sample.yaml +++ b/app/config.sample.yaml @@ -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, diff --git a/app/templates/moduls/base.html.j2 b/app/templates/moduls/base.html.j2 index fdf00e4..60ae966 100644 --- a/app/templates/moduls/base.html.j2 +++ b/app/templates/moduls/base.html.j2 @@ -6,7 +6,7 @@ @@ -36,7 +36,7 @@
logo

{{platform.titel}}

diff --git a/app/templates/moduls/card.html.j2 b/app/templates/moduls/card.html.j2 index bcd663a..1c516d2 100644 --- a/app/templates/moduls/card.html.j2 +++ b/app/templates/moduls/card.html.j2 @@ -4,9 +4,9 @@
{% 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 %} {{ card.title }} diff --git a/app/templates/moduls/navigation.html.j2 b/app/templates/moduls/navigation.html.j2 index 6240636..22c872e 100644 --- a/app/templates/moduls/navigation.html.j2 +++ b/app/templates/moduls/navigation.html.j2 @@ -55,9 +55,9 @@