From 9b2eff54176f4f6357a87e2289d3dcfa74e0ade2 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:37:16 -0400 Subject: [PATCH] Refuse icon search in demo mode with the demo message The icon catalogues are fetched from outside, which demo mode blocks, so a search failed with a generic error. Demo mode now answers icon search with the blocked.demo refusal, and the admin explains that icon search is off in the live demo. --- api/src/routes/icons.js | 7 +++++++ api/test/demo-routes.test.js | 15 +++++++++++++++ ui/admin/index.html | 2 +- ui/i18n/de.json | 1 + ui/i18n/en.json | 1 + ui/i18n/es.json | 1 + ui/i18n/fa.json | 1 + ui/i18n/fr.json | 1 + ui/i18n/zh-Hans.json | 1 + ui/js/admin-app-form.js | 8 +++++--- ui/js/admin.js | 2 +- ui/test/icon-picker.test.mjs | 7 +++++-- 12 files changed, 40 insertions(+), 7 deletions(-) diff --git a/api/src/routes/icons.js b/api/src/routes/icons.js index 2b29bc7a..d7eb1652 100644 --- a/api/src/routes/icons.js +++ b/api/src/routes/icons.js @@ -54,6 +54,13 @@ const ICON_CACHE_TTL = 24 * 60 * 60 * 1000; on('GET', '/api/icons/search', async (req, res) => { const q = (new URL(req.url, 'http://x').searchParams.get('q') || '').trim(); if (!q) return json(res, 200, { results: [] }); + if (IS_DEMO) { + return json(res, 403, { + error: 'Outbound requests are disabled in demo mode', + kind: KIND.BLOCKED, + code: 'blocked.demo', + }); + } try { json(res, 200, { results: await searchIcons(q) }); } catch (e) { diff --git a/api/test/demo-routes.test.js b/api/test/demo-routes.test.js index e7a8d62e..1d99c238 100644 --- a/api/test/demo-routes.test.js +++ b/api/test/demo-routes.test.js @@ -98,3 +98,18 @@ test('no demo write reached the disk', () => { assert.equal(fs.existsSync(process.env.CONFIG_PATH), false); assert.deepEqual(fs.readdirSync(process.env.ICONS_PATH), []); }); + +test('icon search answers the demo refusal instead of failing on the blocked catalogues', async () => { + const r = await new Promise((resolve, reject) => { + http + .get(`${base}/api/icons/search?q=plex`, res => { + let raw = ''; + res.on('data', c => (raw += c)); + res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(raw) })); + }) + .on('error', reject); + }); + assert.equal(r.status, 403); + assert.equal(r.body.code, 'blocked.demo'); + assert.equal(r.body.kind, 'blocked'); +}); diff --git a/ui/admin/index.html b/ui/admin/index.html index 28a56421..cc9e837a 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -302,6 +302,6 @@

General

- + diff --git a/ui/i18n/de.json b/ui/i18n/de.json index bae7583d..33af4c79 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -168,6 +168,7 @@ "iconNoMatch": "Kein Symbol für „{q}“ gefunden", "iconTryUpload": "Lade ein eigenes hoch oder lasse das Feld leer, um den ersten Buchstaben zu verwenden.", "iconSearchFailed": "Symbolkataloge sind nicht erreichbar", + "iconSearchDemo": "In der Live-Demo ist die Symbolsuche deaktiviert.", "iconFrom": "von {source}", "iconResults": "Symbolvorschläge", "iconResultCount_one": "{count} Symbol gefunden", diff --git a/ui/i18n/en.json b/ui/i18n/en.json index 6f642644..a75df991 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -168,6 +168,7 @@ "iconNoMatch": "No icon found for “{q}”", "iconTryUpload": "Upload your own, or leave it empty to use the first letter.", "iconSearchFailed": "Icon catalogues could not be reached", + "iconSearchDemo": "Icon search is turned off in the live demo.", "iconFrom": "from {source}", "iconResults": "Icon suggestions", "iconResultCount_one": "{count} icon found", diff --git a/ui/i18n/es.json b/ui/i18n/es.json index a1dc4fb6..0080c981 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -169,6 +169,7 @@ "iconNoMatch": "No se encontró ningún icono para «{q}»", "iconTryUpload": "Sube el tuyo o déjalo vacío para usar la primera letra.", "iconSearchFailed": "No se pudo acceder a los catálogos de iconos", + "iconSearchDemo": "La búsqueda de iconos está desactivada en la demo en vivo.", "iconFrom": "de {source}", "iconResults": "Sugerencias de iconos", "iconResultCount_one": "{count} icono encontrado", diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index 758887f4..86d35bd7 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -168,6 +168,7 @@ "iconNoMatch": "آیکونی برای «{q}» پیدا نشد", "iconTryUpload": "آیکون خود را بارگذاری کنید، یا خالی بگذارید تا حرف اول به کار برود.", "iconSearchFailed": "دسترسی به فهرست‌های آیکون ممکن نشد", + "iconSearchDemo": "جستجوی آیکون در نسخه نمایشی آنلاین غیرفعال است.", "iconFrom": "از {source}", "iconResults": "پیشنهادهای آیکون", "iconResultCount_one": "{count} آیکون پیدا شد", diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index 9f7d55c5..13106d20 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -169,6 +169,7 @@ "iconNoMatch": "Aucune icône trouvée pour « {q} »", "iconTryUpload": "Téléversez la vôtre, ou laissez vide pour utiliser la première lettre.", "iconSearchFailed": "Les catalogues d’icônes sont inaccessibles", + "iconSearchDemo": "La recherche d’icônes est désactivée dans la démo en ligne.", "iconFrom": "de {source}", "iconResults": "Suggestions d’icônes", "iconResultCount_one": "{count} icône trouvée", diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json index ecb861d5..147bf20a 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -167,6 +167,7 @@ "iconNoMatch": "未找到“{q}”的图标", "iconTryUpload": "可上传自定义图标,或留空以使用首字母。", "iconSearchFailed": "无法访问图标目录", + "iconSearchDemo": "在线演示中已关闭图标搜索。", "iconFrom": "来自 {source}", "iconResults": "图标建议", "iconResultCount_other": "找到 {count} 个图标", diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index 1652e90d..1f51ea1c 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -674,12 +674,14 @@ function wireIcon() { const d = await apiGet(`/api/icons/search?q=${encodeURIComponent(q)}`); if (mine !== run) return; showIPRes(d.results || [], v); - } catch { + } catch (e) { if (mine !== run) return; /* No catalogue to confirm against, so the typed name is all that is left. */ updPrev(); - ipMessage(t('app.iconSearchFailed'), t('app.iconTryUpload')); - ipSay(t('app.iconSearchFailed')); + const demo = /** @type {{ code?: string }} */ (e)?.code === 'blocked.demo'; + const why = t(demo ? 'app.iconSearchDemo' : 'app.iconSearchFailed'); + ipMessage(why, demo ? '' : t('app.iconTryUpload')); + ipSay(why); } }; inp.oninput = () => { diff --git a/ui/js/admin.js b/ui/js/admin.js index eb7ab7b7..29c6bea5 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -1,4 +1,4 @@ -import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=4853370b'; +import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=622c5ce8'; import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=5246ff39'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=61d89772'; diff --git a/ui/test/icon-picker.test.mjs b/ui/test/icon-picker.test.mjs index e9034b76..3181e8c8 100644 --- a/ui/test/icon-picker.test.mjs +++ b/ui/test/icon-picker.test.mjs @@ -100,7 +100,8 @@ test('the count and the empty result are announced', () => { assert.match(form, /role="status" aria-live="polite"/, 'nothing is announced'); assert.match(form, /ipSay\(t\('app\.iconResultCount'/); assert.match(form, /ipSay\(t\('app\.iconNoMatch'/); - assert.match(form, /ipSay\(t\('app\.iconSearchFailed'/); + assert.match(form, /const why = t\(demo \? 'app\.iconSearchDemo' : 'app\.iconSearchFailed'\)/); + assert.match(form, /ipSay\(why\)/, 'a failed search is not announced'); assert.match(form, /aria-describedby="ip-status"/, 'the input is not tied to the status line'); }); @@ -172,6 +173,7 @@ test('every string the picker shows is translated', () => { 'iconNoMatch', 'iconTryUpload', 'iconSearchFailed', + 'iconSearchDemo', 'iconFrom', 'iconResults', 'iconVariant', @@ -246,7 +248,8 @@ test('the tile follows the result that matches what was typed', () => { to try, or the field would go quiet whenever the catalogues are down. */ test('a search that fails still previews the typed name', () => { const search = form.slice(form.indexOf('const search = async'), form.indexOf('inp.oninput')); - const fail = search.slice(search.indexOf('} catch {')); + const fail = search.slice(search.indexOf('} catch (e) {')); + assert.ok(fail.length > 1, 'the failure branch is gone'); assert.match(fail, /updPrev\(\)/); });