From 78871b99e3bd42c013196a46a9aef881067c7e6c Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:46:47 -0400 Subject: [PATCH 1/2] Load every catalogue icon through the server Drop the direct CDN fallbacks from the icon chain, route saved CDN links and icon picker results through /api/icons/cdn, and set connect-src 'self' in both page policies. The demo still loads icons from the CDN directly. --- api/test/nginx-config.test.js | 12 +++++ e2e/icon-privacy.spec.js | 36 +++++++++++++ nginx/csp-default.conf | 2 +- nginx/dashboard.conf | 2 +- ui/admin/index.html | 2 +- ui/index.html | 2 +- ui/js/admin-app-form.js | 23 ++++++--- ui/js/admin-auth.js | 4 +- ui/js/admin-color-control.js | 4 +- ui/js/admin-drag.js | 2 +- ui/js/admin-list.js | 8 +-- ui/js/admin-settings.js | 8 +-- ui/js/admin-shared.js | 4 +- ui/js/admin-widget-form.js | 8 +-- ui/js/admin.js | 20 ++++---- ui/js/background.js | 2 +- ui/js/dashboard.js | 10 ++-- ui/js/icons.js | 61 +++++++++++++--------- ui/js/listbox.js | 2 +- ui/js/spotlight.js | 2 +- ui/js/ui.js | 4 +- ui/js/utils.js | 2 +- ui/js/widget-config-form.js | 8 +-- ui/test/icon-fallback.test.mjs | 10 ++-- ui/test/icons.test.mjs | 92 ++++++++++++++++++++++++++-------- 25 files changed, 227 insertions(+), 103 deletions(-) create mode 100644 e2e/icon-privacy.spec.js diff --git a/api/test/nginx-config.test.js b/api/test/nginx-config.test.js index 124ae50a..dc821a2f 100644 --- a/api/test/nginx-config.test.js +++ b/api/test/nginx-config.test.js @@ -172,6 +172,18 @@ test('every policy in the config states a frame-ancestors', () => { /* Google Fonts was allowed by three policies for a widget that no longer exists, so every page could pull a stylesheet and a font from a host nothing asked for. The app uses system fonts and ships its own assets. */ +test('no policy lets a page connect to an outside host', () => { + const all = [...dashboard.matchAll(/add_header Content-Security-Policy "([^"]+)"/g)].map(m => m[1]); + all.push(policy); + for (const p of all) { + const connect = p + .split(';') + .map(d => d.trim()) + .find(d => d.startsWith('connect-src ')); + if (connect) assert.equal(connect, "connect-src 'self'"); + } +}); + test('no policy allows an outside font or stylesheet host', () => { const all = [...dashboard.matchAll(/add_header Content-Security-Policy "([^"]+)"/g)].map(m => m[1]); all.push(policy); diff --git a/e2e/icon-privacy.spec.js b/e2e/icon-privacy.spec.js new file mode 100644 index 00000000..5306c64f --- /dev/null +++ b/e2e/icon-privacy.spec.js @@ -0,0 +1,36 @@ +// @ts-check +/* Icons from the catalogues load through the server. The browser never asks + the CDN, even when the server cannot supply a file. */ + +const { test, expect } = require('@playwright/test'); +const { seedConfig, dismissSetupPrompt, app, openDashboardList } = require('./helpers'); + +const ITEMS = [ + { ...app('unknown', 'Unknown'), iconUrl: 'no-such-icon-in-any-catalogue' }, + { ...app('linked', 'Linked'), iconUrl: 'https://cdn.jsdelivr.net/gh/selfhst/icons/svg/jellyfin-light.svg' }, +]; + +test.beforeEach(async ({ request }) => { + await seedConfig(request, { items: ITEMS }); + await dismissSetupPrompt(request); +}); + +for (const [path, open] of [ + ['/', page => page.goto('/')], + ['/admin/', openDashboardList], +]) { + test(`${path} never requests an icon from the CDN`, async ({ page }) => { + const outside = []; + page.on('request', r => { + if (new URL(r.url()).host === 'cdn.jsdelivr.net') outside.push(r.url()); + }); + const proxied = []; + page.on('response', r => { + if (r.url().includes('/api/icons/cdn?')) proxied.push(new URL(r.url()).searchParams.get('name')); + }); + await open(page); + await expect.poll(() => [...new Set(proxied)].sort()).toEqual(['jellyfin-light', 'no-such-icon-in-any-catalogue']); + await page.waitForLoadState('networkidle'); + expect(outside).toEqual([]); + }); +} diff --git a/nginx/csp-default.conf b/nginx/csp-default.conf index 856732bb..695fd4ec 100644 --- a/nginx/csp-default.conf +++ b/nginx/csp-default.conf @@ -12,4 +12,4 @@ # # Keep this to the one header. Anything that differs per call site belongs in # the location. -add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https://images.unsplash.com https://cdn.jsdelivr.net; frame-src 'self'; frame-ancestors 'self'; connect-src 'self' https://api.unsplash.com https://cdn.jsdelivr.net; font-src 'self';" always; +add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https://images.unsplash.com https://cdn.jsdelivr.net; frame-src 'self'; frame-ancestors 'self'; connect-src 'self'; font-src 'self';" always; diff --git a/nginx/dashboard.conf b/nginx/dashboard.conf index 99102420..ecc4ab1c 100644 --- a/nginx/dashboard.conf +++ b/nginx/dashboard.conf @@ -112,7 +112,7 @@ server { include /etc/nginx/http.d/security-headers.conf; add_header X-Frame-Options "DENY" always; add_header Cache-Control "no-cache, no-store, must-revalidate" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https://images.unsplash.com https://cdn.jsdelivr.net; connect-src 'self' https://cdn.jsdelivr.net; font-src 'self'; frame-ancestors 'none';" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https://images.unsplash.com https://cdn.jsdelivr.net; connect-src 'self'; font-src 'self'; frame-ancestors 'none';" always; } # ── Icons ── diff --git a/ui/admin/index.html b/ui/admin/index.html index 99c0825d..52f27f5c 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -300,6 +300,6 @@

General

- + diff --git a/ui/index.html b/ui/index.html index fdc7beea..24daf46b 100644 --- a/ui/index.html +++ b/ui/index.html @@ -56,5 +56,5 @@ - + diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index 9a8ee203..47ea9f68 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -1,6 +1,13 @@ -import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=14b18b8c'; +import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=dc4ac52e'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { loadLocalIcons, resolveIcon, iconChain, cdnIconRef, splitIconRef } from '/js/icons.js?v=9c7b5111'; +import { + loadLocalIcons, + resolveIcon, + iconChain, + cdnIconRef, + splitIconRef, + throughServer, +} from '/js/icons.js?v=5ac01d72'; import { state } from '/js/admin-state.js?v=af772a1b'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; @@ -24,10 +31,10 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=36dc5cc1'; -import { createListbox } from '/js/listbox.js?v=23e87940'; +} from '/js/admin-shared.js?v=da7df247'; +import { createListbox } from '/js/listbox.js?v=4afb5969'; import { MAX_LABELS } from '/js/badge-logic.js?v=5c72bd4c'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=e80f4a68'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=82916010'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; @@ -121,7 +128,7 @@ export function buildAppForm(body, item) {
- ${state.siurl ? html`` : html`${initial(item?.label)}`} + ${state.siurl ? html`` : html`${initial(item?.label)}`} @@ -767,7 +774,7 @@ function ipRow(i, iconRef, name, source, urls = [], variants = []) { const img = document.createElement('img'); img.alt = ''; /* Ordered by the catalogue, whose first entry is the file it actually holds. */ - const srcs = [...urls, ...variants.flatMap(v => v.urls || [])].filter(Boolean); + const srcs = [...urls, ...variants.flatMap(v => v.urls || [])].filter(Boolean).map(throughServer); if (!srcs.length) srcs.push(...iconChain(iconRef)); let step = 0; img.src = srcs[0] || ''; @@ -857,7 +864,7 @@ function updPrev(explicit) { setInitialGlyph(p); return; } - const fallbacks = explicit?.length ? explicit : iconChain(state.siurl); + const fallbacks = explicit?.length ? explicit.map(throughServer) : iconChain(state.siurl); if (!fallbacks.length) { setInitialGlyph(p); return; diff --git a/ui/js/admin-auth.js b/ui/js/admin-auth.js index 1513ab2c..261fb8af 100644 --- a/ui/js/admin-auth.js +++ b/ui/js/admin-auth.js @@ -1,8 +1,8 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=36dc5cc1'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=da7df247'; import { initI18n, t } from '/js/i18n.js?v=ed7c5565'; import { loginErrorKey } from '/js/admin-error.js?v=b0881957'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; -import { el, inp as inpById, qa } from '/js/utils.js?v=14b18b8c'; +import { el, inp as inpById, qa } from '/js/utils.js?v=dc4ac52e'; import { blockingScreenFor } from '/js/config-recovery.js?v=85138697'; export async function checkAuth(onLogin) { diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index 87b07e0d..8be515eb 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -1,7 +1,7 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=36dc5cc1'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=da7df247'; import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa, q } from '/js/utils.js?v=14b18b8c'; +import { qa, q } from '/js/utils.js?v=dc4ac52e'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; diff --git a/ui/js/admin-drag.js b/ui/js/admin-drag.js index 666cbb63..bbe67258 100644 --- a/ui/js/admin-drag.js +++ b/ui/js/admin-drag.js @@ -12,7 +12,7 @@ import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=6b767e76'; import { state } from '/js/admin-state.js?v=af772a1b'; import { snapshotItems } from '/js/admin-save-logic.js?v=5613a9fa'; -import { qa } from '/js/utils.js?v=14b18b8c'; +import { qa } from '/js/utils.js?v=dc4ac52e'; /** @type {(before: unknown) => void} */ let _save = () => {}; diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index c3de0fde..8dc5a4ef 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -14,12 +14,12 @@ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; import { snapshotItems } from '/js/admin-save-logic.js?v=5613a9fa'; import { reorderItems } from '/js/admin-logic.js?v=fc7f0836'; -import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=d9f5ad98'; -import { paintIcon } from '/js/admin-shared.js?v=36dc5cc1'; -import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=14b18b8c'; +import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=29e09aad'; +import { paintIcon } from '/js/admin-shared.js?v=da7df247'; +import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=dc4ac52e'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { sizeLabel } from '/js/admin-widget-form.js?v=8a6be17f'; +import { sizeLabel } from '/js/admin-widget-form.js?v=047fef40'; import { widgetGlyph } from '/js/widget-glyphs.js?v=b383b7c8'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index ce84ee6b..967e42f0 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -1,4 +1,4 @@ -import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=36dc5cc1'; +import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=da7df247'; import { socketProbeAdvice } from '/js/admin-error.js?v=b0881957'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { t } from '/js/i18n.js?v=ed7c5565'; @@ -11,11 +11,11 @@ import { BLOCK, } from '/js/admin-logic.js?v=fc7f0836'; import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; -import { el, inp, setUserText } from '/js/utils.js?v=14b18b8c'; +import { el, inp, setUserText } from '/js/utils.js?v=dc4ac52e'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { serialWrites } from '/js/admin-save-logic.js?v=5613a9fa'; -import { renderColorControl } from '/js/admin-color-control.js?v=e80f4a68'; -import { BACKDROP } from '/js/background.js?v=0f4ca726'; +import { renderColorControl } from '/js/admin-color-control.js?v=82916010'; +import { BACKDROP } from '/js/background.js?v=d6f2e701'; import { ALLOWED_HOSTS_MAX, firstBadHost, diff --git a/ui/js/admin-shared.js b/ui/js/admin-shared.js index 0f0706fd..7734dfd8 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -1,10 +1,10 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=fc7f0836'; -import { el, isolate, q } from '/js/utils.js?v=14b18b8c'; +import { el, isolate, q } from '/js/utils.js?v=dc4ac52e'; import { t } from '/js/i18n.js?v=ed7c5565'; import { errorAdvice } from '/js/admin-error.js?v=b0881957'; import { formatList, formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { iconChain } from '/js/icons.js?v=9c7b5111'; +import { iconChain } from '/js/icons.js?v=5ac01d72'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; import { blockingScreenFor, recoveryShown, showBlockingScreen } from '/js/config-recovery.js?v=85138697'; diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index 19a54313..d04e086a 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,11 +1,11 @@ import { state } from '/js/admin-state.js?v=af772a1b'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=36dc5cc1'; -import { createListbox } from '/js/listbox.js?v=23e87940'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=191cb80f'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=da7df247'; +import { createListbox } from '/js/listbox.js?v=4afb5969'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=c4755d57'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=fc7f0836'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { focusFirst, q, qi, qa } from '/js/utils.js?v=14b18b8c'; +import { focusFirst, q, qi, qa } from '/js/utils.js?v=dc4ac52e'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']); diff --git a/ui/js/admin.js b/ui/js/admin.js index 2dfa2651..c0648aba 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -1,7 +1,7 @@ -import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=dd5f148f'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=71f8a57a'; +import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=92c6949c'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=73ed7df1'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=7b21e1a0'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=02db1758'; import { resolveAdminSection } from '/js/admin-logic.js?v=fc7f0836'; import { afterImport, @@ -23,7 +23,7 @@ import { showBgFields, showWallpaperFile, wireUnsplashKey, -} from '/js/admin-settings.js?v=5d039019'; +} from '/js/admin-settings.js?v=9ffe0860'; import { apiGet, apiPost, @@ -37,16 +37,16 @@ import { setReauthHandler, toast, withheldText, -} from '/js/admin-shared.js?v=36dc5cc1'; +} from '/js/admin-shared.js?v=da7df247'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=8a6be17f'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=047fef40'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; -import { createListbox } from '/js/listbox.js?v=23e87940'; +import { createListbox } from '/js/listbox.js?v=4afb5969'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=ed7c5565'; -import { loadLocalIcons } from '/js/icons.js?v=9c7b5111'; +import { loadLocalIcons } from '/js/icons.js?v=5ac01d72'; import { ensureSprite, iconSvg } from '/js/icon-set.js?v=628341d4'; import { clearSkipTls, @@ -82,8 +82,8 @@ import { storeGet, storeSet, tgt, -} from '/js/utils.js?v=14b18b8c'; -import { applyBackground, resolveBackground } from '/js/background.js?v=0f4ca726'; +} from '/js/utils.js?v=dc4ac52e'; +import { applyBackground, resolveBackground } from '/js/background.js?v=d6f2e701'; import { parseYamlTolerant, YamlLiteError, YamlTooLargeError } from '/js/yaml-lite.js?v=a42ef914'; ensureSprite(); diff --git a/ui/js/background.js b/ui/js/background.js index 10ca4f1a..776c931d 100644 --- a/ui/js/background.js +++ b/ui/js/background.js @@ -1,5 +1,5 @@ // @ts-check -import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=14b18b8c'; +import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=dc4ac52e'; import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; export const BACKDROP = '#0d1117'; diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index 75eaa80f..1834c7fb 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -1,4 +1,4 @@ -import { loadLocalIcons, iconChain } from '/js/icons.js?v=9c7b5111'; +import { loadLocalIcons, iconChain } from '/js/icons.js?v=5ac01d72'; import { WIDGET_HEIGHTS, WIDGET_DESIGN, @@ -28,9 +28,9 @@ import { storeSet, teardownWidgets, titleWhenTruncated, -} from '/js/utils.js?v=14b18b8c'; +} from '/js/utils.js?v=dc4ac52e'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; -import { initSpotlight } from '/js/spotlight.js?v=97143da4'; +import { initSpotlight } from '/js/spotlight.js?v=c76fb899'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; import { initI18n, t, currentLang } from '/js/i18n.js?v=ed7c5565'; import { blockingScreenFor, showBlockingScreen } from '/js/config-recovery.js?v=85138697'; @@ -46,7 +46,7 @@ import { buildMobile, resetMobileChrome, mkFolderGlyph, -} from '/js/ui.js?v=6af083c8'; +} from '/js/ui.js?v=4265d299'; import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=5c72bd4c'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=23793736'; @@ -59,7 +59,7 @@ import { restorePage, wheelPager, } from '/js/dashboard-logic.js?v=6a29cedc'; -import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=0f4ca726'; +import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=d6f2e701'; import { repeatJittered } from '/js/jitter.js?v=087a1fcf'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { startWakeLock } from '/js/wake-lock.js?v=6b9591cf'; diff --git a/ui/js/icons.js b/ui/js/icons.js index a56ed295..395493cc 100644 --- a/ui/js/icons.js +++ b/ui/js/icons.js @@ -86,38 +86,53 @@ function cdnFileUrl(source, slug, ext) { return `${CDN_BASE[source]}/${ext}/${slug}.${ext}`; } +function proxiedCdnUrl(url) { + for (const source of SOURCES) { + const base = `${CDN_BASE[source]}/`; + if (!url.startsWith(base)) continue; + const rest = url.slice(base.length); + const svgOnly = source === 'simple' || source === 'lobe'; + const m = svgOnly ? /^([^/?#]+)\.(svg)$/.exec(rest) : /^(svg|png)\/([^/?#]+)\.\1$/.exec(rest); + if (!m) return ''; + const [slug, ext] = svgOnly ? [m[1], m[2]] : [m[2], m[1]]; + return `/api/icons/cdn?name=${encodeURIComponent(slug)}&ext=${ext}&source=${source}`; + } + return ''; +} + +/** Load catalogue files through the server. A direct load tells the CDN which + services this dashboard shows. + @param {string} url @returns {string} */ +export function throughServer(url) { + return (_proxy && proxiedCdnUrl(url)) || url; +} + export function iconChain(rawIcon) { if (!rawIcon) return []; const localUrl = resolveIcon(rawIcon); + if (rawIcon.startsWith('http://') || rawIcon.startsWith('https://')) { + const chain = localUrl && localUrl !== rawIcon ? [localUrl] : []; + chain.push(throughServer(rawIcon)); + return chain; + } const name = rawIcon .replace(/\.(svg|png)$/i, '') .split('/') .pop() .split('?')[0]; const dot = rawIcon.lastIndexOf('.'); - const explicitExt = !rawIcon.startsWith('http') && dot > 0 ? rawIcon.slice(dot + 1).toLowerCase() : ''; - const chain = []; - if (localUrl) chain.push(localUrl); - if (rawIcon.startsWith('http')) { - if (localUrl && rawIcon !== localUrl) chain.push(rawIcon); - if (!localUrl) chain.push(rawIcon); - } else { - const { source, slug } = splitIconRef(name); - const cdn = encodeURIComponent(cdnIconName(slug)); - const src = `&source=${source}`; - /* Through the API first, which holds the file, so the CDN is not told which - services this dashboard shows on every load. The direct link stays as the - fallback for a failed proxy fetch. */ - /* One request, not one per format. Hundreds of catalogue entries are png - only and hundreds have no png, and the server knows which from the - catalogue index it already holds. */ - if (_proxy && explicitExt) chain.push(`/api/icons/cdn?name=${cdn}&ext=${explicitExt}${src}`); - else if (_proxy) chain.push(`/api/icons/cdn?name=${cdn}${src}`); - for (const ext of ['svg', 'png']) { - if (explicitExt && explicitExt !== ext) continue; - const direct = cdnFileUrl(source, cdn, ext); - if (direct) chain.push(direct); - } + const explicitExt = dot > 0 ? rawIcon.slice(dot + 1).toLowerCase() : ''; + const chain = localUrl ? [localUrl] : []; + const { source, slug } = splitIconRef(name); + const cdn = encodeURIComponent(cdnIconName(slug)); + if (_proxy) { + chain.push(`/api/icons/cdn?name=${cdn}${explicitExt ? `&ext=${explicitExt}` : ''}&source=${source}`); + return chain; + } + for (const ext of ['svg', 'png']) { + if (explicitExt && explicitExt !== ext) continue; + const direct = cdnFileUrl(source, cdn, ext); + if (direct) chain.push(direct); } return chain; } diff --git a/ui/js/listbox.js b/ui/js/listbox.js index b9368457..d574e6c4 100644 --- a/ui/js/listbox.js +++ b/ui/js/listbox.js @@ -10,7 +10,7 @@ import { nextActiveIndex } from '/js/admin-logic.js?v=fc7f0836'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { formatList } from '/js/format-number.js?v=abcd4ae4'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa } from '/js/utils.js?v=14b18b8c'; +import { qa } from '/js/utils.js?v=dc4ac52e'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; const CHEV = iconSvg('chevrons', 22, 'solid', 'dd-chev'); diff --git a/ui/js/spotlight.js b/ui/js/spotlight.js index c5e87350..8072ec13 100644 --- a/ui/js/spotlight.js +++ b/ui/js/spotlight.js @@ -1,5 +1,5 @@ import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; -import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=14b18b8c'; +import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=dc4ac52e'; import { t } from '/js/i18n.js?v=ed7c5565'; /* Attached to the window so a re-open can undo the previous one. */ diff --git a/ui/js/ui.js b/ui/js/ui.js index b8b31395..51ffd9de 100644 --- a/ui/js/ui.js +++ b/ui/js/ui.js @@ -1,4 +1,4 @@ -import { iconChain } from '/js/icons.js?v=9c7b5111'; +import { iconChain } from '/js/icons.js?v=5ac01d72'; import { widgetSrc, cardPreset, fixedAppearance, uniqueTitle, WIDGET_DESIGN } from '/js/widget-types.js?v=b0e8f76d'; import { mk, @@ -16,7 +16,7 @@ import { q, qa, setUserText, -} from '/js/utils.js?v=14b18b8c'; +} from '/js/utils.js?v=dc4ac52e'; import { t, currentLang } from '/js/i18n.js?v=ed7c5565'; import { toneForColor } from '/js/label-contrast.js?v=0b1ebb19'; import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=349f4300'; diff --git a/ui/js/utils.js b/ui/js/utils.js index 03e99144..57623268 100644 --- a/ui/js/utils.js +++ b/ui/js/utils.js @@ -1,4 +1,4 @@ -import { iconChain } from '/js/icons.js?v=9c7b5111'; +import { iconChain } from '/js/icons.js?v=5ac01d72'; import { toneForColor } from '/js/label-contrast.js?v=0b1ebb19'; import { SETTINGS_ICON, SETTINGS_ICON_LIGHT } from '/js/settings-icon.js?v=4079b66a'; import { mkGlassRim } from '/js/glass-rim.js?v=3faec233'; diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index a79a23ab..c1ec50a9 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -3,9 +3,9 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=36dc5cc1'; -import { createListbox } from '/js/listbox.js?v=23e87940'; -import { renderColorControl } from '/js/admin-color-control.js?v=e80f4a68'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=da7df247'; +import { createListbox } from '/js/listbox.js?v=4afb5969'; +import { renderColorControl } from '/js/admin-color-control.js?v=82916010'; import { seedCarried, applyOptionSet, @@ -15,7 +15,7 @@ import { visibleFieldFlags, } from '/js/admin-logic.js?v=fc7f0836'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; -import { qi } from '/js/utils.js?v=14b18b8c'; +import { qi } from '/js/utils.js?v=dc4ac52e'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; const PE = diff --git a/ui/test/icon-fallback.test.mjs b/ui/test/icon-fallback.test.mjs index 91cb8977..8ceccdf0 100644 --- a/ui/test/icon-fallback.test.mjs +++ b/ui/test/icon-fallback.test.mjs @@ -60,7 +60,7 @@ function host() { const settle = () => new Promise(r => setTimeout(r, 0)); const { paintIcon } = await import('../js/admin-shared.js'); -const { iconChain } = await import('../js/icons.js'); +const { iconChain, loadLocalIcons } = await import('../js/icons.js'); test('an icon that resolves is painted as an image', async () => { const chain = iconChain('plex'); @@ -74,11 +74,15 @@ test('an icon that resolves is painted as an image', async () => { /* The defect: the picker set candidates[0] and stopped. */ test('a first candidate that misses falls through to the next', async () => { - const chain = iconChain('plex'); + const realFetch = globalThis.fetch; + globalThis.fetch = async () => ({ ok: true, json: async () => ({ files: ['plex.png'] }) }); + await loadLocalIcons(); + globalThis.fetch = realFetch; + const chain = iconChain('plex.png'); assert.ok(chain.length > 1, 'this icon name should offer more than one candidate'); makeDom([chain[chain.length - 1]]); const h = host(); - paintIcon(h, 'plex', 'P'); + paintIcon(h, 'plex.png', 'P'); await settle(); const img = h.children[0]; assert.ok(img, 'the image was dropped even though a later candidate exists'); diff --git a/ui/test/icons.test.mjs b/ui/test/icons.test.mjs index 5ecb6ab8..07da404c 100644 --- a/ui/test/icons.test.mjs +++ b/ui/test/icons.test.mjs @@ -12,7 +12,7 @@ import { register } from 'node:module'; register('./js-root-hooks.mjs', import.meta.url); -const { resolveIcon, iconChain, loadLocalIcons, cdnIconName, cdnIconRef, splitIconRef } = await import( +const { resolveIcon, iconChain, loadLocalIcons, cdnIconName, cdnIconRef, splitIconRef, throughServer } = await import( '../js/icons.js' ); @@ -100,22 +100,17 @@ test('an empty or missing value produces nothing', async () => { /* ── iconChain ────────────────────────────────────────────────────────────── */ -test('a local icon is tried before the CDN', async () => { +test('a local icon is tried before the proxy', async () => { await withLocalIcons(ON_DISK, () => { const chain = iconChain('LOGO.SVG'); assert.equal(chain[0], '/icons/LOGO.SVG', 'the local file comes first'); - assert.ok(chain.length > 1, 'and the CDN is still a fallback'); + assert.ok(chain[1].startsWith('/api/icons/cdn?'), 'and the proxy is still a fallback'); }); }); -test('a name with no local copy falls through to the CDN', async () => { +test('a name with no local copy goes to the proxy and never to the CDN', async () => { await withLocalIcons([], () => { - const chain = iconChain('radarr'); - assert.deepEqual(chain, [ - '/api/icons/cdn?name=radarr&source=di', - 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg', - 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/radarr.png', - ]); + assert.deepEqual(iconChain('radarr'), ['/api/icons/cdn?name=radarr&source=di']); }); }); @@ -185,7 +180,6 @@ test('a mixed-case name reaches the right CDN URL', async () => { await withLocalIcons([], () => { assert.equal(iconChain('MySpeed')[0], '/api/icons/cdn?name=myspeed&source=di'); assert.equal(iconChain('Home Assistant')[0], '/api/icons/cdn?name=home-assistant&source=di'); - assert.ok(iconChain('MySpeed').includes('https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/myspeed.svg')); }); }); @@ -210,11 +204,7 @@ test('a local icon still takes precedence over the CDN', async () => { stored as source and name together. */ test('a catalogue prefix picks that catalogue', async () => { await withLocalIcons([], () => { - assert.deepEqual(iconChain('selfhst:bigbluebutton'), [ - '/api/icons/cdn?name=bigbluebutton&source=selfhst', - 'https://cdn.jsdelivr.net/gh/selfhst/icons/svg/bigbluebutton.svg', - 'https://cdn.jsdelivr.net/gh/selfhst/icons/png/bigbluebutton.png', - ]); + assert.deepEqual(iconChain('selfhst:bigbluebutton'), ['/api/icons/cdn?name=bigbluebutton&source=selfhst']); }); }); @@ -240,12 +230,19 @@ test('an extension in the name is still passed through', async () => { 404 on every dashboard load. */ test('a catalogue with no png is not asked for one', async () => { await withLocalIcons([], () => { - assert.deepEqual(iconChain('simple:plex'), [ - '/api/icons/cdn?name=plex&source=simple', - 'https://cdn.jsdelivr.net/gh/simple-icons/simple-icons/icons/plex.svg', - ]); - assert.equal(iconChain('lobe:deepseek').length, 2); + assert.deepEqual(iconChain('simple:plex'), ['/api/icons/cdn?name=plex&source=simple']); }); + await withLocalIcons( + [], + () => { + assert.deepEqual(iconChain('simple:plex'), [ + 'https://cdn.jsdelivr.net/gh/simple-icons/simple-icons/icons/plex.svg', + ]); + assert.equal(iconChain('lobe:deepseek').length, 1); + }, + true, + ); + await withLocalIcons([], () => {}); }); test('an unknown prefix is treated as a name, not a catalogue', async () => { @@ -260,3 +257,56 @@ test('a prefix survives normalising and a stray one does not', async () => { assert.equal(cdnIconRef('selfhst:'), ''); assert.deepEqual(splitIconRef('lobe:openai-color'), { source: 'lobe', slug: 'openai-color' }); }); + +/* ── the CDN is not told which services the dashboard shows ─────────────── */ + +test('a saved catalogue link loads through the proxy', async () => { + await withLocalIcons([], () => { + assert.deepEqual(iconChain('https://cdn.jsdelivr.net/gh/selfhst/icons/svg/jellyfin-light.svg'), [ + '/api/icons/cdn?name=jellyfin-light&ext=svg&source=selfhst', + ]); + assert.deepEqual(iconChain('https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/plex.png'), [ + '/api/icons/cdn?name=plex&ext=png&source=di', + ]); + assert.deepEqual(iconChain('https://cdn.jsdelivr.net/gh/simple-icons/simple-icons/icons/plex.svg'), [ + '/api/icons/cdn?name=plex&ext=svg&source=simple', + ]); + }); +}); + +test('a saved catalogue link held locally is served locally first', async () => { + await withLocalIcons(['plex.png'], () => { + assert.deepEqual(iconChain('https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/plex.png'), [ + '/icons/plex.png', + '/api/icons/cdn?name=plex&ext=png&source=di', + ]); + }); +}); + +test('a link that is not a catalogue file is loaded as saved', async () => { + await withLocalIcons([], () => { + for (const u of [ + 'https://example.invalid/plex.svg', + 'https://cdn.jsdelivr.net/gh/someone/else/plex.svg', + 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.png', + 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/webp/plex.webp', + ]) + assert.deepEqual(iconChain(u), [u]); + }); +}); + +test('picker URLs from the catalogue go through the proxy, except in the demo', async () => { + const u = 'https://cdn.jsdelivr.net/gh/lobehub/lobe-icons/packages/static-svg/icons/openai.svg'; + await withLocalIcons([], () => { + assert.equal(throughServer(u), '/api/icons/cdn?name=openai&ext=svg&source=lobe'); + assert.equal(throughServer('/icons/x.svg'), '/icons/x.svg'); + }); + await withLocalIcons([], () => assert.equal(throughServer(u), u), true); +}); + +test('no chain outside the demo names the CDN', async () => { + await withLocalIcons([], () => { + for (const raw of ['radarr', 'radarr.png', 'selfhst:bigbluebutton', 'simple:plex', 'lobe:deepseek', 'nosuch:plex']) + for (const u of iconChain(raw)) assert.ok(!u.includes('cdn.jsdelivr.net'), `${raw} -> ${u}`); + }); +}); From 3995e0c129dd6bf93e14cb4f2a3a2a9f84537017 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:08:43 -0400 Subject: [PATCH 2/2] Assert that icon chains stay on this server --- ui/test/icons.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/test/icons.test.mjs b/ui/test/icons.test.mjs index 07da404c..fe566b68 100644 --- a/ui/test/icons.test.mjs +++ b/ui/test/icons.test.mjs @@ -304,9 +304,9 @@ test('picker URLs from the catalogue go through the proxy, except in the demo', await withLocalIcons([], () => assert.equal(throughServer(u), u), true); }); -test('no chain outside the demo names the CDN', async () => { +test('every chain outside the demo stays on this server', async () => { await withLocalIcons([], () => { for (const raw of ['radarr', 'radarr.png', 'selfhst:bigbluebutton', 'simple:plex', 'lobe:deepseek', 'nosuch:plex']) - for (const u of iconChain(raw)) assert.ok(!u.includes('cdn.jsdelivr.net'), `${raw} -> ${u}`); + for (const u of iconChain(raw)) assert.match(u, /^\/(?!\/)/, `${raw} -> ${u}`); }); });