diff --git a/api/src/widget-data.js b/api/src/widget-data.js index 00b33ff9..c5d0cd89 100644 --- a/api/src/widget-data.js +++ b/api/src/widget-data.js @@ -16,7 +16,7 @@ const log = require('./log'); const { fail, KIND, WidgetError } = require('./api-error'); const { rateLimit } = require('./auth'); const LIMITS = require('./poll-limits'); -const { browserLink, serviceLink } = require('../../ui/js/service-link.js'); +const { serviceBase, serviceLink } = require('../../ui/js/service-link.js'); function normalizeBase(raw) { if (!raw) return ''; @@ -51,7 +51,7 @@ function dataFnContext(wc, endpoint, searchParams, fetch, row = null) { metrics: IS_DEMO ? demoData.metrics : { cpuSample, ramPercent, cpuTemp, diskStats, procCount, uptimeSeconds }, demo: IS_DEMO ? demoData.helpers : null, normalizeBase, - browserLink, + serviceBase, serviceLink, fail: (message, opts) => { throw new WidgetError(message, opts); diff --git a/api/test/backup-data.test.js b/api/test/backup-data.test.js index 0a8f6a92..88ae9482 100644 --- a/api/test/backup-data.test.js +++ b/api/test/backup-data.test.js @@ -5,7 +5,7 @@ const path = require('node:path'); const dataFn = require(path.join(__dirname, '..', '..', 'ui', 'widgets', 'backup', 'data.js')); const { WidgetError, KIND } = require('../src/api-error'); const log = require('../src/log'); -const { browserLink, serviceLink } = require('../../ui/js/service-link.js'); +const { serviceBase, serviceLink } = require('../../ui/js/service-link.js'); /* Duplicati mints a fresh token per instance, so every fixture answers the login route as well as the data route it is asked about. */ @@ -21,7 +21,7 @@ function ctxFor(config, endpoint, routes, row = null) { throw new WidgetError(message, opts); }, KIND, - browserLink, + serviceBase, serviceLink, log, async fetchJSON(url, opts) { diff --git a/api/test/books-shelves-data.test.js b/api/test/books-shelves-data.test.js index 9a6e0577..9da07f2c 100644 --- a/api/test/books-shelves-data.test.js +++ b/api/test/books-shelves-data.test.js @@ -6,14 +6,14 @@ const { dispatchProvider } = require('../src/provider-dispatch'); const { errorParts } = require('../test-support/widget-ctx'); const dataFn = require(path.join(__dirname, '..', '..', 'ui', 'widgets', 'books', 'data.js')); -const { browserLink, serviceLink } = require('../../ui/js/service-link.js'); +const { serviceBase, serviceLink } = require('../../ui/js/service-link.js'); function ctxFor(config, reply) { const ctx = { endpoint: undefined, config, normalizeBase, - browserLink, + serviceBase, serviceLink, fetchJSON: async url => reply(url), ...errorParts(), diff --git a/api/test/disk-health-data.test.js b/api/test/disk-health-data.test.js index 74837fcd..360041df 100644 --- a/api/test/disk-health-data.test.js +++ b/api/test/disk-health-data.test.js @@ -6,7 +6,7 @@ const { dispatchProvider } = require(path.join(__dirname, '..', 'src', 'provider const { normalizeBase } = require(path.join(__dirname, '..', 'src', 'widget-data.js')); const dataFn = require(path.join(__dirname, '..', '..', 'ui', 'widgets', 'disk-health', 'data.js')); -const { browserLink, serviceLink } = require('../../ui/js/service-link.js'); +const { serviceBase, serviceLink } = require('../../ui/js/service-link.js'); const KIND = { INVALID: 'invalid', AUTH: 'auth' }; @@ -16,7 +16,7 @@ function makeCtx(endpoint, config, fetchJSON) { config, fetchJSON, normalizeBase, - browserLink, + serviceBase, serviceLink, KIND, fail: (message, opts) => { diff --git a/api/test/dns-link.test.js b/api/test/dns-link.test.js index 880673dd..46e8fbd9 100644 --- a/api/test/dns-link.test.js +++ b/api/test/dns-link.test.js @@ -2,7 +2,7 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); const { normalizeBase } = require('../src/widget-data'); -const { browserLink, serviceLink } = require('../../ui/js/service-link.js'); +const { serviceBase, serviceLink } = require('../../ui/js/service-link.js'); const { KIND, WidgetError } = require('../src/api-error'); const dataFn = require(path.join(__dirname, '..', '..', 'ui', 'widgets', 'dns', 'data.js')); @@ -10,7 +10,7 @@ const dataFn = require(path.join(__dirname, '..', '..', 'ui', 'widgets', 'dns', const ctxFor = config => ({ config, normalizeBase, - browserLink, + serviceBase, serviceLink, KIND, fail(msg, opts) { diff --git a/api/test/dns-pihole-data.test.js b/api/test/dns-pihole-data.test.js index 050cef4d..36033620 100644 --- a/api/test/dns-pihole-data.test.js +++ b/api/test/dns-pihole-data.test.js @@ -5,7 +5,7 @@ const path = require('node:path'); const { normalizeBase } = require(path.join(__dirname, '..', 'src', 'widget-data.js')); const dataFn = require(path.join(__dirname, '..', '..', 'ui', 'widgets', 'dns', 'data.js')); -const { browserLink, serviceLink } = require('../../ui/js/service-link.js'); +const { serviceBase, serviceLink } = require('../../ui/js/service-link.js'); const KIND = { INVALID: 'invalid', AUTH: 'auth' }; const CONFIG = { provider: 'pihole', dnsUrl: 'http://pi.local', dnsPiholePassword: 'pw' }; @@ -15,7 +15,7 @@ function makeCtx(fetchJSON) { config: CONFIG, fetchJSON, normalizeBase, - browserLink, + serviceBase, serviceLink, KIND, fail: (message, opts) => { diff --git a/api/test/nowplaying-data.test.js b/api/test/nowplaying-data.test.js index 3bc74825..ab3a19c2 100644 --- a/api/test/nowplaying-data.test.js +++ b/api/test/nowplaying-data.test.js @@ -4,14 +4,14 @@ const path = require('node:path'); const { normalizeBase } = require('../src/widget-data'); const dataFn = require(path.join(__dirname, '..', '..', 'ui', 'widgets', 'nowplaying', 'data.js')); -const { browserLink, serviceLink } = require('../../ui/js/service-link.js'); +const { serviceBase, serviceLink } = require('../../ui/js/service-link.js'); /* Minimal stand-in for the ctx widget-data.js builds. */ function ctxFor(config, payload) { return { config, normalizeBase, - browserLink, + serviceBase, serviceLink, fetchJSON: async () => ({ status: 200, data: payload }), }; @@ -189,3 +189,22 @@ test('a Link URL set by the reader becomes the base of the item link', async () ); assert.equal(r.sessions[0].link, 'https://watch.example.com/web/#/details?id=i9&serverId=s1'); }); + +test('a Link URL that points at a page opens that page for every item', async () => { + const href = 'http://10.0.0.5:8096/web/index.html'; + const r = await dataFn( + ctxFor({ provider: 'jellyfin', jellyfinUrl: 'http://jellyfin:8096', jellyfinKey: 'k', href }, [ + { ServerId: 's1', NowPlayingItem: { Id: 'i9', Name: 'Arrival', Type: 'Movie' } }, + ]), + ); + assert.equal(r.sessions[0].link, href); +}); + +test('a typed Link URL on a name without a dot still gets Plex item links', async () => { + const ctx = ctxFor( + { provider: 'plex', plexUrl: 'http://plex:32400', plexToken: 't', href: 'http://nas:32400' }, + { MediaContainer: { machineIdentifier: 'm1', Metadata: [{ type: 'movie', title: 'Arrival', ratingKey: '7' }] } }, + ); + const r = await dataFn(ctx); + assert.match(r.sessions[0].link, /^http:\/\/nas:32400\/web\/index\.html#!\/server\/m1\/details\?key=/); +}); diff --git a/api/test/system-summary-beszel-data.test.js b/api/test/system-summary-beszel-data.test.js index 54788ecd..da9d2c0f 100644 --- a/api/test/system-summary-beszel-data.test.js +++ b/api/test/system-summary-beszel-data.test.js @@ -6,7 +6,7 @@ const { dispatchProvider } = require('../src/provider-dispatch'); const { errorParts } = require('../test-support/widget-ctx'); const dataFn = require(path.join(__dirname, '..', '..', 'ui', 'widgets', 'system-summary', 'data.js')); -const { browserLink, serviceLink } = require('../../ui/js/service-link.js'); +const { serviceBase, serviceLink } = require('../../ui/js/service-link.js'); const SYSTEM = { id: 'sys1', name: 'homelab', status: 'up' }; const STATS = { @@ -73,7 +73,7 @@ function ctxFor(config, h = hub(), endpoint = 'system') { ...config, }, normalizeBase, - browserLink, + serviceBase, serviceLink, fetchJSON: h.fetchJSON, ...errorParts(), diff --git a/api/test/system-summary-glances-data.test.js b/api/test/system-summary-glances-data.test.js index fb76ac8a..b2e9f3a1 100644 --- a/api/test/system-summary-glances-data.test.js +++ b/api/test/system-summary-glances-data.test.js @@ -6,7 +6,7 @@ const { dispatchProvider } = require('../src/provider-dispatch'); const { errorParts } = require('../test-support/widget-ctx'); const dataFn = require(path.join(__dirname, '..', '..', 'ui', 'widgets', 'system-summary', 'data.js')); -const { browserLink, serviceLink } = require('../../ui/js/service-link.js'); +const { serviceBase, serviceLink } = require('../../ui/js/service-link.js'); const PLUGINS = { cpu: { total: 12.5, iowait: 3.5, idle: 84 }, @@ -31,7 +31,7 @@ function ctxFor(config, { serves = [4], endpoint = 'system' } = {}) { endpoint, config, normalizeBase, - browserLink, + serviceBase, serviceLink, fetchJSON: async (url, opts) => { urls.push(url); @@ -194,7 +194,7 @@ test('the local source is still the default when no provider is saved', async () settings: {}, metrics, normalizeBase, - browserLink, + serviceBase, serviceLink, ...errorParts(), }; diff --git a/api/test/system-summary-unraid-data.test.js b/api/test/system-summary-unraid-data.test.js index 943e22af..ab5914d5 100644 --- a/api/test/system-summary-unraid-data.test.js +++ b/api/test/system-summary-unraid-data.test.js @@ -6,7 +6,7 @@ const { dispatchProvider } = require('../src/provider-dispatch'); const { errorParts } = require('../test-support/widget-ctx'); const dataFn = require(path.join(__dirname, '..', '..', 'ui', 'widgets', 'system-summary', 'data.js')); -const { browserLink, serviceLink } = require('../../ui/js/service-link.js'); +const { serviceBase, serviceLink } = require('../../ui/js/service-link.js'); const BOOTED = '2026-08-15T00:00:00.000Z'; @@ -37,7 +37,7 @@ function ctxFor(config, reply = { status: 200, data: { data: REPLY } }, endpoint endpoint, config: { statProvider: 'unraid', unraidUrl: 'tower.local', unraidApiKey: 'KEY', ...config }, normalizeBase, - browserLink, + serviceBase, serviceLink, fetchJSON: async (url, opts) => { sent.push({ url, opts, query: JSON.parse(opts.body || '{}').query || '' }); diff --git a/ui/js/service-link.js b/ui/js/service-link.js index 4fb12fc4..7110d473 100644 --- a/ui/js/service-link.js +++ b/ui/js/service-link.js @@ -28,13 +28,31 @@ export function browserLink(raw) { return u.href.replace(/\/+$/, ''); } -/** Where a widget opens. `own` is the Link URL the reader set, taken as the - service's address in the browser; `url` is the address the server reads. - With a `path`, the item's page under that address, else `own` as typed. +/** The address item pages go under, or '' when there is none. A Link URL the + reader typed is trusted to be reachable, but only a bare address takes item + paths: one with its own path, query or fragment points at a page. + @param {unknown} own @param {unknown} url @returns {string} */ +export function serviceBase(own, url) { + const typed = typeof own === 'string' ? own.trim() : ''; + if (!typed) return browserLink(url); + let u; + try { + u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(typed) ? typed : 'http://' + typed); + } catch { + return ''; + } + if (u.protocol !== 'http:' && u.protocol !== 'https:') return ''; + if ((u.pathname !== '/' && u.pathname !== '') || u.search || u.hash || u.username || u.password) return ''; + return u.origin; +} + +/** Where a widget opens. `own` is the Link URL the reader set; `url` is the + address the server reads. With a `path`, the item's page under the service + base, else `own` as typed. @param {unknown} own @param {unknown} url @param {string} [path] @returns {string} */ export function serviceLink(own, url, path = '') { const typed = typeof own === 'string' ? own.trim() : ''; - const web = browserLink(typed || url); - if (!path) return typed || web; - return web ? web + path : typed; + if (!path) return typed || browserLink(url); + const base = serviceBase(typed, url); + return base ? base + path : typed; } diff --git a/ui/test/service-link.test.mjs b/ui/test/service-link.test.mjs index 67abc9e9..9b0a2a68 100644 --- a/ui/test/service-link.test.mjs +++ b/ui/test/service-link.test.mjs @@ -39,5 +39,28 @@ test('a Link URL is the base for item pages and is kept as typed without one', a assert.equal(serviceLink('', 'http://10.0.0.5:32400', '/web/x'), 'http://10.0.0.5:32400/web/x'); assert.equal(serviceLink('', 'http://plex:32400', '/web/x'), ''); assert.equal(serviceLink('https://example.com/page?a=1', 'http://plex:32400'), 'https://example.com/page?a=1'); - assert.equal(serviceLink('http://nas:32400', 'http://plex:32400', '/web/x'), 'http://nas:32400'); + assert.equal(serviceLink('http://nas:32400', 'http://plex:32400', '/web/x'), 'http://nas:32400/web/x'); +}); + +test('a Link URL that points at a page opens that page, never a joined path', async () => { + const { serviceLink } = await import('../js/service-link.js'); + for (const typed of [ + 'http://10.0.0.5:32400/web/index.html#!/', + 'https://app.plex.tv/desktop', + 'http://10.0.0.5:8096/web/index.html', + 'http://10.0.0.5:8200/ngax/index.html', + 'https://example.com/plex/', + 'http://10.0.0.5:8200/?tab=logs', + ]) + assert.equal(serviceLink(typed, 'http://plex:32400', '/web/index.html#!/server/x'), typed, typed); +}); + +test('the service base is a typed bare address, or else the reachable server address', async () => { + const { serviceBase } = await import('../js/service-link.js'); + assert.equal(serviceBase('http://nas:8080/', 'http://plex:32400'), 'http://nas:8080'); + assert.equal(serviceBase('nas.local:8080', ''), 'http://nas.local:8080'); + assert.equal(serviceBase('', 'http://10.0.0.5:32400'), 'http://10.0.0.5:32400'); + assert.equal(serviceBase('', 'http://plex:32400'), ''); + for (const typed of ['https://app.plex.tv/desktop', 'http://u:p@10.0.0.5', 'javascript:alert(1)', 'http://[']) + assert.equal(serviceBase(typed, 'http://10.0.0.5:32400'), '', typed); }); diff --git a/ui/widgets/nowplaying/data.js b/ui/widgets/nowplaying/data.js index 5be50713..ad14eaa7 100644 --- a/ui/widgets/nowplaying/data.js +++ b/ui/widgets/nowplaying/data.js @@ -37,7 +37,7 @@ async function plex(ctx) { if (r.status >= 400) ctx.fail('Plex HTTP ' + r.status); let list = (r.data && r.data.MediaContainer && r.data.MediaContainer.Metadata) || []; if (!Array.isArray(list)) list = [list]; - const reachable = ctx.browserLink(ctx.config.href || ctx.config.plexUrl); + const reachable = ctx.serviceBase(ctx.config.href, ctx.config.plexUrl); const machine = reachable && list.length ? await plexMachineId(ctx, base, token).catch(() => '') : ''; return list.map(m => { const type = (m.type || '').toLowerCase();