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..d4180af6 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) { @@ -582,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, @@ -611,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) { @@ -709,8 +713,37 @@ 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 found; + try { + found = await resolver.resolveAll(host, budget); + } catch (e) { + if (e instanceof Error && e.message === 'Timed out') return { ...PING_TIMED_OUT }; + found = e instanceof Error ? e : new Error(String(e)); + } + 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/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..29faee15 --- /dev/null +++ b/api/test/ping-link-local.test.js @@ -0,0 +1,83 @@ +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, '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)); + 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, '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); + } finally { + 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 2a66528c..0d5fd484 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..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 } from '/js/limits.js?v=31048a24'; +import { DOCK_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'; @@ -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')}

@@ -253,7 +254,12 @@ export function buildAppForm(body, item) { 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..43e58521 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=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-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..9b0f3a21 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,7 +1,7 @@ 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'; diff --git a/ui/js/admin.js b/ui/js/admin.js index 4288f29d..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=6dfcf64d'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=9b2d4d17'; +} 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=9a75dcf8'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=6479edae'; 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=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'; @@ -59,14 +59,16 @@ 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=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, @@ -590,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); }); @@ -622,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) { @@ -676,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; @@ -701,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() || '', @@ -1223,6 +1226,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 +1241,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 +1307,127 @@ 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; +let dockerImporting = false; +el('btn-imp-docker').onclick = async () => { + if (dockerImporting) return; + dockerImporting = 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'); + } + dockerImporting = 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..dcb8a358 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,18 @@ 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} */ +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); @@ -166,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 }); }, }; } @@ -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, 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, 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); 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, 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..b8c01b1e 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,108 @@ 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); +}); + +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/'); +}); 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); +});