feat(webui): add cached image proxy with configurable dir

Add disk-backed image proxy support to the web UI and expose it via
`/api/image`. The proxy validates image URLs, fetches remote images with
a timeout, stores image bytes + metadata in a local cache, and serves
cached responses with proper content type and cache headers.

Also add `SCRAPPR_IMAGE_CACHE` (default `.cache/webui-images`) and pass it
through `cmd/outward-web` into `webui.Run`, with startup logging updated
to include the cache location.

This reduces repeated remote fetches and makes image delivery more
reliable for the UI.feat(webui): add cached image proxy with configurable dir

Add disk-backed image proxy support to the web UI and expose it via
`/api/image`. The proxy validates image URLs, fetches remote images with
a timeout, stores image bytes + metadata in a local cache, and serves
cached responses with proper content type and cache headers.

Also add `SCRAPPR_IMAGE_CACHE` (default `.cache/webui-images`) and pass it
through `cmd/outward-web` into `webui.Run`, with startup logging updated
to include the cache location.

This reduces repeated remote fetches and makes image delivery more
reliable for the UI.
This commit is contained in:
2026-03-16 09:56:47 +02:00
parent ad3385c63b
commit b9d035d71a
3 changed files with 262 additions and 15 deletions

View File

@@ -212,8 +212,9 @@ function renderRecipeCard(entry) {
.join("");
const canonicalImageUrl = normalizeImageUrl(entry.item.image_url || "");
const image = canonicalImageUrl
? `<img src="${escapeHtml(canonicalImageUrl)}" data-fallback-src="${escapeHtml(fallbackImageUrl(canonicalImageUrl))}" alt="${escapeHtml(entry.item.name)}">`
const proxiedImageUrl = canonicalImageUrl ? imageProxyUrl(canonicalImageUrl) : "";
const image = proxiedImageUrl
? `<img src="${escapeHtml(proxiedImageUrl)}" alt="${escapeHtml(entry.item.name)}">`
: `<div class="result-fallback">${initials}</div>`;
const station = entry.recipe.station ? `Station: ${escapeHtml(entry.recipe.station)}` : "Station: Any";
@@ -239,18 +240,11 @@ function renderRecipeCard(entry) {
}
function wireResultImages() {
const images = resultsRoot.querySelectorAll("img[data-fallback-src]");
const images = resultsRoot.querySelectorAll("img");
for (const image of images) {
image.onerror = () => {
const fallback = image.dataset.fallbackSrc || "";
if (!fallback || image.dataset.failedOnce === "1") {
image.onerror = null;
image.replaceWith(createFallbackNode(image.alt || "?"));
return;
}
image.dataset.failedOnce = "1";
image.src = fallback;
image.onerror = null;
image.replaceWith(createFallbackNode(image.alt || "?"));
};
}
}
@@ -308,6 +302,10 @@ function fallbackImageUrl(raw) {
return base.replace(/\/revision\/latest$/, "");
}
function imageProxyUrl(raw) {
return `/api/image?url=${encodeURIComponent(raw)}`;
}
function collapseRequirements(ingredients) {
const counts = new Map();