From bc3c555725636086db17d97752a9980eb65a6d3a Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:53:22 -0400 Subject: [PATCH 1/2] Import apps from gethomepage Docker labels Read homepage.* labels from every container through the socket proxy and add them as apps and folders, with the same preview as the YAML import. Only name, group, link, icon, description, site monitor, ping and weight leave the server; widget keys never do. Docker API calls retry with the engine's version prefix when a proxy refuses unversioned paths. Health pings refuse link-local addresses. App, folder and widget names are capped at 64 characters, item ids at 32. Imports remove a username and password from addresses, and the app form warns when a link holds one. --- api/src/docker-api.js | 38 +++++ api/src/docker-labels.js | 26 ++++ api/src/proxy.js | 36 ++++- api/src/routes/config.js | 3 +- api/src/routes/health.js | 53 ++++++- api/test/config-name-limit.test.js | 44 ++++++ api/test/docker-api.test.js | 92 ++++++++++++ api/test/docker-labels.test.js | 140 ++++++++++++++++++ api/test/ping-link-local.test.js | 46 ++++++ ui/admin/index.html | 7 +- ui/css/admin.css | 3 +- ui/i18n/de.json | 10 +- ui/i18n/en.json | 10 +- ui/i18n/es.json | 10 +- ui/i18n/fa.json | 10 +- ui/i18n/fr.json | 10 +- ui/i18n/zh-Hans.json | 10 +- ui/index.html | 2 +- ui/js/admin-app-form.js | 20 ++- ui/js/admin-auth.js | 4 +- ui/js/admin-color-control.js | 2 +- ui/js/admin-drag.js | 4 +- ui/js/admin-error.js | 2 + ui/js/admin-list.js | 10 +- ui/js/admin-save-logic.js | 1 + ui/js/admin-settings.js | 8 +- ui/js/admin-shared.js | 2 +- ui/js/admin-widget-form.js | 7 +- ui/js/admin.js | 227 ++++++++++++++++------------- ui/js/dashboard.js | 4 +- ui/js/import-foreign.js | 87 +++++++++-- ui/js/limits.js | 15 ++ ui/js/widget-config-form.js | 6 +- ui/test/admin-error.test.mjs | 8 + ui/test/admin-save-logic.test.mjs | 5 + ui/test/i18n-coverage.test.mjs | 2 + ui/test/import-foreign.test.mjs | 82 +++++++++++ ui/test/limits.test.mjs | 23 +++ 38 files changed, 906 insertions(+), 163 deletions(-) create mode 100644 api/src/docker-api.js create mode 100644 api/src/docker-labels.js create mode 100644 api/test/config-name-limit.test.js create mode 100644 api/test/docker-api.test.js create mode 100644 api/test/docker-labels.test.js create mode 100644 api/test/ping-link-local.test.js create mode 100644 ui/test/limits.test.mjs diff --git a/api/src/docker-api.js b/api/src/docker-api.js new file mode 100644 index 00000000..f7af023b --- /dev/null +++ b/api/src/docker-api.js @@ -0,0 +1,38 @@ +const { fetchUnchecked } = require('./proxy'); + +/* Socket proxy address -> engine API version. */ +const prefixes = new Map(); +const MAX_PREFIXES = 8; + +/* The engine sets Api-Version on every reply, a rejected version included. A + proxy's own refusal carries none. */ +/** @param {string} base @param {object} opts @returns {Promise} */ +async function engineVersion(base, opts) { + const r = await fetchUnchecked(`${base}/v1.24/version`, { ...opts, withHeaders: true }); + const v = String(r.headers?.['api-version'] || r.data?.ApiVersion || ''); + return /^1\.\d{1,3}$/.test(v) ? v : ''; +} + +/** @param {string} socketUrl @param {string} path starts with "/" + @param {{ maxBytes?: number, timeout?: number }} [opts] */ +async function dockerGet(socketUrl, path, opts = {}) { + const base = socketUrl.replace(/\/+$/, ''); + const known = prefixes.get(base); + if (known) { + const r = await fetchUnchecked(`${base}/v${known}${path}`, opts); + if (r.status >= 400) prefixes.delete(base); + return r; + } + const r = await fetchUnchecked(`${base}${path}`, opts); + if (r.status !== 401 && r.status !== 403) return r; + const version = await engineVersion(base, opts); + if (!version) return r; + const retried = await fetchUnchecked(`${base}/v${version}${path}`, opts); + if (retried.status < 400) { + if (prefixes.size >= MAX_PREFIXES) prefixes.clear(); + prefixes.set(base, version); + } + return retried; +} + +module.exports = { dockerGet }; diff --git a/api/src/docker-labels.js b/api/src/docker-labels.js new file mode 100644 index 00000000..15f9a51b --- /dev/null +++ b/api/src/docker-labels.js @@ -0,0 +1,26 @@ +/* Labels can carry widget API keys. Only these fields leave the server. */ +const FIELDS = Object.freeze(['name', 'group', 'href', 'icon', 'description', 'siteMonitor', 'ping', 'weight']); + +/** One entry per container that carries a `homepage.` label, in list order. + @param {unknown} list the body of GET /containers/json + @returns {Array<{ container: string, labels: Record, widget: boolean }>} */ +function homepageLabels(list) { + const out = []; + for (const c of Array.isArray(list) ? list : []) { + const all = c && typeof c.Labels === 'object' && c.Labels ? c.Labels : {}; + const keys = Object.keys(all).filter(k => k.startsWith('homepage.')); + if (!keys.length) continue; + const name = Array.isArray(c.Names) && typeof c.Names[0] === 'string' ? c.Names[0].replace(/^\//, '') : ''; + /** @type {Record} */ + const labels = {}; + for (const f of FIELDS) { + const v = all[`homepage.${f}`]; + if (typeof v === 'string') labels[f] = v; + } + const widget = keys.some(k => /^homepage\.widgets?[.[]/.test(k)); + out.push({ container: name, labels, widget }); + } + return out; +} + +module.exports = { homepageLabels }; diff --git a/api/src/proxy.js b/api/src/proxy.js index 3e4c7c87..1ccbcacd 100644 --- a/api/src/proxy.js +++ b/api/src/proxy.js @@ -100,6 +100,10 @@ const isPrivateAddress = addr => inAddressSet(addr, _blockedV4, BLOCKED_IPV6_RE) /** Loopback, unspecified or link-local. @param {string} addr @returns {boolean} */ const isSelfAddress = addr => inAddressSet(addr, _selfV4, SELF_IPV6_RE); + +const _linkLocalV4 = toRanges(BLOCKED_IPV4.filter(([base]) => base === '169.254.0.0')); +/** @param {string} addr @returns {boolean} */ +const isLinkLocal = addr => inAddressSet(addr, _linkLocalV4, /^fe[89ab][0-9a-f]:/i); /* Never sniff this from the body alone. A plain-text line like "Version 1.2" matches the metric grammar exactly. */ function looksLikeMetrics(ct, body) { @@ -709,8 +713,36 @@ function jsonRpcUnchecked(url, opts, fn) { return jsonRpc(rewriteUrl(url), opts, fn); } -function pingUnchecked(url, ms, skipTls) { - return pingUrl(rewriteUrl(url), ms, skipTls); +const PING_LINK_LOCAL = Object.freeze({ + ok: false, + status: 0, + error: 'Blocked: link-local address.', + kind: 'blocked', + code: 'blocked.link-local', +}); + +/* Keep the link-local refusal. 169.254.169.254 is the cloud metadata service. */ +async function pingUnchecked(url, ms, skipTls) { + if (IS_DEMO) return pingUrl(url, ms, skipTls); + const target = rewriteUrl(url); + let host; + try { + host = bareHost(new URL(target).hostname); + } catch { + return pingUrl(target, ms, skipTls); + } + if (net.isIP(host)) return isLinkLocal(host) ? { ...PING_LINK_LOCAL } : pingUrl(target, ms, skipTls); + const budget = ms || PING_MS; + const started = Date.now(); + let address; + try { + ({ address } = await lookupWithin(host, budget)); + } catch (e) { + if (e instanceof Error && e.message === 'Timed out') return { ...PING_TIMED_OUT }; + return pingUrl(target, Math.max(1, budget - (Date.now() - started)), skipTls); + } + if (isLinkLocal(address)) return { ...PING_LINK_LOCAL }; + return pingUrl(target, Math.max(1, budget - (Date.now() - started)), skipTls, address); } async function pingChecked(url, ms, skipTls) { diff --git a/api/src/routes/config.js b/api/src/routes/config.js index 71c2bda7..38a9cff3 100644 --- a/api/src/routes/config.js +++ b/api/src/routes/config.js @@ -6,7 +6,7 @@ const { fail, KIND } = require('../api-error'); /* Shared with the browser rather than copied. The Dockerfile places this file where the same relative path resolves inside the image. */ const { firstUnsafeLink } = require('../../../ui/js/link-url.js'); -const { DOCK_MAX } = require('../../../ui/js/limits.js'); +const { DOCK_MAX, clampName } = require('../../../ui/js/limits.js'); const { scrubAllSecrets, preserveAllSecrets } = require('../config-secrets'); const { rewriteChanged } = require('../secret-scope'); const { firstMalformedRow } = require('../badge-headers'); @@ -137,6 +137,7 @@ on('POST', '/api/config', async (req, res) => { }); } } + for (const item of data.items) if (typeof item.label === 'string') item.label = clampName(item.label); if (data.items.filter(i => i.type === 'app' && i.dock).length > DOCK_MAX) return json(res, 400, { error: `at most ${DOCK_MAX} apps can be shown in the dock`, diff --git a/api/src/routes/health.js b/api/src/routes/health.js index 3bba41ce..6c0bb23b 100644 --- a/api/src/routes/health.js +++ b/api/src/routes/health.js @@ -4,7 +4,6 @@ const { KIND } = require('../api-error'); const LIMITS = require('../poll-limits'); const { loadConfig } = require('../config'); const { - fetchUnchecked, pingUnchecked, urlPolicyError, pingErrorText, @@ -20,10 +19,13 @@ const { IS_DEMO } = require('../demo'); const demoData = require('../demo-data'); const log = require('../log'); const proxmox = require('../proxmox'); +const { homepageLabels } = require('../docker-labels'); +const { dockerGet } = require('../docker-api'); const { RETYPE_MESSAGE } = require('../secret-scope'); const { errorBody } = require('../api-error'); const SOCKET_PROXY_URL_DEFAULT = process.env.SOCKET_PROXY_URL || ''; const CONTAINERS_KEEP_MS = 2 * 60 * 1000; +const LABELS_MAX_BYTES = 16 * 1024 * 1024; /** @param {any} item */ function guestOf(item) { @@ -39,7 +41,7 @@ function socketProxyUrl(cfg) { async function fetchContainerHealth(socketUrl) { try { - const r = await fetchUnchecked(`${socketUrl}/containers/json?all=true`); + const r = await dockerGet(socketUrl, '/containers/json?all=true'); if (!Array.isArray(r.data)) return Object.create(null); /* Null prototype. Keys are container names, so on an ordinary object a container called "constructor" matches an inherited value and one that @@ -109,7 +111,7 @@ async function probeSocketProxy(url) { const policy = urlPolicyError(u); if (policy) return { ok: false, fatal: true, error: policy, code: 'invalid.url' }; try { - const r = await fetchUnchecked(`${url.replace(/\/+$/, '')}/version`, { timeout: PING_MS }); + const r = await dockerGet(url, '/version', { timeout: PING_MS }); if (r.status === 401 || r.status === 403) return { ok: false, fatal: true, error: 'The socket proxy refused the request.', code: 'upstream.refused' }; if (r.status >= 400) @@ -150,6 +152,51 @@ on('POST', '/api/docker/test', async (req, res) => { } }); +on('GET', '/api/docker/labels', async (req, res) => { + const limited = rateLimit(getIp(req), 'docker-labels', 10, 60_000); + if (limited) return json(res, 429, { error: limited, kind: KIND.BLOCKED, code: 'blocked.rate-limit' }); + if (IS_DEMO) + return json(res, 403, { + error: 'Outbound requests are disabled in demo mode', + kind: KIND.BLOCKED, + code: 'blocked.demo', + }); + const socketUrl = socketProxyUrl(loadConfig()); + if (!socketUrl) + return json(res, 400, { error: 'No socket proxy is set.', kind: KIND.INVALID, code: 'invalid.socket-missing' }); + try { + const r = await dockerGet(socketUrl, '/containers/json?all=true', { maxBytes: LABELS_MAX_BYTES }); + if (r.status === 401 || r.status === 403) + return json(res, 502, { + error: 'The socket proxy refused the request.', + kind: KIND.UPSTREAM, + code: 'upstream.refused', + }); + if (r.status >= 400) + return json(res, 502, { + error: `The socket proxy answered with HTTP ${r.status}.`, + kind: KIND.UPSTREAM, + code: 'upstream.status', + detail: { status: r.status }, + }); + if (!Array.isArray(r.data)) + return json(res, 502, { + error: 'Something is listening there, but it is not a Docker socket proxy.', + kind: KIND.UPSTREAM, + code: 'upstream.not-docker', + }); + json(res, 200, { containers: homepageLabels(r.data) }); + } catch (e) { + if (e.responseTooLarge) + return json(res, 502, { + error: 'The container list is too large to read.', + kind: KIND.UPSTREAM, + code: 'upstream.too-many-containers', + }); + json(res, 502, errorBody(e)); + } +}); + const MISSING = Object.freeze({ error: 'Enter the Proxmox address, token ID and secret.', kind: KIND.INVALID, diff --git a/api/test/config-name-limit.test.js b/api/test/config-name-limit.test.js new file mode 100644 index 00000000..e7b7798f --- /dev/null +++ b/api/test/config-name-limit.test.js @@ -0,0 +1,44 @@ +const path = require('node:path'); + +const { tmpDir } = require('../test-support/tmp'); +process.env.CONFIG_PATH = path.join(tmpDir('namelimit'), 'apps.json'); + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); + +require('../src/routes'); +const { dispatch } = require('../src/router'); +const { loadConfig, saveConfig } = require('../src/config'); +const { NAME_MAX } = require('../../ui/js/limits.js'); + +let server, base; + +before(async () => { + saveConfig({ items: [], settings: {} }); + server = http.createServer(dispatch); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + base = `http://127.0.0.1:${server.address().port}`; +}); +after(() => server.close()); + +test('a saved name is cut to the name limit', async () => { + const long = 'N'.repeat(2000); + const r = await fetch(`${base}/api/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: base }, + body: JSON.stringify({ + items: [ + { id: 'a', type: 'app', label: long, href: 'http://a.lan' }, + { id: 'f', type: 'folder', label: long, children: ['a'] }, + { id: 'b', type: 'app', label: 'Short', href: 'http://b.lan' }, + ], + settings: {}, + }), + }); + assert.equal(r.status, 200); + const labels = loadConfig() + .items.filter(i => ['a', 'f', 'b'].includes(i.id)) + .map(i => i.label); + assert.deepEqual(labels, ['N'.repeat(NAME_MAX), 'N'.repeat(NAME_MAX), 'Short']); +}); diff --git a/api/test/docker-api.test.js b/api/test/docker-api.test.js new file mode 100644 index 00000000..591448a3 --- /dev/null +++ b/api/test/docker-api.test.js @@ -0,0 +1,92 @@ +const path = require('node:path'); + +const { tmpDir } = require('../test-support/tmp'); +process.env.CONFIG_PATH = path.join(tmpDir('docker-api'), 'apps.json'); + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); + +require('../src/routes'); +const { dispatch } = require('../src/router'); +const { dockerGet } = require('../src/docker-api'); + +const LIST = [{ Names: ['/emby'], Labels: { 'homepage.name': 'Emby', 'homepage.group': 'Media' } }]; +let seen = []; + +function versionedOnly(req, res) { + seen.push(req.url); + if (!/^\/v1\.\d+\//.test(req.url)) { + res.writeHead(403); + return res.end('Forbidden'); + } + if (req.url.startsWith('/v1.24/')) { + res.writeHead(400, { 'Content-Type': 'application/json', 'Api-Version': '1.53' }); + return res.end('{"message":"client version 1.24 is too old"}'); + } + res.writeHead(200, { 'Content-Type': 'application/json', 'Api-Version': '1.53' }); + res.end(JSON.stringify(req.url.startsWith('/v1.53/version') ? { ApiVersion: '1.53' } : LIST)); +} + +function noContainers(req, res) { + seen.push(req.url); + if (req.url.includes('/containers/')) { + res.writeHead(403); + return res.end('Forbidden'); + } + res.writeHead(200, { 'Content-Type': 'application/json', 'Api-Version': '1.53' }); + res.end('{"ApiVersion":"1.53"}'); +} + +let versioned, versionedBase, blocked, blockedBase, api, apiBase; +const listen = s => new Promise(r => s.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${s.address().port}`))); + +before(async () => { + versioned = http.createServer(versionedOnly); + versionedBase = await listen(versioned); + blocked = http.createServer(noContainers); + blockedBase = await listen(blocked); + api = http.createServer(dispatch); + apiBase = await listen(api); +}); +after(() => { + for (const s of [versioned, blocked, api]) { + s.closeAllConnections?.(); + s.close(); + } +}); + +test('a proxy that allows only versioned paths is read through the engine version', async () => { + seen = []; + const r = await dockerGet(versionedBase, '/containers/json?all=true'); + assert.equal(r.status, 200); + assert.deepEqual(r.data, LIST); + assert.deepEqual(seen, ['/containers/json?all=true', '/v1.24/version', '/v1.53/containers/json?all=true']); +}); + +test('the version found is reused without asking again', async () => { + seen = []; + await dockerGet(versionedBase, '/containers/json?all=true'); + assert.deepEqual(seen, ['/v1.53/containers/json?all=true']); +}); + +test('a refusal that a versioned path does not lift is returned as it is', async () => { + seen = []; + const r = await dockerGet(blockedBase, '/containers/json?all=true'); + assert.equal(r.status, 403); + seen = []; + await dockerGet(blockedBase, '/containers/json?all=true'); + assert.equal(seen[0], '/containers/json?all=true'); +}); + +test('the socket proxy test passes on a proxy that allows only versioned paths', async () => { + const body = JSON.stringify({ url: `${versionedBase}/` }); + const r = await fetch(`${apiBase}/api/docker/test`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: apiBase }, + body, + }); + const out = await r.json(); + assert.equal(out.ok, true); + assert.equal(out.version, '1.53'); +}); diff --git a/api/test/docker-labels.test.js b/api/test/docker-labels.test.js new file mode 100644 index 00000000..1a4896ff --- /dev/null +++ b/api/test/docker-labels.test.js @@ -0,0 +1,140 @@ +const path = require('node:path'); + +const { tmpDir } = require('../test-support/tmp'); +process.env.CONFIG_PATH = path.join(tmpDir('docker-labels'), 'apps.json'); +delete process.env.SOCKET_PROXY_URL; + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); + +require('../src/routes'); +const { dispatch } = require('../src/router'); +const { loadConfig, saveConfig } = require('../src/config'); +const { homepageLabels } = require('../src/docker-labels'); + +let server, base, proxy, proxyBase; +let reply = { status: 200, body: '[]' }; +let lastPath = ''; + +const listen = s => new Promise(r => s.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${s.address().port}`))); +const close = s => + new Promise(r => { + s.closeAllConnections?.(); + s.close(r); + }); + +before(async () => { + proxy = http.createServer((req, res) => { + lastPath = req.url; + res.writeHead(reply.status, { 'Content-Type': 'application/json' }); + res.end(reply.body); + }); + proxyBase = await listen(proxy); + server = http.createServer(dispatch); + base = await listen(server); +}); +after(async () => { + await close(server); + await close(proxy); +}); + +function setSocket(url) { + const cfg = loadConfig(); + cfg.settings = { ...cfg.settings, server: { ...cfg.settings?.server, socketProxyUrl: url } }; + saveConfig(cfg); +} + +async function labels() { + const r = await fetch(`${base}/api/docker/labels`); + return { status: r.status, body: await r.json() }; +} + +const EMBY = { + Names: ['/emby'], + Labels: { + 'homepage.group': 'Media', + 'homepage.name': 'Emby', + 'homepage.href': 'http://emby.home/', + 'homepage.icon': 'emby.png', + 'homepage.widget.type': 'emby', + 'homepage.widget.key': 'secret-api-key', + 'com.docker.compose.project': 'media', + }, +}; + +test('only listed homepage fields leave the server', () => { + const [c] = homepageLabels([EMBY]); + assert.equal(c.container, 'emby'); + assert.deepEqual(c.labels, { name: 'Emby', group: 'Media', href: 'http://emby.home/', icon: 'emby.png' }); + assert.equal(c.widget, true); + assert.ok(!JSON.stringify(c).includes('secret-api-key')); +}); + +test('containers without homepage labels are left out', () => { + assert.deepEqual(homepageLabels([{ Names: ['/db'], Labels: { 'com.docker.compose.service': 'db' } }, {}, null]), []); + assert.deepEqual(homepageLabels({ message: 'not a list' }), []); +}); + +test('a container with only instance labels is listed without a name', () => { + const [c] = homepageLabels([{ Names: ['/x'], Labels: { 'homepage.instance.lan.name': 'X' } }]); + assert.deepEqual(c.labels, {}); +}); + +test('indexed widget labels count as a widget', () => { + const [c] = homepageLabels([{ Names: ['/x'], Labels: { 'homepage.name': 'X', 'homepage.widgets[0].type': 'y' } }]); + assert.equal(c.widget, true); +}); + +test('the route asks for a socket proxy when none is set', async () => { + setSocket(''); + const r = await labels(); + assert.equal(r.status, 400); + assert.equal(r.body.code, 'invalid.socket-missing'); +}); + +test('the route reads every container through the socket proxy', async () => { + setSocket(`${proxyBase}/`); + reply = { status: 200, body: JSON.stringify([EMBY]) }; + const r = await labels(); + assert.equal(r.status, 200); + assert.equal(lastPath, '/containers/json?all=true'); + assert.equal(r.body.containers.length, 1); + assert.ok(!JSON.stringify(r.body).includes('secret-api-key')); +}); + +test('a proxy that refuses the container list is reported', async () => { + setSocket(proxyBase); + reply = { status: 403, body: '{}' }; + const r = await labels(); + assert.equal(r.status, 502); + assert.equal(r.body.code, 'upstream.refused'); +}); + +test('an error status from the proxy is reported with its status', async () => { + setSocket(proxyBase); + reply = { status: 500, body: '{"message":"boom"}' }; + const r = await labels(); + assert.equal(r.body.code, 'upstream.status'); + assert.equal(r.body.detail.status, 500); +}); + +test('an answer that is not a container list is reported as not Docker', async () => { + setSocket(proxyBase); + reply = { status: 200, body: '{"message":"page not found"}' }; + const r = await labels(); + assert.equal(r.status, 502); + assert.equal(r.body.code, 'upstream.not-docker'); +}); + +test('a container list past the read limit is reported as too many containers', async () => { + setSocket(proxyBase); + const pad = 'x'.repeat(64 * 1024); + reply = { + status: 200, + body: JSON.stringify(Array.from({ length: 300 }, (_, i) => ({ Names: [`/c${i}`], Labels: { pad } }))), + }; + const r = await labels(); + assert.equal(r.status, 502); + assert.equal(r.body.code, 'upstream.too-many-containers'); +}); diff --git a/api/test/ping-link-local.test.js b/api/test/ping-link-local.test.js new file mode 100644 index 00000000..65b1ea02 --- /dev/null +++ b/api/test/ping-link-local.test.js @@ -0,0 +1,46 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); + +const resolver = require('../src/resolve'); +const { pingUnchecked } = require('../src/proxy'); + +test('a link-local address is never pinged', async () => { + for (const url of [ + 'http://169.254.169.254/latest/meta-data', + 'http://[fe80::1]/', + 'http://[::ffff:169.254.169.254]/', + ]) { + const r = await pingUnchecked(url, 1000); + assert.equal(r.code, 'blocked.link-local', url); + } +}); + +test('a name that resolves to a link-local address is never pinged', async t => { + t.mock.method(resolver, 'resolveHost', async () => ({ address: '169.254.169.254', family: 4 })); + const r = await pingUnchecked('http://metadata.google.internal/', 1000); + assert.equal(r.code, 'blocked.link-local'); +}); + +test('a private address is still pinged', async () => { + const server = http.createServer((_, res) => res.end('ok')); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + try { + const r = await pingUnchecked(`http://127.0.0.1:${server.address().port}/`, 2000); + assert.equal(r.ok, true); + } finally { + server.close(); + } +}); + +test('a name that resolves to a private address is pinged at that address', async t => { + const server = http.createServer((_, res) => res.end('ok')); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + t.mock.method(resolver, 'resolveHost', async () => ({ address: '127.0.0.1', family: 4 })); + try { + const r = await pingUnchecked(`http://nas.example:${server.address().port}/`, 2000); + assert.equal(r.ok, true); + } finally { + server.close(); + } +}); diff --git a/ui/admin/index.html b/ui/admin/index.html index 2a66528c..8e983f5f 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -11,7 +11,7 @@ - + @@ -128,8 +128,9 @@
Import / Export
Import from another dashboard
+
Import from Docker labels
-

Reads gethomepage and Dashy YAML files. Links and folders are added to your dashboard; widgets from the other dashboard become plain links.

+

Reads gethomepage and Dashy YAML files, and gethomepage labels on your containers through the Docker Socket URL. Links and folders are added to your dashboard; widgets from the other dashboard become plain links.

@@ -318,6 +319,6 @@

General

- + diff --git a/ui/css/admin.css b/ui/css/admin.css index b1fd9f0c..2b4f4ed7 100644 --- a/ui/css/admin.css +++ b/ui/css/admin.css @@ -392,6 +392,7 @@ button.rnm{font-family:inherit;color:inherit;background:none;border:0;padding:0; .row-status.ok{color:var(--ok)} .row-status.err{color:var(--danger)} .row-status.warn{color:var(--warning)} +.grp-tip.warn{color:var(--warning)} /* ── Textarea row (Add to URL / Add to Header) ── */ .kv-hdr{padding-bottom:4px} @@ -596,7 +597,7 @@ a:focus-visible,button:focus-visible,[role="button"]:focus-visible{outline:2px s .dlg-lead{padding:0 8px 8px;font-size:var(--fs-body);line-height:var(--lh-body);letter-spacing:var(--tr-body)} .dlg-sec{padding:12px 12px 4px;font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);font-weight:500;color:var(--dm)} .dlg-ul{list-style:none;margin:0;padding:0 12px 8px;display:flex;flex-direction:column;gap:4px} -.dlg-li{display:flex;align-items:baseline;gap:8px;font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote)} +.dlg-li{display:flex;align-items:baseline;gap:8px;overflow-wrap:anywhere;font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote)} .dlg-li .dlg-why{color:var(--dm);font-size:var(--fs-caption-1);line-height:var(--lh-caption-1);letter-spacing:var(--tr-caption-1)} .dlg-choice{display:flex;align-items:flex-start;gap:8px;padding:0 12px 4px;font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);cursor:pointer} .dlg-choice input{margin-block-start:2px;flex:none} diff --git a/ui/i18n/de.json b/ui/i18n/de.json index 54e8d6eb..6715c7f3 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -61,7 +61,8 @@ "importExport": "Import / Export", "signOut": "Abmelden", "importForeign": "Aus anderem Dashboard importieren", - "importForeignTip": "Liest YAML-Dateien von gethomepage und Dashy. Links und Ordner werden deinem Dashboard hinzugefügt; Widgets des anderen Dashboards werden zu einfachen Links.", + "importDocker": "Aus Docker-Labels importieren", + "importForeignTip": "Liest YAML-Dateien von gethomepage und Dashy sowie gethomepage-Labels deiner Container über die Docker-Socket-URL. Links und Ordner werden deinem Dashboard hinzugefügt; Widgets des anderen Dashboards werden zu einfachen Links.", "logDebug": "Debug", "logInfo": "Info", "logError": "Fehler", @@ -208,6 +209,7 @@ "username": "Benutzername", "password": "Passwort", "urlHasLogin": "Gib Benutzername und Passwort unter Anmeldung ein. Eine Anmeldung in der URL wird nicht gesendet.", + "hrefHasLogin": "Jeder, der das Dashboard öffnen kann, kann Benutzername und Passwort in diesem Link lesen. Entferne sie und melde dich auf der Seite des Dienstes selbst an.", "unit": "Einheit", "unitPh": "z. B. GB", "badgeMin": "Anzeigen ab", @@ -283,6 +285,7 @@ "invalidUrl": "Das ist keine gültige http- oder https-Adresse.", "socketRefused": "Der Socket-Proxy hat die Anfrage abgelehnt.", "notSocketProxy": "Dort hat etwas geantwortet, aber es ist kein Docker-Socket-Proxy.", + "socketMissing": "Schalte unter „Allgemein“ die Zustandsprüfungen ein und gib die Docker-Socket-URL ein.", "noConnection": "Die Verbindung ist fehlgeschlagen.", "duplicateId": "Zwei Einträge haben dieselbe ID {id}. Jeder Eintrag braucht eine eigene ID.", "missingChildren": "Der Ordner {id} verweist auf Einträge, die nicht in der Datei sind.", @@ -377,6 +380,8 @@ "authNeedsPassword": "Lege ein Passwort fest, bevor du die Authentifizierung aktivierst.", "importForeignDone": "Import abgeschlossen. Apps: {apps}. Ordner: {folders}.", "importForeignNothing": "Aus diesen Dateien gibt es nichts zu importieren.", + "importDockerNothing": "Kein Container hat sowohl ein Label homepage.name als auch homepage.group.", + "importDockerTooLarge": "Docker listet zu viele Container, um sie auf einmal zu importieren.", "importYamlUnsupported": "{file}: Zeile {line} enthält YAML, das der Import nicht lesen kann.", "importUnknownFormat": "{file} ist keine gethomepage- oder Dashy-Konfiguration.", "importTooLarge": "{file} enthält zu viele Einträge für einen Import.", @@ -533,6 +538,8 @@ "notePing": "ICMP-Ping verworfen: {detail}", "noteSchemeAdded": "http:// zum Link hinzugefügt: {detail}", "noteHealthUrl": "Adresse der Zustandsprüfung verworfen, sie ist keine vollständige Webadresse: {detail}", + "noteNameShortened": "Name zu lang, gekürzt", + "noteLoginRemoved": "Benutzername und Passwort aus der Adresse entfernt: {detail}", "noteRemoteContainer": "Container ignoriert, er läuft auf {detail}", "noteFlattened": "verschachtelte Gruppe wurde ein eigener Ordner", "noteSubItems": "Untereintrag in den Ordner verschoben", @@ -546,6 +553,7 @@ "skipRelativeHref": "der Link hat keine Adresse, nur einen Pfad", "skipUnreadable": "dieser Eintrag hat nicht die Form, die das Dateiformat erlaubt", "skipUnparsable": "Zeile {detail} konnte nicht gelesen werden", + "skipNoNameOrGroup": "der Container hat kein Label homepage.name oder homepage.group", "monitors": "· fragt {url} ab" }, "login": { diff --git a/ui/i18n/en.json b/ui/i18n/en.json index ebcdcaab..d9a75e52 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -61,7 +61,8 @@ "importExport": "Import / Export", "signOut": "Sign out", "importForeign": "Import from another dashboard", - "importForeignTip": "Reads gethomepage and Dashy YAML files. Links and folders are added to your dashboard; widgets from the other dashboard become plain links.", + "importDocker": "Import from Docker labels", + "importForeignTip": "Reads gethomepage and Dashy YAML files, and gethomepage labels on your containers through the Docker Socket URL. Links and folders are added to your dashboard; widgets from the other dashboard become plain links.", "logDebug": "Debug", "logInfo": "Info", "logError": "Errors", @@ -208,6 +209,7 @@ "username": "Username", "password": "Password", "urlHasLogin": "Enter the username and password under Sign In. A login in the URL is not sent.", + "hrefHasLogin": "Anyone who can open the dashboard can read a username and password in this link. Remove them and sign in on the service’s own page.", "unit": "Unit", "unitPh": "e.g. GB", "badgeMin": "Show From", @@ -283,6 +285,7 @@ "invalidUrl": "That is not a valid http or https address.", "socketRefused": "The socket proxy refused the request.", "notSocketProxy": "Something answered there, but it is not a Docker socket proxy.", + "socketMissing": "Turn on Health Checks in General and enter the Docker Socket URL.", "noConnection": "The connection failed.", "duplicateId": "Two items share the id {id}. Each item needs its own id.", "missingChildren": "Some items in the folder {id} are not in the file.", @@ -377,6 +380,8 @@ "authNeedsPassword": "Set a password before turning authentication on.", "importForeignDone": "Import finished. Apps: {apps}. Folders: {folders}.", "importForeignNothing": "Nothing to import from those files.", + "importDockerNothing": "No container has both a homepage.name and a homepage.group label.", + "importDockerTooLarge": "Docker lists too many containers to import at once.", "importYamlUnsupported": "{file}: the importer cannot read the YAML on line {line}.", "importUnknownFormat": "{file} is not a gethomepage or Dashy config.", "importTooLarge": "{file} has too many entries to import.", @@ -533,6 +538,8 @@ "notePing": "ICMP ping dropped: {detail}", "noteSchemeAdded": "http:// added to the link: {detail}", "noteHealthUrl": "health check address dropped, it is not a full web address: {detail}", + "noteNameShortened": "name too long, shortened", + "noteLoginRemoved": "username and password removed from the address: {detail}", "noteRemoteContainer": "container ignored, it runs on {detail}", "noteFlattened": "nested group became its own folder", "noteSubItems": "sub-item moved into the folder", @@ -546,6 +553,7 @@ "skipRelativeHref": "the link has no address, only a path", "skipUnreadable": "this entry is not in a shape the file format allows", "skipUnparsable": "line {detail} could not be read", + "skipNoNameOrGroup": "the container has no homepage.name or homepage.group label", "monitors": "· polls {url}" }, "login": { diff --git a/ui/i18n/es.json b/ui/i18n/es.json index 3b143c54..38892f33 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -61,7 +61,8 @@ "importExport": "Importar / Exportar", "signOut": "Cerrar sesión", "importForeign": "Importar desde otro panel", - "importForeignTip": "Lee archivos YAML de gethomepage y Dashy. Los enlaces y las carpetas se añaden a tu panel; los widgets del otro panel se convierten en enlaces simples.", + "importDocker": "Importar desde etiquetas de Docker", + "importForeignTip": "Lee archivos YAML de gethomepage y Dashy, y las etiquetas de gethomepage de tus contenedores a través de la URL del socket de Docker. Los enlaces y las carpetas se añaden a tu panel; los widgets del otro panel se convierten en enlaces simples.", "logDebug": "Depuración", "logInfo": "Información", "logError": "Errores", @@ -211,6 +212,7 @@ "username": "Usuario", "password": "Contraseña", "urlHasLogin": "Introduce el usuario y la contraseña en Inicio de sesión. Los datos de acceso de la URL no se envían.", + "hrefHasLogin": "Cualquiera que pueda abrir el panel puede leer el usuario y la contraseña de este enlace. Quítalos e inicia sesión en la propia página del servicio.", "unit": "Unidad", "unitPh": "p. ej. GB", "badgeMin": "Mostrar desde", @@ -289,6 +291,7 @@ "invalidUrl": "Esa no es una dirección http o https válida.", "socketRefused": "El proxy de socket rechazó la solicitud.", "notSocketProxy": "Algo respondió ahí, pero no es un proxy de socket de Docker.", + "socketMissing": "Activa Comprobaciones de estado en General e introduce la URL del socket de Docker.", "noConnection": "La conexión falló.", "duplicateId": "Dos elementos comparten el id {id}. Cada elemento necesita su propio id.", "missingChildren": "La carpeta {id} incluye elementos que no están en el archivo.", @@ -384,6 +387,8 @@ "authNeedsPassword": "Establece una contraseña antes de activar la autenticación.", "importForeignDone": "Importación terminada. Aplicaciones: {apps}. Carpetas: {folders}.", "importForeignNothing": "No hay nada que importar en esos archivos.", + "importDockerNothing": "Ningún contenedor tiene a la vez la etiqueta homepage.name y homepage.group.", + "importDockerTooLarge": "Docker muestra demasiados contenedores para importarlos de una vez.", "importYamlUnsupported": "{file}: la línea {line} usa YAML que el importador no puede leer.", "importUnknownFormat": "{file} no es una configuración de gethomepage ni de Dashy.", "importTooLarge": "{file} tiene demasiadas entradas para importarlas.", @@ -547,6 +552,8 @@ "notePing": "ping ICMP descartado: {detail}", "noteSchemeAdded": "http:// añadido al enlace: {detail}", "noteHealthUrl": "dirección de comprobación de estado descartada, no es una dirección web completa: {detail}", + "noteNameShortened": "nombre demasiado largo, acortado", + "noteLoginRemoved": "usuario y contraseña quitados de la dirección: {detail}", "noteRemoteContainer": "contenedor ignorado, se ejecuta en {detail}", "noteFlattened": "el grupo anidado pasó a ser su propia carpeta", "noteSubItems": "subelemento movido a la carpeta", @@ -560,6 +567,7 @@ "skipRelativeHref": "el enlace no tiene dirección, solo una ruta", "skipUnreadable": "esta entrada no tiene la forma que permite el formato del archivo", "skipUnparsable": "no se pudo leer la línea {detail}", + "skipNoNameOrGroup": "el contenedor no tiene la etiqueta homepage.name o homepage.group", "monitors": "· sondea {url}" }, "login": { diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index 1fdd2e8b..50c02409 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -61,7 +61,8 @@ "importExport": "وارد کردن / خروجی گرفتن", "signOut": "خروج از حساب", "importForeign": "وارد کردن از داشبورد دیگر", - "importForeignTip": "فایل‌های YAML مربوط به gethomepage و Dashy را می‌خواند. پیوندها و پوشه‌ها به داشبورد شما افزوده می‌شوند؛ ویجت‌های داشبورد دیگر به پیوند ساده تبدیل می‌شوند.", + "importDocker": "وارد کردن از لیبل‌های Docker", + "importForeignTip": "فایل‌های YAML مربوط به gethomepage و Dashy و لیبل‌های gethomepage روی کانتینرهای شما را از راه آدرس سوکت Docker می‌خواند. پیوندها و پوشه‌ها به داشبورد شما افزوده می‌شوند؛ ویجت‌های داشبورد دیگر به پیوند ساده تبدیل می‌شوند.", "logDebug": "اشکال‌زدایی", "logInfo": "اطلاعات", "logError": "خطاها", @@ -208,6 +209,7 @@ "username": "نام کاربری", "password": "رمز عبور", "urlHasLogin": "نام کاربری و رمز عبور را در بخش ورود وارد کنید. اطلاعات ورود داخل آدرس ارسال نمی‌شود.", + "hrefHasLogin": "هر کسی که داشبورد را باز کند، می‌تواند نام کاربری و رمز عبور این پیوند را ببیند. آن‌ها را حذف کنید و در صفحه خود سرویس وارد شوید.", "unit": "واحد", "unitPh": "مثلا GB", "badgeMin": "نمایش از", @@ -283,6 +285,7 @@ "invalidUrl": "این یک آدرس http یا https معتبر نیست.", "socketRefused": "پراکسی سوکت درخواست را رد کرد.", "notSocketProxy": "چیزی در آن آدرس پاسخ داد، اما پراکسی سوکت Docker نیست.", + "socketMissing": "در بخش «عمومی» بررسی سلامت را روشن کنید و آدرس سوکت Docker را وارد کنید.", "noConnection": "اتصال برقرار نشد.", "duplicateId": "دو مورد شناسه یکسان {id} دارند. هر مورد به شناسه خودش نیاز دارد.", "missingChildren": "پوشه {id} به مواردی اشاره می‌کند که در فایل نیستند.", @@ -377,6 +380,8 @@ "authNeedsPassword": "پیش از فعال‌سازی احراز هویت، یک رمز عبور تعیین کنید.", "importForeignDone": "وارد کردن پایان یافت. برنامه‌ها: {apps}. پوشه‌ها: {folders}.", "importForeignNothing": "در این فایل‌ها چیزی برای وارد کردن نیست.", + "importDockerNothing": "هیچ کانتینری هر دو لیبل homepage.name و homepage.group را ندارد.", + "importDockerTooLarge": "Docker کانتینرهای زیادی را فهرست می‌کند و نمی‌توان همه را یک‌جا وارد کرد.", "importYamlUnsupported": "فایل {file}: YAML در خط {line} قابل خواندن نیست.", "importUnknownFormat": "فایل {file} پیکربندی gethomepage یا Dashy نیست.", "importTooLarge": "فایل {file} موارد زیادی برای وارد کردن دارد.", @@ -533,6 +538,8 @@ "notePing": "پینگ ICMP کنار گذاشته شد: {detail}", "noteSchemeAdded": "http:// به پیوند افزوده شد: {detail}", "noteHealthUrl": "آدرس بررسی سلامت کنار گذاشته شد، آدرس وب کامل نیست: {detail}", + "noteNameShortened": "نام بیش از حد بلند بود و کوتاه شد", + "noteLoginRemoved": "نام کاربری و رمز عبور از آدرس حذف شد: {detail}", "noteRemoteContainer": "کانتینر نادیده گرفته شد، روی {detail} اجرا می‌شود", "noteFlattened": "گروه تودرتو به پوشه‌ای جداگانه تبدیل شد", "noteSubItems": "زیرمورد به پوشه منتقل شد", @@ -546,6 +553,7 @@ "skipRelativeHref": "پیوند آدرس ندارد، فقط یک مسیر", "skipUnreadable": "این ورودی ساختاری ندارد که قالب فایل بپذیرد", "skipUnparsable": "خط {detail} خوانده نشد", + "skipNoNameOrGroup": "کانتینر لیبل homepage.name یا homepage.group ندارد", "monitors": "· بررسی {url}" }, "login": { diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index f924d9a6..956233c2 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -61,7 +61,8 @@ "importExport": "Importer / Exporter", "signOut": "Se déconnecter", "importForeign": "Importer depuis un autre tableau de bord", - "importForeignTip": "Lit les fichiers YAML de gethomepage et Dashy. Les liens et les dossiers sont ajoutés à votre tableau de bord ; les widgets de l’autre tableau de bord deviennent de simples liens.", + "importDocker": "Importer depuis les labels Docker", + "importForeignTip": "Lit les fichiers YAML de gethomepage et Dashy, ainsi que les labels gethomepage de vos conteneurs via l’URL du socket Docker. Les liens et les dossiers sont ajoutés à votre tableau de bord ; les widgets de l’autre tableau de bord deviennent de simples liens.", "logDebug": "Débogage", "logInfo": "Info", "logError": "Erreurs", @@ -211,6 +212,7 @@ "username": "Nom d’utilisateur", "password": "Mot de passe", "urlHasLogin": "Saisissez le nom d’utilisateur et le mot de passe sous Connexion. Les identifiants dans l’URL ne sont pas envoyés.", + "hrefHasLogin": "Toute personne qui peut ouvrir le tableau de bord peut lire le nom d’utilisateur et le mot de passe de ce lien. Retirez-les et connectez-vous sur la page du service.", "unit": "Unité", "unitPh": "p. ex. Go", "badgeMin": "Afficher à partir de", @@ -289,6 +291,7 @@ "invalidUrl": "Ce n’est pas une adresse http ou https valide.", "socketRefused": "Le proxy de socket a refusé la requête.", "notSocketProxy": "Quelque chose a répondu, mais ce n’est pas un proxy de socket Docker.", + "socketMissing": "Activez Vérifications de l’état dans Général et saisissez l’URL du socket Docker.", "noConnection": "La connexion a échoué.", "duplicateId": "Deux éléments partagent l’identifiant {id}. Chaque élément doit avoir son propre identifiant.", "missingChildren": "Le dossier {id} contient des éléments absents du fichier.", @@ -384,6 +387,8 @@ "authNeedsPassword": "Définissez un mot de passe avant d’activer l’authentification.", "importForeignDone": "Importation terminée. Applications : {apps}. Dossiers : {folders}.", "importForeignNothing": "Rien à importer dans ces fichiers.", + "importDockerNothing": "Aucun conteneur n’a à la fois un label homepage.name et un label homepage.group.", + "importDockerTooLarge": "Docker liste trop de conteneurs pour les importer en une seule fois.", "importYamlUnsupported": "{file} : la ligne {line} utilise du YAML que l’importation ne sait pas lire.", "importUnknownFormat": "{file} n’est pas une configuration gethomepage ou Dashy.", "importTooLarge": "{file} contient trop d’entrées pour être importé.", @@ -547,6 +552,8 @@ "notePing": "ping ICMP ignoré : {detail}", "noteSchemeAdded": "http:// ajouté au lien : {detail}", "noteHealthUrl": "adresse de vérification de l’état ignorée, ce n’est pas une adresse web complète : {detail}", + "noteNameShortened": "nom trop long, raccourci", + "noteLoginRemoved": "nom d’utilisateur et mot de passe retirés de l’adresse : {detail}", "noteRemoteContainer": "conteneur ignoré, il tourne sur {detail}", "noteFlattened": "le groupe imbriqué est devenu un dossier distinct", "noteSubItems": "sous-élément déplacé dans le dossier", @@ -560,6 +567,7 @@ "skipRelativeHref": "le lien n’a pas d’adresse, seulement un chemin", "skipUnreadable": "cette entrée n’a pas la forme autorisée par le format du fichier", "skipUnparsable": "la ligne {detail} n’a pas pu être lue", + "skipNoNameOrGroup": "le conteneur n’a pas de label homepage.name ou homepage.group", "monitors": "· interroge {url}" }, "login": { diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json index 997e698d..79372d63 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -61,7 +61,8 @@ "importExport": "导入 / 导出", "signOut": "退出登录", "importForeign": "从其他仪表板导入", - "importForeignTip": "读取 gethomepage 与 Dashy 的 YAML 文件。链接和文件夹会添加到你的仪表板;其他仪表板的小组件会变成普通链接。", + "importDocker": "从 Docker 标签导入", + "importForeignTip": "读取 gethomepage 与 Dashy 的 YAML 文件,并通过 Docker 套接字代理地址读取容器上的 gethomepage 标签。链接和文件夹会添加到你的仪表板;其他仪表板的小组件会变成普通链接。", "logDebug": "调试", "logInfo": "信息", "logError": "错误", @@ -205,6 +206,7 @@ "username": "用户名", "password": "密码", "urlHasLogin": "请在“登录”中填写用户名和密码。URL 中的登录信息不会发送。", + "hrefHasLogin": "任何能打开仪表板的人都能看到此链接中的用户名和密码。请移除它们,并在服务自己的页面登录。", "unit": "单位", "unitPh": "例如 GB", "badgeMin": "显示阈值", @@ -277,6 +279,7 @@ "invalidUrl": "这不是有效的 http 或 https 地址。", "socketRefused": "套接字代理拒绝了该请求。", "notSocketProxy": "该地址有响应,但不是 Docker 套接字代理。", + "socketMissing": "请在“通用”中打开健康检查,并填写 Docker 套接字代理地址。", "noConnection": "连接失败。", "duplicateId": "两个项目使用了相同的 ID {id}。每个项目都需要自己的 ID。", "missingChildren": "文件夹 {id} 列出了文件中不存在的项目。", @@ -370,6 +373,8 @@ "authNeedsPassword": "启用身份验证前请先设置密码。", "importForeignDone": "导入完成。应用:{apps}。文件夹:{folders}。", "importForeignNothing": "这些文件中没有可导入的内容。", + "importDockerNothing": "没有容器同时带有 homepage.name 和 homepage.group 标签。", + "importDockerTooLarge": "Docker 列出的容器太多,无法一次导入。", "importYamlUnsupported": "{file}:第 {line} 行使用了导入程序无法读取的 YAML。", "importUnknownFormat": "{file} 不是 gethomepage 或 Dashy 配置。", "importTooLarge": "{file} 的条目太多,无法导入。", @@ -519,6 +524,8 @@ "notePing": "已舍弃 ICMP ping:{detail}", "noteSchemeAdded": "已为链接添加 http://:{detail}", "noteHealthUrl": "已舍弃健康检查地址,它不是完整的网址:{detail}", + "noteNameShortened": "名称过长,已缩短", + "noteLoginRemoved": "已从地址中移除用户名和密码:{detail}", "noteRemoteContainer": "已忽略容器,它运行在 {detail}", "noteFlattened": "嵌套分组已成为独立文件夹", "noteSubItems": "子项已移入该文件夹", @@ -532,6 +539,7 @@ "skipRelativeHref": "链接没有地址,只有路径", "skipUnreadable": "此条目不符合该文件格式允许的结构", "skipUnparsable": "无法读取第 {detail} 行", + "skipNoNameOrGroup": "容器没有 homepage.name 或 homepage.group 标签", "monitors": "· 轮询 {url}" }, "login": { diff --git a/ui/index.html b/ui/index.html index e4d57689..0892a1ac 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 5a3434ff..0c59e44f 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -9,7 +9,7 @@ import { throughServer, } from '/js/icons.js?v=5ac01d72'; import { state } from '/js/admin-state.js?v=064b9b85'; -import { DOCK_MAX } from '/js/limits.js?v=31048a24'; +import { DOCK_MAX, NAME_MAX } from '/js/limits.js?v=ea61a441'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { isDockBlocked, @@ -33,11 +33,11 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=8fec4e2f'; +} from '/js/admin-shared.js?v=3e9cb9c8'; import { createListbox } from '/js/listbox.js?v=3e47b403'; import { MAX_LABELS } from '/js/badge-logic.js?v=85b1bc96'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=d444b337'; -import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=a60b17ac'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=8c27f165'; +import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=1c258dc1'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=8b4aadd4'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; @@ -58,7 +58,7 @@ export function buildFolderForm(body, item) {
${t('folder.name')} ${item?.label || t('folder.namePh')} - +
@@ -158,6 +158,7 @@ export function buildAppForm(body, item) { ${ier('ie-name', t('app.name'), 'f-lbl', item?.label, t('app.namePh'))} ${ier('ie-url', t('app.url'), 'f-href', item?.href, t('app.urlPh'), 'url')}
+

${t('app.hrefHasLogin')}

${t('app.icon')}

@@ -247,13 +248,20 @@ export function buildAppForm(body, item) {

${t('app.selfSignedTip')}

`, ); + const nameInput = inpById('f-lbl'); + if (nameInput) nameInput.maxLength = NAME_MAX; initInlineEdit('ie-name', 'f-lbl', { placeholder: t('app.namePh'), onCommit() { updPrev(); }, }); - initInlineEdit('ie-url', 'f-href', { placeholder: t('app.urlPh') }); + initInlineEdit('ie-url', 'f-href', { + placeholder: t('app.urlPh'), + onCommit(value) { + reveal(el('href-login'), urlHasLogin(value)); + }, + }); initInlineEdit('ie-hc-con', 'hc-con', { placeholder: t('app.containerPh') }); initInlineEdit('ie-hc-ping', 'hc-ping', { placeholder: t('app.pingUrlPh') }); initInlineEdit('ie-static-label', 'f-static-label', { placeholder: t('app.labelPh') }); diff --git a/ui/js/admin-auth.js b/ui/js/admin-auth.js index 41c5f3d4..fa415550 100644 --- a/ui/js/admin-auth.js +++ b/ui/js/admin-auth.js @@ -1,6 +1,6 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=8fec4e2f'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=3e9cb9c8'; import { initI18n, t } from '/js/i18n.js?v=ed7c5565'; -import { loginErrorKey } from '/js/admin-error.js?v=a60b17ac'; +import { loginErrorKey } from '/js/admin-error.js?v=1c258dc1'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { el, inp as inpById, qa } from '/js/utils.js?v=cfee1271'; import { blockingScreenFor } from '/js/config-recovery.js?v=85138697'; diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index 0c6b6920..f37f6750 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -1,4 +1,4 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=8fec4e2f'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=3e9cb9c8'; 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=cfee1271'; diff --git a/ui/js/admin-drag.js b/ui/js/admin-drag.js index bf5bf960..6f7e623c 100644 --- a/ui/js/admin-drag.js +++ b/ui/js/admin-drag.js @@ -10,9 +10,9 @@ it is near, and which classes say so. */ import { applyDrop, canDropInGroup, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=1d1d553e'; -import { DOCK_MAX } from '/js/limits.js?v=31048a24'; +import { DOCK_MAX } from '/js/limits.js?v=ea61a441'; import { state } from '/js/admin-state.js?v=064b9b85'; -import { snapshotItems } from '/js/admin-save-logic.js?v=32cb6aeb'; +import { snapshotItems } from '/js/admin-save-logic.js?v=b83791fb'; import { qa } from '/js/utils.js?v=cfee1271'; /** @type {(before: unknown) => void} */ diff --git a/ui/js/admin-error.js b/ui/js/admin-error.js index 5140bcfc..27eb5dd9 100644 --- a/ui/js/admin-error.js +++ b/ui/js/admin-error.js @@ -31,6 +31,8 @@ const BY_CODE = Object.freeze({ 'invalid.url': 'adminError.invalidUrl', 'upstream.refused': 'adminError.socketRefused', 'upstream.not-docker': 'adminError.notSocketProxy', + 'invalid.socket-missing': 'adminError.socketMissing', + 'upstream.too-many-containers': 'toast.importDockerTooLarge', 'blocked.unresolved': 'adminError.unreachable', 'invalid.duplicate-id': 'adminError.duplicateId', 'invalid.missing-children': 'adminError.missingChildren', diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index fb6e1a42..8af2015c 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -12,17 +12,17 @@ reads and writes them too. */ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=064b9b85'; -import { snapshotItems } from '/js/admin-save-logic.js?v=32cb6aeb'; +import { snapshotItems } from '/js/admin-save-logic.js?v=b83791fb'; import { isShownChild, reorderItems, shownChildren, topLevelRows } from '/js/admin-logic.js?v=b3befae4'; import { listGroup } from '/js/admin-drag-logic.js?v=1d1d553e'; -import { initDrag, wireDockZone, wireRowDrag } from '/js/admin-drag.js?v=14bd8c6c'; -import { DOCK_MAX } from '/js/limits.js?v=31048a24'; +import { initDrag, wireDockZone, wireRowDrag } from '/js/admin-drag.js?v=11a378f9'; +import { DOCK_MAX } from '/js/limits.js?v=ea61a441'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { paintIcon, toast } from '/js/admin-shared.js?v=8fec4e2f'; +import { paintIcon, toast } from '/js/admin-shared.js?v=3e9cb9c8'; import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=cfee1271'; 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=b0b45771'; +import { sizeLabel } from '/js/admin-widget-form.js?v=1a2d4a3a'; import { widgetGlyph } from '/js/widget-glyphs.js?v=479a53ee'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; diff --git a/ui/js/admin-save-logic.js b/ui/js/admin-save-logic.js index f8c2fdd7..9a7fb2d7 100644 --- a/ui/js/admin-save-logic.js +++ b/ui/js/admin-save-logic.js @@ -11,6 +11,7 @@ export function cleanId(label, fallback = 'item') { String(label || '') .replace(/[^a-zA-Z0-9]/g, '_') .replace(/_+/g, '_') + .slice(0, 32) .replace(/^_|_$/g, '') || fallback ); } diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index c08f7fdd..ee7df109 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -1,5 +1,5 @@ -import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=8fec4e2f'; -import { socketProbeAdvice } from '/js/admin-error.js?v=a60b17ac'; +import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=3e9cb9c8'; +import { socketProbeAdvice } from '/js/admin-error.js?v=1c258dc1'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { t } from '/js/i18n.js?v=ed7c5565'; import { @@ -13,8 +13,8 @@ import { import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; import { el, inp, setUserText } from '/js/utils.js?v=cfee1271'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { serialWrites } from '/js/admin-save-logic.js?v=32cb6aeb'; -import { renderColorControl } from '/js/admin-color-control.js?v=d444b337'; +import { serialWrites } from '/js/admin-save-logic.js?v=b83791fb'; +import { renderColorControl } from '/js/admin-color-control.js?v=8c27f165'; import { BACKDROP } from '/js/background.js?v=72db97ac'; import { ALLOWED_HOSTS_MAX, diff --git a/ui/js/admin-shared.js b/ui/js/admin-shared.js index 148da1d2..ae335cc4 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -2,7 +2,7 @@ import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=b3befae4'; import { el, isolate, q } from '/js/utils.js?v=cfee1271'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { errorAdvice } from '/js/admin-error.js?v=a60b17ac'; +import { errorAdvice } from '/js/admin-error.js?v=1c258dc1'; import { formatList, formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { iconChain } from '/js/icons.js?v=5ac01d72'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index c6b0cd24..c82a8ccb 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,12 +1,13 @@ import { state } from '/js/admin-state.js?v=064b9b85'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=8fec4e2f'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=3e9cb9c8'; import { createListbox } from '/js/listbox.js?v=3e47b403'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=7e48b5b8'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=78b9928c'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=b3befae4'; import { t } from '/js/i18n.js?v=ed7c5565'; import { focusFirst, q, qi, qa } from '/js/utils.js?v=cfee1271'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; +import { NAME_MAX } from '/js/limits.js?v=ea61a441'; const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']); @@ -59,7 +60,7 @@ function _renderWidgetForm(body) { setHtml( shell, html` -
${t('widgetCfg.name')}${state._wlabel ? state._wlabel : t('widgetCfg.namePh')}
+
${t('widgetCfg.name')}${state._wlabel ? state._wlabel : t('widgetCfg.namePh')}
${t('widgetCfg.type')}
`, ); body.appendChild(shell); diff --git a/ui/js/admin.js b/ui/js/admin.js index 4288f29d..b09e76cb 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -4,10 +4,10 @@ import { buildSettingsTileForm, captureActLabels, serializeKvRows, -} from '/js/admin-app-form.js?v=6dfcf64d'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=9b2d4d17'; +} from '/js/admin-app-form.js?v=592c677b'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=99cc620c'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=9a75dcf8'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=122e7499'; import { resolveAdminSection, settleDock } from '/js/admin-logic.js?v=b3befae4'; import { afterImport, @@ -21,7 +21,7 @@ import { serialWrites, snapshotItems, upsertItem, -} from '/js/admin-save-logic.js?v=32cb6aeb'; +} from '/js/admin-save-logic.js?v=b83791fb'; import { loadSettings, savedWallpaperUrl, @@ -31,7 +31,7 @@ import { wireUnsplashKey, wireProxmox, applyHealthChecks, -} from '/js/admin-settings.js?v=60c010a8'; +} from '/js/admin-settings.js?v=9f96f864'; import { apiGet, apiPost, @@ -45,9 +45,9 @@ import { setReauthHandler, toast, withheldText, -} from '/js/admin-shared.js?v=8fec4e2f'; +} from '/js/admin-shared.js?v=3e9cb9c8'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=064b9b85'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=b0b45771'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=1a2d4a3a'; import { initFluidHover } from '/js/fluid-hover.js?v=8b4aadd4'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=75a71ab4'; @@ -59,13 +59,14 @@ import { ensureSprite, iconSvg } from '/js/icon-set.js?v=26655aca'; import { clearSkipTls, convert, + convertDockerLabels, detectSource, ImportTooLargeError, insecureApps, NOTE, parseErrorsAsSkipped, SKIP, -} from '/js/import-foreign.js?v=3582433f'; +} from '/js/import-foreign.js?v=c766baa0'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=6b0320bd'; import { @@ -1223,6 +1224,7 @@ const SKIP_TEXT = { [SKIP.RELATIVE_HREF]: 'importForeign.skipRelativeHref', [SKIP.UNREADABLE]: 'importForeign.skipUnreadable', [SKIP.UNPARSABLE]: 'importForeign.skipUnparsable', + [SKIP.NO_NAME_OR_GROUP]: 'importForeign.skipNoNameOrGroup', }; const NOTE_TEXT = { [NOTE.ICON_DROPPED]: 'importForeign.noteIcon', @@ -1237,6 +1239,8 @@ const NOTE_TEXT = { [NOTE.PAGES_NOT_FOLLOWED]: 'importForeign.notePages', [NOTE.SCHEME_ADDED]: 'importForeign.noteSchemeAdded', [NOTE.HEALTH_URL_DROPPED]: 'importForeign.noteHealthUrl', + [NOTE.NAME_SHORTENED]: 'importForeign.noteNameShortened', + [NOTE.LOGIN_REMOVED]: 'importForeign.noteLoginRemoved', }; /** One "Heading (n)" block followed by a line per entry. @@ -1301,107 +1305,126 @@ el('imp-foreign').onchange = async e => { skipped.push(...parseErrorsAsSkipped(parseErrors, file.name), ...out.skipped); notes.push(...out.notes); } + await reviewImport(items, skipped, notes, 'toast.importForeignNothing'); + } catch (err) { + toast(t('toast.importFailed', { err: errorText(err) }), 'err'); + } + input.value = ''; +}; - const apps = items.filter(i => i.type === 'app').length; - const folders = items.length - apps; - if (!apps) { - toast(t('toast.importForeignNothing'), 'err'); - input.value = ''; - return; +el('btn-imp-docker').onclick = async () => { + const btn = /** @type {HTMLButtonElement} */ (el('btn-imp-docker')); + btn.disabled = true; + try { + const r = await apiGet('/api/docker/labels'); + let out; + try { + out = convertDockerLabels(r.containers, new Set(state.items.map(i => i.id))); + } catch (err) { + if (err instanceof ImportTooLargeError) throw new ShownError(t('toast.importDockerTooLarge')); + throw err; } + await reviewImport(out.items, out.skipped, out.notes, 'toast.importDockerNothing'); + } catch (err) { + toast(t('toast.importFailed', { err: errorText(err) }), 'err'); + } + btn.disabled = false; +}; - const body = document.createElement('div'); - const lead = document.createElement('p'); - lead.className = 'dlg-lead'; - lead.textContent = t('importForeign.lead'); - body.appendChild(lead); - dlgSection( - body, - t('importForeign.willAdd'), - items - .filter(i => i.type === 'app') - .map(i => { - const ping = i.monitoring?.healthcheck?.pingUrl; - return { - name: i.label, - group: '', - why: ping ? `${i.href} ${t('importForeign.monitors', { url: ping })}` : i.href, - }; - }), - ); - dlgSection( - body, - t('importForeign.willCreate'), - items - .filter(i => i.type === 'folder') - .map(i => ({ +/** @param {any[]} items @param {any[]} skipped @param {any[]} notes @param {string} nothingKey */ +async function reviewImport(items, skipped, notes, nothingKey) { + const apps = items.filter(i => i.type === 'app').length; + const folders = items.length - apps; + if (!apps) { + toast(t(nothingKey), 'err'); + return; + } + + const body = document.createElement('div'); + const lead = document.createElement('p'); + lead.className = 'dlg-lead'; + lead.textContent = t('importForeign.lead'); + body.appendChild(lead); + dlgSection( + body, + t('importForeign.willAdd'), + items + .filter(i => i.type === 'app') + .map(i => { + const ping = i.monitoring?.healthcheck?.pingUrl; + return { name: i.label, group: '', - why: t('importForeign.appCount', { n: formatNumber(i.children.length) }), - })), - ); - dlgSection( - body, - t('importForeign.changed'), - notes.map(n => ({ name: n.name, group: n.group, why: t(NOTE_TEXT[n.code], { detail: n.detail || '' }) })), - ); - dlgSection( - body, - t('importForeign.notImported'), - skipped.map(s => ({ name: s.name, group: s.group, why: t(SKIP_TEXT[s.reason], { detail: s.detail || '' }) })), - ); - - /* Never take certificate skipping from the file on its say-so. It stays off - unless it is turned on here. */ - const insecure = insecureApps(items); - /** @type {HTMLInputElement|null} */ - let skipTlsChoice = null; - if (insecure.length) { - const heading = document.createElement('div'); - heading.className = 'dlg-sec'; - heading.textContent = `${t('app.allowSelfSigned')} (${formatNumber(insecure.length)})`; - const choice = document.createElement('label'); - choice.className = 'dlg-choice'; - skipTlsChoice = document.createElement('input'); - skipTlsChoice.type = 'checkbox'; - const why = document.createElement('span'); - why.className = 'dlg-why'; - why.textContent = t('app.selfSignedTip'); - choice.append(skipTlsChoice, why); - const ul = document.createElement('ul'); - ul.className = 'dlg-ul'; - for (const item of insecure) { - const li = document.createElement('li'); - li.className = 'dlg-li'; - const nm = document.createElement('span'); - setUserText(nm, item.label); - li.appendChild(nm); - ul.appendChild(li); - } - body.append(heading, choice, ul); - } - - const ok = await confirmModal({ - title: t('importForeign.title'), - body, - confirmLabel: t('common.import'), - cancelLabel: t('common.cancel'), - className: 'wide', - }); - if (!ok) { - input.value = ''; - return; + why: ping ? `${i.href} ${t('importForeign.monitors', { url: ping })}` : i.href, + }; + }), + ); + dlgSection( + body, + t('importForeign.willCreate'), + items + .filter(i => i.type === 'folder') + .map(i => ({ + name: i.label, + group: '', + why: t('importForeign.appCount', { n: formatNumber(i.children.length) }), + })), + ); + dlgSection( + body, + t('importForeign.changed'), + notes.map(n => ({ name: n.name, group: n.group, why: t(NOTE_TEXT[n.code], { detail: n.detail || '' }) })), + ); + dlgSection( + body, + t('importForeign.notImported'), + skipped.map(s => ({ name: s.name, group: s.group, why: t(SKIP_TEXT[s.reason], { detail: s.detail || '' }) })), + ); + + /* Never take certificate skipping from the file on its say-so. It stays off + unless it is turned on here. */ + const insecure = insecureApps(items); + /** @type {HTMLInputElement|null} */ + let skipTlsChoice = null; + if (insecure.length) { + const heading = document.createElement('div'); + heading.className = 'dlg-sec'; + heading.textContent = `${t('app.allowSelfSigned')} (${formatNumber(insecure.length)})`; + const choice = document.createElement('label'); + choice.className = 'dlg-choice'; + skipTlsChoice = document.createElement('input'); + skipTlsChoice.type = 'checkbox'; + const why = document.createElement('span'); + why.className = 'dlg-why'; + why.textContent = t('app.selfSignedTip'); + choice.append(skipTlsChoice, why); + const ul = document.createElement('ul'); + ul.className = 'dlg-ul'; + for (const item of insecure) { + const li = document.createElement('li'); + li.className = 'dlg-li'; + const nm = document.createElement('span'); + setUserText(nm, item.label); + li.appendChild(nm); + ul.appendChild(li); } - if (skipTlsChoice && !skipTlsChoice.checked) clearSkipTls(items); - /* Appended, never merged. An import must not rename, reorder or remove - anything already on the dashboard. */ - await appendAndSave(items); - toast(t('toast.importForeignDone', { apps: formatNumber(apps), folders: formatNumber(folders) })); - } catch (err) { - toast(t('toast.importFailed', { err: errorText(err) }), 'err'); + body.append(heading, choice, ul); } - input.value = ''; -}; + + const ok = await confirmModal({ + title: t('importForeign.title'), + body, + confirmLabel: t('common.import'), + cancelLabel: t('common.cancel'), + className: 'wide', + }); + if (!ok) return; + if (skipTlsChoice && !skipTlsChoice.checked) clearSkipTls(items); + /* Appended, never merged. An import must not rename, reorder or remove + anything already on the dashboard. */ + await appendAndSave(items); + toast(t('toast.importForeignDone', { apps: formatNumber(apps), folders: formatNumber(folders) })); +} el('btn-add').onclick = () => openModal(null); diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index 0be7ac15..99408e5d 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -35,9 +35,9 @@ 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'; import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=389e0ed0'; -import { setupErrorKey } from '/js/admin-error.js?v=a60b17ac'; +import { setupErrorKey } from '/js/admin-error.js?v=1c258dc1'; import { sanitizeItemLinks } from '/js/link-url.js?v=54adb40f'; -import { DOCK_MAX } from '/js/limits.js?v=31048a24'; +import { DOCK_MAX } from '/js/limits.js?v=ea61a441'; import { initUI, mkFolder, diff --git a/ui/js/import-foreign.js b/ui/js/import-foreign.js index eefa7c75..fe809735 100644 --- a/ui/js/import-foreign.js +++ b/ui/js/import-foreign.js @@ -3,8 +3,9 @@ only: a widget in the source becomes a plain app tile, never a Stackyard widget. Keep this module free of the DOM and of the network. */ -import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=32cb6aeb'; +import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=b83791fb'; import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f'; +import { clampName } from '/js/limits.js?v=ea61a441'; export const SKIP = Object.freeze({ NO_LABEL: 'no-label', @@ -14,6 +15,7 @@ export const SKIP = Object.freeze({ RELATIVE_HREF: 'relative-href', UNREADABLE: 'unreadable', UNPARSABLE: 'unparsable', + NO_NAME_OR_GROUP: 'no-name-or-group', }); /* An alias is the same object as its anchor, so a short file can list millions @@ -35,6 +37,8 @@ export const NOTE = Object.freeze({ PAGES_NOT_FOLLOWED: 'pages-not-followed', SCHEME_ADDED: 'scheme-added', HEALTH_URL_DROPPED: 'health-url-dropped', + NAME_SHORTENED: 'name-shortened', + LOGIN_REMOVED: 'login-removed', }); /* Fields with no equivalent on a Stackyard item. */ @@ -65,6 +69,20 @@ function isAbsoluteLink(href) { /** @param {string} href @returns {string} `href` with http:// added when it is a bare host:port */ const withScheme = href => (/^[a-z0-9.-]+:\d{1,5}(?:[/?#]|$)/i.test(href) ? `http://${href}` : href); +/** @param {string} href @returns {string} */ +function withoutLogin(href) { + let u; + try { + u = new URL(href); + } catch { + return href; + } + if (!u.username && !u.password) return href; + u.username = ''; + u.password = ''; + return u.href; +} + /* A value Homepage or Dashy resolves from its own environment. */ const hasPlaceholder = s => /\{\{[^}]*\}\}|\$\{[^}]*\}/.test(s); @@ -180,34 +198,37 @@ function collector(takenIds) { @param {{ label: string, href: string, iconUrl: string, container: string, pingUrl: string, skipTlsVerify?: boolean }} parts @returns {any|null} */ -function addApp(col, group, { label, href: raw, iconUrl, container, pingUrl: rawPing, skipTlsVerify }) { - const href = withScheme(raw); +function addApp(col, group, { label: fullLabel, href: raw, iconUrl, container, pingUrl: rawPing, skipTlsVerify }) { + const label = clampName(fullLabel); + const withHttp = withScheme(raw); if (!label) { - col.skip(SKIP.NO_LABEL, href || '', group); + col.skip(SKIP.NO_LABEL, withoutLogin(withHttp) || '', group); return null; } - if (!href) { + if (!withHttp) { col.skip(SKIP.NO_HREF, label, group); return null; } - if (hasPlaceholder(href)) { - col.skip(SKIP.PLACEHOLDER_HREF, label, group, href); + if (hasPlaceholder(withHttp)) { + col.skip(SKIP.PLACEHOLDER_HREF, label, group, withHttp); return null; } - if (!isAbsoluteLink(href)) { - col.skip(SKIP.RELATIVE_HREF, label, group, href); + if (!isAbsoluteLink(withHttp)) { + col.skip(SKIP.RELATIVE_HREF, label, group, withHttp); return null; } /* The server refuses the whole save over one unsafe link. */ - if (!isSafeLinkUrl(href)) { - col.skip(SKIP.UNSAFE_HREF, label, group, href); + if (!isSafeLinkUrl(withHttp)) { + col.skip(SKIP.UNSAFE_HREF, label, group, withoutLogin(withHttp)); return null; } + const href = withoutLogin(withHttp); let pingUrl = withScheme(rawPing); if (pingUrl && (hasPlaceholder(pingUrl) || !/^https?:\/\//i.test(pingUrl))) { - col.note(NOTE.HEALTH_URL_DROPPED, label, group, rawPing); + col.note(NOTE.HEALTH_URL_DROPPED, label, group, withoutLogin(rawPing)); pingUrl = ''; } + const pingBare = withoutLogin(pingUrl); const built = buildAppItem( { label, @@ -216,7 +237,7 @@ function addApp(col, group, { label, href: raw, iconUrl, container, pingUrl: raw scol: 'dark', hcEn: !!(container || pingUrl), hcCon: container || '', - hcPing: pingUrl || '', + hcPing: pingBare || '', skipTlsVerify: skipTlsVerify || false, actEn: false, actUrl: '', @@ -231,13 +252,18 @@ function addApp(col, group, { label, href: raw, iconUrl, container, pingUrl: raw } col.taken.add(built.item.id); col.items.push(built.item); - if (href !== raw) col.note(NOTE.SCHEME_ADDED, label, group, raw); + if (withHttp !== raw) col.note(NOTE.SCHEME_ADDED, label, group, withoutLogin(raw)); + if (href !== withHttp) col.note(NOTE.LOGIN_REMOVED, label, group, href); + if (pingBare !== pingUrl) col.note(NOTE.LOGIN_REMOVED, label, group, pingBare); + if (label !== fullLabel) col.note(NOTE.NAME_SHORTENED, label, group); return built.item; } /** Create the folder for a group, given the apps that landed in it. */ -function addFolder(col, label, childIds, at) { +function addFolder(col, fullLabel, childIds, at) { if (!childIds.length) return; + const label = clampName(fullLabel); + if (label !== fullLabel) col.note(NOTE.NAME_SHORTENED, label, ''); const id = newItemId(label, 'folder', col.taken); col.taken.add(id); col.items.splice(at, 0, { id, type: 'folder', label, children: childIds }); @@ -419,6 +445,37 @@ export function convertDashy(doc, takenIds = [], untitledFolder = 'Imported') { return result(col); } +/** @param {Array<{ container: string, labels: Record, widget: boolean }>} containers + @param {Iterable} [takenIds] */ +export function convertDockerLabels(containers, takenIds = []) { + /** @type {Map} */ + const groups = new Map(); + const incomplete = []; + for (const c of Array.isArray(containers) ? containers : []) { + const l = c?.labels || {}; + const name = str(l.name); + const group = str(l.group); + if (!name || !group) { + incomplete.push({ reason: SKIP.NO_NAME_OR_GROUP, name: str(c?.container), group: '' }); + continue; + } + if (!groups.has(group)) groups.set(group, []); + groups.get(group)?.push({ name, weight: Number(l.weight) || 0, c }); + } + const byName = (a, b) => a.localeCompare(b); + const doc = [...groups.keys()].sort(byName).map(group => ({ + [group]: (groups.get(group) || []) + .sort((a, b) => a.weight - b.weight || byName(a.name, b.name)) + .map(({ name, c }) => { + const { name: _n, group: _g, weight: _w, ...fields } = c.labels; + return { [name]: { ...fields, container: str(c.container), ...(c.widget ? { widget: true } : {}) } }; + }), + })); + const out = convertHomepageServices(doc, takenIds); + out.skipped.unshift(...incomplete); + return out; +} + function result(col) { return { items: col.items, skipped: col.skipped, notes: col.notes }; } diff --git a/ui/js/limits.js b/ui/js/limits.js index e4a37fdb..c53c6376 100644 --- a/ui/js/limits.js +++ b/ui/js/limits.js @@ -2,3 +2,18 @@ and of imports. */ export const DOCK_MAX = 4; + +export const NAME_MAX = 64; + +/** @param {string} name @returns {string} */ +export function clampName(name) { + const s = String(name); + if (s.length <= NAME_MAX) return s; + const parts = [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(s)]; + return parts.length <= NAME_MAX + ? s + : parts + .slice(0, NAME_MAX) + .map(p => p.segment) + .join(''); +} diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index fa296b56..99746d41 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=8fec4e2f'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=3e9cb9c8'; import { createListbox } from '/js/listbox.js?v=3e47b403'; -import { renderColorControl } from '/js/admin-color-control.js?v=d444b337'; +import { renderColorControl } from '/js/admin-color-control.js?v=8c27f165'; import { seedCarried, applyOptionSet, @@ -14,7 +14,7 @@ import { groupBounds, visibleFieldFlags, } from '/js/admin-logic.js?v=b3befae4'; -import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=a60b17ac'; +import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=1c258dc1'; import { qi } from '/js/utils.js?v=cfee1271'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { SAVED_ROW } from '/js/saved-row.js?v=44039df6'; diff --git a/ui/test/admin-error.test.mjs b/ui/test/admin-error.test.mjs index 5729e38a..8f11568e 100644 --- a/ui/test/admin-error.test.mjs +++ b/ui/test/admin-error.test.mjs @@ -233,6 +233,14 @@ test('an API refusal maps to its own sentence, not the per-kind one', () => { assert.equal(errorAdvice(new TypeError('Failed to fetch')).key, 'adminError.genericInternal'); }); +test('a Docker label import failure maps to its own sentence', () => { + assert.equal(errorAdvice({ kind: 'invalid', code: 'invalid.socket-missing' }).key, 'adminError.socketMissing'); + assert.equal( + errorAdvice({ kind: 'upstream', code: 'upstream.too-many-containers' }).key, + 'toast.importDockerTooLarge', + ); +}); + test('a socket probe result maps to a sentence for each way it fails', () => { const probe = (code, extra = {}) => ({ ok: false, fatal: true, error: 'English prose', code, ...extra }); assert.equal(socketProbeAdvice(probe('invalid.url')).key, 'adminError.invalidUrl'); diff --git a/ui/test/admin-save-logic.test.mjs b/ui/test/admin-save-logic.test.mjs index d64f636d..bd0d6ece 100644 --- a/ui/test/admin-save-logic.test.mjs +++ b/ui/test/admin-save-logic.test.mjs @@ -689,3 +689,8 @@ test('importCounts counts added, updated and deleted items', () => { ]; assert.deepEqual(importCounts(live, file), { added: 1, updated: 1, deleted: 1 }); }); + +test('cleanId keeps at most 32 characters and no trailing separator', () => { + assert.equal(cleanId('N'.repeat(2000)).length, 32); + assert.equal(cleanId(`${'a'.repeat(31)} b`), 'a'.repeat(31)); +}); diff --git a/ui/test/i18n-coverage.test.mjs b/ui/test/i18n-coverage.test.mjs index 059543af..7f04ed49 100644 --- a/ui/test/i18n-coverage.test.mjs +++ b/ui/test/i18n-coverage.test.mjs @@ -346,6 +346,8 @@ const LATIN_OK = [ 'true', 'chars', 'rrggbb', + /* The homepage.group Docker label is typed, not read. */ + 'group', 'autoplay', 'fullscreen', ]; diff --git a/ui/test/import-foreign.test.mjs b/ui/test/import-foreign.test.mjs index 04601d20..916f8cee 100644 --- a/ui/test/import-foreign.test.mjs +++ b/ui/test/import-foreign.test.mjs @@ -23,6 +23,7 @@ const { clearSkipTls, convertHomepageServices, convertHomepageBookmarks, + convertDockerLabels, parseErrorsAsSkipped, SKIP, NOTE, @@ -619,3 +620,84 @@ test('a Dashy status check URL that is only a path is dropped', () => { assert.equal(nas.monitoring.healthcheck.pingUrl, ''); assert.ok(out.notes.some(n => n.code === NOTE.HEALTH_URL_DROPPED && n.detail === '/status')); }); + +const container = (name, labels, widget = false) => ({ container: name, labels, widget }); + +test('Docker labels become folders by group and apps by weight then name', () => { + const out = convertDockerLabels([ + container('sonarr', { name: 'Sonarr', group: 'Media', href: 'http://sonarr:8989' }), + container('pihole', { name: 'Pi-hole', group: 'Network', href: 'http://pihole/admin' }), + container('radarr', { name: 'Radarr', group: 'Media', href: 'http://radarr:7878', weight: '-1' }), + container('emby', { name: 'Emby', group: 'Media', href: 'http://emby:8096' }), + ]); + assert.deepEqual( + folders(out).map(f => f.label), + ['Media', 'Network'], + ); + const media = folders(out)[0].children.map(id => out.items.find(i => i.id === id).label); + assert.deepEqual(media, ['Radarr', 'Emby', 'Sonarr']); +}); + +test('a Docker label app watches its own container', () => { + const out = convertDockerLabels([container('emby', { name: 'Emby', group: 'Media', href: 'http://emby:8096' })]); + const hc = apps(out)[0].monitoring.healthcheck; + assert.equal(hc.enabled, true); + assert.equal(hc.container, 'emby'); +}); + +test('a container missing its name or group label is listed as not imported', () => { + const out = convertDockerLabels([ + container('lan-only', {}), + container('nogroup', { name: 'X', href: 'http://x' }), + container('ok', { name: 'Ok', group: 'G', href: 'http://ok' }), + ]); + assert.equal(apps(out).length, 1); + assert.deepEqual( + out.skipped.map(s => [s.reason, s.name]), + [ + [SKIP.NO_NAME_OR_GROUP, 'lan-only'], + [SKIP.NO_NAME_OR_GROUP, 'nogroup'], + ], + ); +}); + +test('a Docker label widget is noted and its link kept', () => { + const out = convertDockerLabels([container('emby', { name: 'Emby', group: 'Media', href: 'http://emby' }, true)]); + assert.equal(apps(out).length, 1); + assert.ok(out.notes.some(n => n.code === NOTE.WIDGET_AS_LINK && n.name === 'Emby')); +}); + +test('a Docker label link that needs a gethomepage variable is skipped', () => { + const out = convertDockerLabels([ + container('emby', { name: 'Emby', group: 'Media', href: 'http://{{HOMEPAGE_VAR_HOST}}:8096' }), + ]); + assert.equal(apps(out).length, 0); + assert.equal(out.skipped[0].reason, SKIP.PLACEHOLDER_HREF); +}); + +test('a name past the limit is shortened and noted', async () => { + const { NAME_MAX } = await import('/js/limits.js'); + const out = convertDockerLabels([ + container('long', { name: 'N'.repeat(500), group: 'G'.repeat(500), href: 'http://l.lan' }), + ]); + assert.equal(apps(out)[0].label.length, NAME_MAX); + assert.equal(folders(out)[0].label.length, NAME_MAX); + assert.equal(out.notes.filter(n => n.code === NOTE.NAME_SHORTENED).length, 2); + assert.ok(apps(out)[0].id.length < 60); +}); + +test('a username and password are removed from imported addresses and noted', () => { + const out = convertDockerLabels([ + container('nas', { + name: 'NAS', + group: 'Storage', + href: 'http://admin:hunter2@nas.lan:5000/', + siteMonitor: 'http://admin:hunter2@nas.lan:5000/ping', + }), + ]); + const app = apps(out)[0]; + assert.equal(app.href, 'http://nas.lan:5000/'); + assert.equal(app.monitoring.healthcheck.pingUrl, 'http://nas.lan:5000/ping'); + assert.ok(!JSON.stringify(out).includes('hunter2')); + assert.equal(out.notes.filter(n => n.code === NOTE.LOGIN_REMOVED).length, 2); +}); diff --git a/ui/test/limits.test.mjs b/ui/test/limits.test.mjs new file mode 100644 index 00000000..09cdd66d --- /dev/null +++ b/ui/test/limits.test.mjs @@ -0,0 +1,23 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { clampName, NAME_MAX } from '../js/limits.js'; + +test('a name within the limit is kept as it is', () => { + assert.equal(clampName('Plex'), 'Plex'); + assert.equal(clampName('N'.repeat(NAME_MAX)), 'N'.repeat(NAME_MAX)); +}); + +test('a long name is cut to the limit', () => { + assert.equal(clampName('N'.repeat(NAME_MAX + 1)), 'N'.repeat(NAME_MAX)); +}); + +test('a cut never splits a character the reader sees as one', () => { + const family = '\u{1F468}‍\u{1F469}‍\u{1F467}'; + const out = clampName(family.repeat(NAME_MAX + 5)); + assert.equal(out, family.repeat(NAME_MAX)); +}); + +test('emoji that fit by count are not cut by their length in code units', () => { + const name = '\u{1F3AC}'.repeat(NAME_MAX); + assert.equal(clampName(name), name); +}); From 7790b3089d65809b1dbb1a557a83411ede2cbf20 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:16:30 -0400 Subject: [PATCH 2/2] Check every resolved address before a health ping, and strip logins from all import text - Resolve a ping name once, refuse it when any address is link-local, and connect only to the checked addresses, trying each in turn. A failed lookup is reported, never retried unchecked. - Remove usernames and passwords from protocol-relative links and from every import preview line, stopping at a backslash. - Keep the Docker label import button enabled while busy so a closing dialog returns focus to it. - Cut app, folder and widget names to 64 grapheme clusters on save instead of limiting input by UTF-16 code units. --- api/src/proxy.js | 15 ++++++------ api/src/resolve.js | 27 +++++++++++++-------- api/test/ping-link-local.test.js | 41 ++++++++++++++++++++++++++++++-- e2e/admin.spec.js | 21 ++++++++++++++++ e2e/focus.spec.js | 22 +++++++++++++++++ ui/admin/index.html | 2 +- ui/js/admin-app-form.js | 6 ++--- ui/js/admin-list.js | 2 +- ui/js/admin-widget-form.js | 3 +-- ui/js/admin.js | 27 +++++++++++---------- ui/js/import-foreign.js | 36 ++++++++++++++-------------- ui/test/import-foreign.test.mjs | 24 +++++++++++++++++++ 12 files changed, 169 insertions(+), 57 deletions(-) diff --git a/api/src/proxy.js b/api/src/proxy.js index 1ccbcacd..d4180af6 100644 --- a/api/src/proxy.js +++ b/api/src/proxy.js @@ -586,7 +586,7 @@ const PING_TIMED_OUT = Object.freeze({ code: 'timeout.no-answer', }); -function pingUrl(raw, ms = PING_MS, skipTls, pinIp) { +function pingUrl(raw, ms = PING_MS, skipTls, pinIp, lookup) { if (IS_DEMO) { return Promise.resolve({ ok: false, @@ -615,7 +615,7 @@ function pingUrl(raw, ms = PING_MS, skipTls, pinIp) { port, path: u.pathname || '/', timeout: ms, - lookup: resolver.lookupWithin(ms), + lookup: lookup || resolver.lookupWithin(ms), rejectUnauthorized: !skip, }; if (pin) { @@ -734,15 +734,16 @@ async function pingUnchecked(url, ms, skipTls) { if (net.isIP(host)) return isLinkLocal(host) ? { ...PING_LINK_LOCAL } : pingUrl(target, ms, skipTls); const budget = ms || PING_MS; const started = Date.now(); - let address; + let found; try { - ({ address } = await lookupWithin(host, budget)); + found = await resolver.resolveAll(host, budget); } catch (e) { if (e instanceof Error && e.message === 'Timed out') return { ...PING_TIMED_OUT }; - return pingUrl(target, Math.max(1, budget - (Date.now() - started)), skipTls); + found = e instanceof Error ? e : new Error(String(e)); } - if (isLinkLocal(address)) return { ...PING_LINK_LOCAL }; - return pingUrl(target, Math.max(1, budget - (Date.now() - started)), skipTls, address); + if (Array.isArray(found) && found.some(a => isLinkLocal(a.address))) return { ...PING_LINK_LOCAL }; + /* Connect only to the addresses checked above. A second lookup is never checked. */ + return pingUrl(target, Math.max(1, budget - (Date.now() - started)), skipTls, null, resolver.lookupFrom(found)); } async function pingChecked(url, ms, skipTls) { diff --git a/api/src/resolve.js b/api/src/resolve.js index e24a34eb..16e17563 100644 --- a/api/src/resolve.js +++ b/api/src/resolve.js @@ -92,22 +92,29 @@ async function resolveHost(host, ms) { return (await resolveAll(host, ms))[0]; } +/** A `lookup` option for http.request that answers from `found` and never asks DNS. + @param {Address[] | Error} found */ +function lookupFrom(found) { + return (host, options, callback) => { + const cb = typeof options === 'function' ? options : callback; + const o = typeof options === 'object' && options ? options : {}; + if (found instanceof Error) return cb(found); + const wanted = o.family === 4 || o.family === 6 ? found.filter(a => a.family === o.family) : found; + if (!wanted.length) return cb(failed('ENOTFOUND', host)); + if (o.all) return cb(null, wanted); + cb(null, wanted[0].address, wanted[0].family); + }; +} + /** A `lookup` option for http.request, bounded by `ms`. @param {number} ms */ function lookupWithin(ms) { return (host, options, callback) => { - const cb = typeof options === 'function' ? options : callback; - const o = typeof options === 'object' && options ? options : {}; resolveAll(host, ms).then( - list => { - const wanted = o.family === 4 || o.family === 6 ? list.filter(a => a.family === o.family) : list; - if (!wanted.length) return cb(failed('ENOTFOUND', host)); - if (o.all) return cb(null, wanted); - cb(null, wanted[0].address, wanted[0].family); - }, - e => cb(e), + list => lookupFrom(list)(host, options, callback), + e => lookupFrom(e)(host, options, callback), ); }; } -module.exports = { resolveAll, resolveHost, lookupWithin, fromHosts }; +module.exports = { resolveAll, resolveHost, lookupWithin, lookupFrom, fromHosts }; diff --git a/api/test/ping-link-local.test.js b/api/test/ping-link-local.test.js index 65b1ea02..29faee15 100644 --- a/api/test/ping-link-local.test.js +++ b/api/test/ping-link-local.test.js @@ -17,11 +17,33 @@ test('a link-local address is never pinged', async () => { }); test('a name that resolves to a link-local address is never pinged', async t => { - t.mock.method(resolver, 'resolveHost', async () => ({ address: '169.254.169.254', family: 4 })); + t.mock.method(resolver, 'resolveAll', async () => [{ address: '169.254.169.254', family: 4 }]); const r = await pingUnchecked('http://metadata.google.internal/', 1000); assert.equal(r.code, 'blocked.link-local'); }); +test('a name with one link-local address among others is never pinged', async t => { + t.mock.method(resolver, 'resolveAll', async () => [ + { address: '192.168.1.10', family: 4 }, + { address: 'fe80::1', family: 6 }, + ]); + const r = await pingUnchecked('http://nas.example/', 1000); + assert.equal(r.code, 'blocked.link-local'); +}); + +test('a failed lookup is never retried with an unchecked one', async t => { + const answers = [ + () => Promise.reject(Object.assign(new Error('EAI_AGAIN rebind.example'), { code: 'EAI_AGAIN' })), + async () => [{ address: '169.254.169.254', family: 4 }], + ]; + const lookup = t.mock.method(resolver, 'resolveAll', () => answers.shift()()); + const resolveHost = t.mock.method(resolver, 'resolveHost', async () => ({ address: '169.254.169.254', family: 4 })); + const r = await pingUnchecked('http://rebind.example/', 1000); + assert.equal(r.code, 'network.not-found'); + assert.equal(lookup.mock.callCount(), 1); + assert.equal(resolveHost.mock.callCount(), 0); +}); + test('a private address is still pinged', async () => { const server = http.createServer((_, res) => res.end('ok')); await new Promise(r => server.listen(0, '127.0.0.1', r)); @@ -36,7 +58,7 @@ test('a private address is still pinged', async () => { test('a name that resolves to a private address is pinged at that address', async t => { const server = http.createServer((_, res) => res.end('ok')); await new Promise(r => server.listen(0, '127.0.0.1', r)); - t.mock.method(resolver, 'resolveHost', async () => ({ address: '127.0.0.1', family: 4 })); + t.mock.method(resolver, 'resolveAll', async () => [{ address: '127.0.0.1', family: 4 }]); try { const r = await pingUnchecked(`http://nas.example:${server.address().port}/`, 2000); assert.equal(r.ok, true); @@ -44,3 +66,18 @@ test('a name that resolves to a private address is pinged at that address', asyn server.close(); } }); + +test('a name is pinged at its next address when the first does not answer', async t => { + const server = http.createServer((_, res) => res.end('ok')); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + t.mock.method(resolver, 'resolveAll', async () => [ + { address: '127.0.0.2', family: 4 }, + { address: '127.0.0.1', family: 4 }, + ]); + try { + const r = await pingUnchecked(`http://nas.example:${server.address().port}/`, 3000); + assert.equal(r.ok, true); + } finally { + server.close(); + } +}); diff --git a/e2e/admin.spec.js b/e2e/admin.spec.js index b3fbb3dd..7b05e6a7 100644 --- a/e2e/admin.spec.js +++ b/e2e/admin.spec.js @@ -418,3 +418,24 @@ test('the menu stays put when the version arrives', async ({ page }) => { await expect(page.locator('#sidebar-version')).not.toBeEmpty(); expect(await first.boundingBox()).toEqual(before); }); + +test('a long name is cut to 64 characters on save, emoji counted as one each', async ({ page, request }) => { + await openDashboardList(page); + await page.locator('#btn-add').click(); + await page.locator('#ie-name .rv').click(); + await page.locator('#f-lbl').waitFor({ state: 'visible' }); + await page.keyboard.type('\u{1F3AC}'.repeat(40)); + await page.locator('#f-lbl').press('Enter'); + await setInlineRow(page, 'ie-url', 'f-href', 'http://film.invalid'); + await saveEditor(page); + const film = (await readConfig(request)).items.find(i => i.href === 'http://film.invalid'); + expect(film.label).toBe('\u{1F3AC}'.repeat(40)); + + await page.locator('#btn-add').click(); + await setInlineRow(page, 'ie-name', 'f-lbl', 'N'.repeat(70)); + await setInlineRow(page, 'ie-url', 'f-href', 'http://long.invalid'); + await saveEditor(page); + await expect.poll(() => rowNames(page)).toContain('N'.repeat(64)); + const long = (await readConfig(request)).items.find(i => i.href === 'http://long.invalid'); + expect(long.label).toBe('N'.repeat(64)); +}); diff --git a/e2e/focus.spec.js b/e2e/focus.spec.js index 5329a88c..ad2b88d3 100644 --- a/e2e/focus.spec.js +++ b/e2e/focus.spec.js @@ -290,3 +290,25 @@ test('an open section inside a switched-off one takes no focus', async ({ page, ); expect(focusable).toBe(0); }); + +test('closing the Docker label import returns focus to its button', async ({ page }) => { + await page.route('**/api/docker/labels', route => + route.fulfill({ + json: { + containers: [ + { container: 'emby', labels: { name: 'Emby', group: 'Media', href: 'http://emby.invalid' }, widget: false }, + ], + }, + }), + ); + await page.goto('/admin/'); + await page.locator('body.authed').waitFor({ state: 'attached' }); + await page.locator('.nl[data-sec="general"]').click(); + const btn = page.locator('#btn-imp-docker'); + await btn.focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('dialog[open]')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.locator('dialog[open]')).toHaveCount(0); + await expect(btn).toBeFocused(); +}); diff --git a/ui/admin/index.html b/ui/admin/index.html index 8e983f5f..0d5fd484 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -319,6 +319,6 @@

General

- + diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index 0c59e44f..0b10e9ba 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -9,7 +9,7 @@ import { throughServer, } from '/js/icons.js?v=5ac01d72'; import { state } from '/js/admin-state.js?v=064b9b85'; -import { DOCK_MAX, NAME_MAX } from '/js/limits.js?v=ea61a441'; +import { DOCK_MAX } from '/js/limits.js?v=ea61a441'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { isDockBlocked, @@ -58,7 +58,7 @@ export function buildFolderForm(body, item) {
${t('folder.name')} ${item?.label || t('folder.namePh')} - +
@@ -248,8 +248,6 @@ export function buildAppForm(body, item) {

${t('app.selfSignedTip')}

`, ); - const nameInput = inpById('f-lbl'); - if (nameInput) nameInput.maxLength = NAME_MAX; initInlineEdit('ie-name', 'f-lbl', { placeholder: t('app.namePh'), onCommit() { diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index 8af2015c..43e58521 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -22,7 +22,7 @@ import { paintIcon, toast } from '/js/admin-shared.js?v=3e9cb9c8'; import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=cfee1271'; 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=1a2d4a3a'; +import { sizeLabel } from '/js/admin-widget-form.js?v=b45c44b7'; import { widgetGlyph } from '/js/widget-glyphs.js?v=479a53ee'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index c82a8ccb..9b0f3a21 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -7,7 +7,6 @@ import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } fr import { t } from '/js/i18n.js?v=ed7c5565'; import { focusFirst, q, qi, qa } from '/js/utils.js?v=cfee1271'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; -import { NAME_MAX } from '/js/limits.js?v=ea61a441'; const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']); @@ -60,7 +59,7 @@ function _renderWidgetForm(body) { setHtml( shell, html` -
${t('widgetCfg.name')}${state._wlabel ? state._wlabel : t('widgetCfg.namePh')}
+
${t('widgetCfg.name')}${state._wlabel ? state._wlabel : t('widgetCfg.namePh')}
${t('widgetCfg.type')}
`, ); body.appendChild(shell); diff --git a/ui/js/admin.js b/ui/js/admin.js index b09e76cb..ea65e2e8 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -4,10 +4,10 @@ import { buildSettingsTileForm, captureActLabels, serializeKvRows, -} from '/js/admin-app-form.js?v=592c677b'; +} from '/js/admin-app-form.js?v=aa7daaca'; import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=99cc620c'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=122e7499'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=6479edae'; import { resolveAdminSection, settleDock } from '/js/admin-logic.js?v=b3befae4'; import { afterImport, @@ -47,7 +47,7 @@ import { withheldText, } from '/js/admin-shared.js?v=3e9cb9c8'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=064b9b85'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=1a2d4a3a'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=b45c44b7'; import { initFluidHover } from '/js/fluid-hover.js?v=8b4aadd4'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=75a71ab4'; @@ -66,8 +66,9 @@ import { NOTE, parseErrorsAsSkipped, SKIP, -} from '/js/import-foreign.js?v=c766baa0'; +} from '/js/import-foreign.js?v=961efd4d'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; +import { clampName } from '/js/limits.js?v=ea61a441'; import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=6b0320bd'; import { THEME_KEY, @@ -591,13 +592,14 @@ function openFolderPicker(appId, targetFolderId = null) { cancelLabel: t('common.cancel'), }); if (!name) return; + const label = clampName(name); const fid = newItemId( - name, + label, 'folder', state.items.map(i => i.id), ); const before = snapshotItems(state.items); - state.items.push({ id: fid, type: 'folder', label: name, children: [appId] }); + state.items.push({ id: fid, type: 'folder', label, children: [appId] }); state.items.forEach(f => { if (f.type === 'folder' && f.id !== fid) f.children = (f.children || []).filter(id => id !== appId); }); @@ -623,7 +625,7 @@ async function doSave(orig) { /** @type {Record} */ let item; if (state.ctype === 'widget') { - const wlabel = state._wlabel.trim() || state._widgetReg?.[state._wtype]?.label || t('type.widget'); + const wlabel = clampName(state._wlabel.trim() || state._widgetReg?.[state._wtype]?.label || t('type.widget')); if (state._autoForm && state._autoFormType === state._wtype && state._widgetReg[state._wtype]) { const missing = state._autoForm.validate(); if (missing.length) { @@ -677,7 +679,7 @@ async function doSave(orig) { if (shown) delete item.hidden; else item.hidden = true; } else if (state.ctype === 'folder') { - const label = inp('f-fname')?.value?.trim(); + const label = clampName(inp('f-fname')?.value?.trim() || ''); if (!label) { toast(t('toast.nameRequired'), 'err'); return; @@ -702,7 +704,7 @@ async function doSave(orig) { const isPve = inp('hc-type-pve')?.checked; captureActLabels(); const v = { - label: inp('f-lbl')?.value?.trim(), + label: clampName(inp('f-lbl')?.value?.trim() || ''), href: inp('f-href')?.value?.trim(), hcEn: inp('hc-en')?.checked, hcCon: isPing || isPve ? '' : inp('hc-con')?.value?.trim() || '', @@ -1312,9 +1314,10 @@ el('imp-foreign').onchange = async e => { input.value = ''; }; +let dockerImporting = false; el('btn-imp-docker').onclick = async () => { - const btn = /** @type {HTMLButtonElement} */ (el('btn-imp-docker')); - btn.disabled = true; + if (dockerImporting) return; + dockerImporting = true; try { const r = await apiGet('/api/docker/labels'); let out; @@ -1328,7 +1331,7 @@ el('btn-imp-docker').onclick = async () => { } catch (err) { toast(t('toast.importFailed', { err: errorText(err) }), 'err'); } - btn.disabled = false; + dockerImporting = false; }; /** @param {any[]} items @param {any[]} skipped @param {any[]} notes @param {string} nothingKey */ diff --git a/ui/js/import-foreign.js b/ui/js/import-foreign.js index fe809735..dcb8a358 100644 --- a/ui/js/import-foreign.js +++ b/ui/js/import-foreign.js @@ -69,19 +69,17 @@ function isAbsoluteLink(href) { /** @param {string} href @returns {string} `href` with http:// added when it is a bare host:port */ const withScheme = href => (/^[a-z0-9.-]+:\d{1,5}(?:[/?#]|$)/i.test(href) ? `http://${href}` : href); +/* Stop at a backslash. Browsers end the host there, so removing past it changes + where the link goes. */ +const LOGIN = '((?:[a-z][a-z0-9+.-]*:)?//)[^\\s/?#\\\\]*@'; +const LOGIN_AT_START = new RegExp(`^${LOGIN}`, 'i'); +const LOGIN_ANYWHERE = new RegExp(LOGIN, 'gi'); + /** @param {string} href @returns {string} */ -function withoutLogin(href) { - let u; - try { - u = new URL(href); - } catch { - return href; - } - if (!u.username && !u.password) return href; - u.username = ''; - u.password = ''; - return u.href; -} +const withoutLogin = href => href.replace(LOGIN_AT_START, '$1'); + +/** @param {string} s @returns {string} */ +const withoutLoginInText = s => s.replace(LOGIN_ANYWHERE, '$1'); /* A value Homepage or Dashy resolves from its own environment. */ const hasPlaceholder = s => /\{\{[^}]*\}\}|\$\{[^}]*\}/.test(s); @@ -184,11 +182,13 @@ function collector(takenIds) { /** @type {Array<{ code: string, name: string, group: string, detail?: string }>} */ notes: [], /** @param {string} reason @param {string} name @param {string} group @param {string} [detail] */ skip(reason, name, group, detail) { - this.skipped.push(detail ? { reason, name, group, detail } : { reason, name, group }); + name = withoutLoginInText(name); + this.skipped.push(detail ? { reason, name, group, detail: withoutLoginInText(detail) } : { reason, name, group }); }, /** @param {string} code @param {string} name @param {string} group @param {string} [detail] */ note(code, name, group, detail) { - this.notes.push(detail ? { code, name, group, detail } : { code, name, group }); + name = withoutLoginInText(name); + this.notes.push(detail ? { code, name, group, detail: withoutLoginInText(detail) } : { code, name, group }); }, }; } @@ -202,7 +202,7 @@ function addApp(col, group, { label: fullLabel, href: raw, iconUrl, container, p const label = clampName(fullLabel); const withHttp = withScheme(raw); if (!label) { - col.skip(SKIP.NO_LABEL, withoutLogin(withHttp) || '', group); + col.skip(SKIP.NO_LABEL, withHttp || '', group); return null; } if (!withHttp) { @@ -219,13 +219,13 @@ function addApp(col, group, { label: fullLabel, href: raw, iconUrl, container, p } /* The server refuses the whole save over one unsafe link. */ if (!isSafeLinkUrl(withHttp)) { - col.skip(SKIP.UNSAFE_HREF, label, group, withoutLogin(withHttp)); + col.skip(SKIP.UNSAFE_HREF, label, group, withHttp); return null; } const href = withoutLogin(withHttp); let pingUrl = withScheme(rawPing); if (pingUrl && (hasPlaceholder(pingUrl) || !/^https?:\/\//i.test(pingUrl))) { - col.note(NOTE.HEALTH_URL_DROPPED, label, group, withoutLogin(rawPing)); + col.note(NOTE.HEALTH_URL_DROPPED, label, group, rawPing); pingUrl = ''; } const pingBare = withoutLogin(pingUrl); @@ -252,7 +252,7 @@ function addApp(col, group, { label: fullLabel, href: raw, iconUrl, container, p } col.taken.add(built.item.id); col.items.push(built.item); - if (withHttp !== raw) col.note(NOTE.SCHEME_ADDED, label, group, withoutLogin(raw)); + if (withHttp !== raw) col.note(NOTE.SCHEME_ADDED, label, group, raw); if (href !== withHttp) col.note(NOTE.LOGIN_REMOVED, label, group, href); if (pingBare !== pingUrl) col.note(NOTE.LOGIN_REMOVED, label, group, pingBare); if (label !== fullLabel) col.note(NOTE.NAME_SHORTENED, label, group); diff --git a/ui/test/import-foreign.test.mjs b/ui/test/import-foreign.test.mjs index 916f8cee..b8c01b1e 100644 --- a/ui/test/import-foreign.test.mjs +++ b/ui/test/import-foreign.test.mjs @@ -701,3 +701,27 @@ test('a username and password are removed from imported addresses and noted', () assert.ok(!JSON.stringify(out).includes('hunter2')); assert.equal(out.notes.filter(n => n.code === NOTE.LOGIN_REMOVED).length, 2); }); + +test('a protocol-relative link loses its username and password too', () => { + const out = convertDockerLabels([ + container('nas', { name: 'NAS', group: 'Storage', href: '//admin:hunter2@nas.lan:5000/' }), + ]); + assert.equal(apps(out)[0].href, '//nas.lan:5000/'); + assert.ok(!JSON.stringify(out).includes('hunter2')); + assert.equal(out.notes.filter(n => n.code === NOTE.LOGIN_REMOVED).length, 1); +}); + +test('no preview line shows a username and password', () => { + const out = convertDockerLabels([ + container('a', { name: 'A', group: 'G', href: 'https://admin:hunter2@{{HOMEPAGE_VAR_HOST}}/' }), + container('b', { name: 'B', group: 'G', href: 'http://b.lan/', siteMonitor: '//admin:hunter2@b.lan/' }), + container('c', { name: 'C', group: 'G', href: 'http://c.lan/', ping: 'http://admin:hunter2@c.lan' }), + ]); + assert.equal(out.skipped.length, 1); + assert.ok(!JSON.stringify(out).includes('hunter2'), JSON.stringify(out)); +}); + +test('a link is never cut past a backslash', () => { + const out = convertDockerLabels([container('x', { name: 'X', group: 'G', href: 'http://evil.lan\\@nas.lan/' })]); + assert.equal(apps(out)[0].href, 'http://evil.lan\\@nas.lan/'); +});