diff --git a/src/lib/components/host-info.svelte b/src/lib/components/host-info.svelte index 8a966f0..dcb97ff 100644 --- a/src/lib/components/host-info.svelte +++ b/src/lib/components/host-info.svelte @@ -82,6 +82,9 @@ let searchInputRef = $state(null); let currentEnvId = $state(null); let lastUpdated = $state(new Date()); + // Live wall clock: ticks every second so the menubar time stays current between + // data refreshes (lastUpdated only moves when data is fetched) (#1182). + let now = $state(new Date()); let isConnected = $state(false); let initializedFromStore = false; let switchingEnvId = $state(null); // Track which env is being switched to @@ -341,7 +344,7 @@ || getDefaultTimezone() ); - function formatLastUpdated(date: Date, timezone: string): string { + function formatClock(date: Date, timezone: string): string { return new Intl.DateTimeFormat('en-GB', { timeZone: timezone, hour: '2-digit', @@ -365,8 +368,10 @@ fetchDiskUsage(); // No polling - only fetch on mount and environment switch document.addEventListener('click', handleClickOutside); + const clockTimer = setInterval(() => { now = new Date(); }, 1000); return () => { abortPendingRequests(); // Abort on destroy + clearInterval(clockTimer); document.removeEventListener('click', handleClickOutside); }; }); @@ -541,7 +546,10 @@ class="flex items-center gap-2 {isConnected ? 'text-emerald-500' : 'text-muted-foreground'}" title={isConnected ? 'Live updates connected' : 'Live updates disconnected'} > - {formatLastUpdated(lastUpdated, currentTimezone)} + {formatClock(now, currentTimezone)} {#if isConnected} Live diff --git a/src/lib/server/as93-slugs.ts b/src/lib/server/as93-slugs.ts new file mode 100644 index 0000000..f69ba82 --- /dev/null +++ b/src/lib/server/as93-slugs.ts @@ -0,0 +1,84 @@ +/** + * "Details" links for Lissy93 portainer-templates (#1211). + * + * The community site portainer-templates.as93.net renders a rich per-template page + * for the Lissy93 catalog, but its URL slug is NOT a plain kebab of the title (it + * strips ALL non-alphanumerics inside a word: `Pi-Hole` -> `pihole`, + * `Pi-Hole-Unbound` -> `piholeunbound`). We can GUESS a slug that matches ~96% of + * titles, so to avoid dead links we treat the site's own sitemap as the source of + * truth: emit a Details link ONLY when the guessed slug actually exists there. + * The 4% we can't match simply get no Details link (never a wrong one). + */ + +const SITEMAP_URL = 'https://portainer-templates.as93.net/sitemap.xml'; +const BASE_URL = 'https://portainer-templates.as93.net'; +const CACHE_TTL = 24 * 60 * 60 * 1000; // slugs change rarely; refresh daily + +let cache: { slugs: Set; fetchedAt: number } | null = null; +let inflight: Promise> | null = null; + +/** Test-only: clear the in-memory slug cache so a test starts from a cold state. */ +export function __resetAs93Cache(): void { + cache = null; + inflight = null; +} + +/** + * Build the candidate slug the way as93.net does: split on whitespace, strip every + * non-alphanumeric from each word, lowercase, join with '-'. Pure + unit-tested. + * This is only a CANDIDATE - it must be confirmed against the sitemap before use. + */ +export function slugifyTitle(title: string): string { + return title + .split(/\s+/) + .map((w) => w.toLowerCase().replace(/[^a-z0-9]/g, '')) + .filter(Boolean) + .join('-'); +} + +/** Parse `https://portainer-templates.as93.net/` entries into a Set. */ +export function parseSitemapSlugs(xml: string): Set { + const slugs = new Set(); + for (const m of xml.matchAll(/portainer-templates\.as93\.net\/([a-z0-9-]+)/g)) { + if (m[1]) slugs.add(m[1]); + } + return slugs; +} + +/** Fetch + cache the sitemap slug set (in-memory, 24h). Never throws; empty on failure. */ +export async function getAs93Slugs(fetchImpl: typeof fetch = fetch): Promise> { + if (cache && Date.now() - cache.fetchedAt < CACHE_TTL) return cache.slugs; + if (inflight) return inflight; + + inflight = (async () => { + try { + const res = await fetchImpl(SITEMAP_URL, { + headers: { Accept: 'application/xml' }, + redirect: 'manual', + signal: AbortSignal.timeout(15000) + }); + if (!res.ok) return cache?.slugs ?? new Set(); + const slugs = parseSitemapSlugs(await res.text()); + // Only replace the cache on a non-empty parse - a transient bad body must + // not wipe a good set and drop every Details link. + if (slugs.size > 0) cache = { slugs, fetchedAt: Date.now() }; + return cache?.slugs ?? slugs; + } catch { + return cache?.slugs ?? new Set(); + } finally { + inflight = null; + } + })(); + return inflight; +} + +/** + * The as93.net details URL for a template title, or null when the guessed slug is + * not in the sitemap (so we never emit a dead link). Pure lookup against a slug set + * the caller loaded once via getAs93Slugs. + */ +export function resolveAs93Url(title: string, slugs: Set): string | null { + const slug = slugifyTitle(title); + if (!slug || !slugs.has(slug)) return null; + return `${BASE_URL}/${slug}`; +} diff --git a/src/lib/server/selfhst-icons.ts b/src/lib/server/selfhst-icons.ts index 5bfaf76..9a44631 100644 --- a/src/lib/server/selfhst-icons.ts +++ b/src/lib/server/selfhst-icons.ts @@ -33,6 +33,16 @@ export function isValidSelfhstRef(ref: string): boolean { return /^[a-z0-9-]{1,64}$/.test(ref); } +/** + * Clean a client-supplied ref list for the batch endpoint: keep only valid string + * refs, de-dupe, and cap the count so one request can't fan out unboundedly. Pure. + */ +export function sanitizeRefList(refs: unknown, max: number): string[] { + if (!Array.isArray(refs)) return []; + const valid = refs.filter((r): r is string => typeof r === 'string' && isValidSelfhstRef(r)); + return Array.from(new Set(valid)).slice(0, max); +} + function cacheDir(): string { const dataDir = process.env.DATA_DIR || './data'; const dir = resolve(dataDir, 'icon-cache', 'selfhst'); diff --git a/src/routes/api/icons/selfhst/[ref]/+server.ts b/src/routes/api/icons/selfhst/[ref]/+server.ts index 7ee217c..ea1030d 100644 --- a/src/routes/api/icons/selfhst/[ref]/+server.ts +++ b/src/routes/api/icons/selfhst/[ref]/+server.ts @@ -2,14 +2,28 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getSelfhstIcon, isValidSelfhstRef } from '$lib/server/selfhst-icons'; +const SVG_HEADERS = { + 'Content-Type': 'image/svg+xml', + // The SVG is sanitized at cache time; these headers are belt-and-braces so even a + // direct navigation to this URL cannot execute embedded content. + 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox", + 'X-Content-Type-Options': 'nosniff' +} as const; + +// A neutral placeholder returned (200) when an icon can't be resolved, instead of a +// 404. A grid of icons where several 404 reads like probing to a WAF (CrowdSec) and +// gets the client blocked (#1467); a 200 placeholder that the UI can still show avoids +// that while looking the same to the user as the old fallback glyph. +const PLACEHOLDER_SVG = + ''; + /** * @openapi * summary: Get a selfh.st app icon (raw SVG), fetched once from the CDN and cached on disk - * description: Proxies an icon from the selfh.st collection so the browser never contacts an external CDN directly. The first request for a given ref fetches the SVG from jsdelivr and caches it under DATA_DIR; later requests are served locally. Icons are CC BY 4.0 (selfh.st). Returns 404 if the ref is invalid or the icon can't be fetched, so the UI falls back to a generic icon. + * description: Proxies an icon from the selfh.st collection so the browser never contacts an external CDN directly. The first request for a given ref fetches the SVG from jsdelivr and caches it under DATA_DIR; later requests are served locally. Icons are CC BY 4.0 (selfh.st). When the icon can't be resolved it returns a neutral placeholder SVG (200) rather than a 404, so a grid of icons doesn't read as probing to a WAF. * path: ref:string! selfh.st icon Reference (lowercase letters, digits, hyphens), e.g. "plex" - * resp-200: Raw image/svg+xml body, Cache-Control public max-age=604800 + * resp-200: Raw image/svg+xml body (the icon, or a neutral placeholder if it can't be resolved), Cache-Control public max-age=604800 * resp-400: The ref is not a valid selfh.st reference - * resp-404: No such icon (invalid ref or upstream fetch failed) */ export const GET: RequestHandler = async ({ params }) => { const ref = params.ref; @@ -18,16 +32,13 @@ export const GET: RequestHandler = async ({ params }) => { } const buf = await getSelfhstIcon(ref); if (!buf) { - return json({ error: 'Icon not found' }, { status: 404 }); + // A short cache so a ref that resolves later (transient CDN failure) is retried + // soon, but repeated views in the meantime don't re-hit the server. + return new Response(PLACEHOLDER_SVG, { + headers: { ...SVG_HEADERS, 'Cache-Control': 'public, max-age=300' } + }); } return new Response(new Uint8Array(buf), { - headers: { - 'Content-Type': 'image/svg+xml', - 'Cache-Control': 'public, max-age=604800', - // The SVG is sanitized at cache time; these headers are belt-and-braces so - // even a direct navigation to this URL cannot execute embedded content. - 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox", - 'X-Content-Type-Options': 'nosniff' - } + headers: { ...SVG_HEADERS, 'Cache-Control': 'public, max-age=604800' } }); }; diff --git a/src/routes/api/icons/selfhst/batch/+server.ts b/src/routes/api/icons/selfhst/batch/+server.ts new file mode 100644 index 0000000..91a53be --- /dev/null +++ b/src/routes/api/icons/selfhst/batch/+server.ts @@ -0,0 +1,71 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getSelfhstIcon, sanitizeRefList } from '$lib/server/selfhst-icons'; + +// Cap how many refs one call resolves, and how many upstream fetches run at once, +// so a single request can't fan out into an unbounded burst of CDN fetches. +const MAX_REFS = 200; +const CONCURRENCY = 8; + +/** + * POST /api/icons/selfhst/batch - resolve many selfh.st icons in ONE request. + * + * The icon picker renders a grid of app logos; fetching each as its own + * makes dozens of distinct requests in a + * second, which a WAF (CrowdSec) flags as crawling/probing and blocks the client + * (#1467). This batches them: the client asks for all visible refs at once and gets + * one response, so the WAF sees a single request. Refs that can't be resolved are + * simply omitted (no per-icon 404s, which also read as probing). + * + * Each icon is returned as a `data:image/svg+xml` URI so the picker keeps rendering + * via (script-inert) rather than inlining SVG markup - the SVG sanitizer was + * designed for the +CSP path, so we don't weaken it by switching to {@html}. + * + * @openapi + * summary: Resolve multiple selfh.st app icons in one request (avoids per-icon requests a WAF flags as crawling) + * description: Takes a list of selfh.st icon references and returns each resolved icon as a data:image/svg+xml URI, keyed by reference. Unresolvable or invalid refs are omitted rather than returned as errors. Each icon is fetched from the CDN once and cached on disk, same as the single-icon endpoint. + * body: {refs:array!} + * body-example: {"refs":["plex","gitea","grafana"]} + * resp-200: {icons:object!} + * resp-200-desc: icons maps each resolved reference to a data:image/svg+xml base64 URI; unresolvable refs are omitted. + * resp-200-example: {"icons":{"plex":"data:image/svg+xml;base64,PHN2Zy4uLg=="}} + * resp-400: The request body is missing a refs array + */ +export const POST: RequestHandler = async ({ request }) => { + let body: { refs?: unknown }; + try { + body = await request.json(); + } catch { + return json({ error: 'Invalid JSON body' }, { status: 400 }); + } + if (!Array.isArray(body.refs)) { + return json({ error: 'Body must include a refs array' }, { status: 400 }); + } + + // De-dupe, validate, and cap before doing any work. + const refs = sanitizeRefList(body.refs, MAX_REFS); + + const icons: Record = {}; + let next = 0; + async function worker(): Promise { + while (next < refs.length) { + const ref = refs[next++]; + const buf = await getSelfhstIcon(ref); + // data: URI so the picker renders via (script-inert), keeping the same + // safety posture as the single-icon endpoint. + if (buf) icons[ref] = `data:image/svg+xml;base64,${buf.toString('base64')}`; + } + } + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, refs.length) }, worker)); + + return json( + { icons }, + { + headers: { + // The picker re-opens often; let the browser cache the batch briefly so a + // re-open of the same grid doesn't re-hit the server at all. + 'Cache-Control': 'private, max-age=300' + } + } + ); +}; diff --git a/src/routes/api/templates/+server.ts b/src/routes/api/templates/+server.ts index 87c4979..c9afa4c 100644 --- a/src/routes/api/templates/+server.ts +++ b/src/routes/api/templates/+server.ts @@ -3,6 +3,7 @@ import { json } from '@sveltejs/kit'; import { authorize } from '$lib/server/authorize'; import { isSafeWebhookUrl } from '$lib/server/url-safety'; import { getTemplateSources, type TemplateSource } from '$lib/server/templates'; +import { getAs93Slugs, resolveAs93Url } from '$lib/server/as93-slugs'; export interface TemplateItem { id: string; @@ -25,6 +26,9 @@ export interface TemplateItem { note?: string; /** Project / homepage URL the user can click to learn more or report issues (#1211). */ projectUrl?: string; + /** Rich per-template page on portainer-templates.as93.net (Lissy93 catalog only, + * and only when the slug is confirmed to exist - never a dead link) (#1211). */ + detailsUrl?: string; } /** @@ -79,7 +83,11 @@ function hashId(source: string, title: string): string { return Math.abs(hash).toString(36); } -function normalizePortainerTemplate(entry: any, sourceName: string): TemplateItem | null { +function normalizePortainerTemplate( + entry: any, + sourceName: string, + as93Slugs?: Set +): TemplateItem | null { if (!entry.title && !entry.name) return null; // Skip Swarm templates (type 2) if (entry.type === 2) return null; @@ -96,7 +104,9 @@ function normalizePortainerTemplate(entry: any, sourceName: string): TemplateIte categories: Array.isArray(entry.categories) ? entry.categories : [], source: sourceName, note: note || undefined, - projectUrl: resolveProjectUrl(entry, description, note) + projectUrl: resolveProjectUrl(entry, description, note), + // Lissy93 catalog only: a confirmed as93.net details page (else omitted). + detailsUrl: (as93Slugs && resolveAs93Url(title, as93Slugs)) || undefined }; if (entry.type === 3 && entry.repository) { @@ -193,7 +203,16 @@ async function fetchSource(source: TemplateSource): Promise { } else { // Portainer-format templates const entries = Array.isArray(raw) ? raw : (raw.templates || []); - templates = entries.map((e: any) => normalizePortainerTemplate(e, source.name)).filter(Boolean) as TemplateItem[]; + // Lissy93 catalog: load the as93.net slug set once so each template can get a + // confirmed "Details" link (#1211). Fail-safe: any error yields an empty set, + // so templates still load - they just get no Details link. + const isLissy93 = /Lissy93\/portainer-templates/i.test(source.url); + // getAs93Slugs never throws (returns an empty set on any failure), so a + // sitemap outage just means no Details links - templates still load. + const as93Slugs = isLissy93 ? await getAs93Slugs() : undefined; + templates = entries + .map((e: any) => normalizePortainerTemplate(e, source.name, as93Slugs)) + .filter(Boolean) as TemplateItem[]; } cache.set(source.url, { data: templates, fetchedAt: Date.now() }); @@ -209,7 +228,7 @@ async function fetchSource(source: TemplateSource): Promise { * * @openapi * summary: Return the normalized app templates aggregated from all enabled template sources (server-side cached for 1 hour) - * resp-200: array<{id:string!, type:string!, title:string!, description:string!, logo:string!, categories:array!, source:string!, image:string, projectUrl:string}> + * resp-200: array<{id:string!, type:string!, title:string!, description:string!, logo:string!, categories:array!, source:string!, image:string, projectUrl:string, detailsUrl:string}> * resp-200-example: [{"id":"a1b2c3","type":"container","title":"Nginx","description":"Web server","logo":"https://example.com/nginx.png","categories":["web"],"source":"LinuxServer.io","image":"lscr.io/linuxserver/nginx:latest","projectUrl":"https://github.com/linuxserver/docker-nginx"}] * resp-403: Permission denied */ diff --git a/src/routes/stacks/IconPickerModal.svelte b/src/routes/stacks/IconPickerModal.svelte index f21481e..b239ab3 100644 --- a/src/routes/stacks/IconPickerModal.svelte +++ b/src/routes/stacks/IconPickerModal.svelte @@ -69,6 +69,48 @@ ).slice(0, 120) ); + // ref -> data: URI for the currently-shown grid. The icons are fetched in ONE + // batch request instead of one request per ref, so a WAF (CrowdSec) doesn't + // see the grid as crawling and block the client (#1467). + let iconData = $state>({}); + let iconsLoading = $state(false); + let batchSeq = 0; + // Refs already asked for (resolved OR omitted-as-unresolvable). We key `missing` + // off THIS, not off iconData, so a ref the batch omits still counts as attempted + // and never gets requested again - otherwise the effect (which reads+writes + // iconData) would re-fire forever, POSTing the same unresolvable refs in a loop. + // It is intentionally a plain Set (non-reactive), so mutating it doesn't retrigger. + const attempted = new Set(); + $effect(() => { + const refs = selfhstResults.map((e) => e.Reference); + if (refs.length === 0) return; + const missing = refs.filter((r) => !attempted.has(r)); + if (missing.length === 0) return; + for (const r of missing) attempted.add(r); + const seq = ++batchSeq; + iconsLoading = true; + fetch('/api/icons/selfhst/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refs: missing }) + }) + .then((r) => (r.ok ? r.json() : { icons: {} })) + .then((data: { icons?: Record }) => { + const icons = data.icons ?? {}; + if (Object.keys(icons).length > 0) iconData = { ...iconData, ...icons }; + }) + .catch(() => { + /* leave unresolved refs as placeholder tiles */ + }) + .finally(() => { + if (seq === batchSeq) iconsLoading = false; + }); + }); + + // The first batch is still in flight and no icon has resolved yet -> show a spinner + // instead of a grid of empty pulse tiles. + const showIconSpinner = $derived(iconsLoading && Object.keys(iconData).length === 0); + function pickSelfhst(ref: string) { onselect(`selfhst:${ref}`); open = false; @@ -155,7 +197,12 @@ {manifestError} {:else} -
+ {#if showIconSpinner} +
+ Loading icons... +
+ {/if} +
{#each selfhstResults as entry (entry.Reference)} {/each}
diff --git a/src/routes/templates/TemplateCard.svelte b/src/routes/templates/TemplateCard.svelte index 4040a60..e3d485e 100644 --- a/src/routes/templates/TemplateCard.svelte +++ b/src/routes/templates/TemplateCard.svelte @@ -1,7 +1,7 @@