Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions api/src/widget-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '';
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions api/test/backup-data.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions api/test/books-shelves-data.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
4 changes: 2 additions & 2 deletions api/test/disk-health-data.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' };

Expand All @@ -16,7 +16,7 @@ function makeCtx(endpoint, config, fetchJSON) {
config,
fetchJSON,
normalizeBase,
browserLink,
serviceBase,
serviceLink,
KIND,
fail: (message, opts) => {
Expand Down
4 changes: 2 additions & 2 deletions api/test/dns-link.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ 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'));

const ctxFor = config => ({
config,
normalizeBase,
browserLink,
serviceBase,
serviceLink,
KIND,
fail(msg, opts) {
Expand Down
4 changes: 2 additions & 2 deletions api/test/dns-pihole-data.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand All @@ -15,7 +15,7 @@ function makeCtx(fetchJSON) {
config: CONFIG,
fetchJSON,
normalizeBase,
browserLink,
serviceBase,
serviceLink,
KIND,
fail: (message, opts) => {
Expand Down
23 changes: 21 additions & 2 deletions api/test/nowplaying-data.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
};
Expand Down Expand Up @@ -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=/);
});
4 changes: 2 additions & 2 deletions api/test/system-summary-beszel-data.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -73,7 +73,7 @@ function ctxFor(config, h = hub(), endpoint = 'system') {
...config,
},
normalizeBase,
browserLink,
serviceBase,
serviceLink,
fetchJSON: h.fetchJSON,
...errorParts(),
Expand Down
6 changes: 3 additions & 3 deletions api/test/system-summary-glances-data.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -31,7 +31,7 @@ function ctxFor(config, { serves = [4], endpoint = 'system' } = {}) {
endpoint,
config,
normalizeBase,
browserLink,
serviceBase,
serviceLink,
fetchJSON: async (url, opts) => {
urls.push(url);
Expand Down Expand Up @@ -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(),
};
Expand Down
4 changes: 2 additions & 2 deletions api/test/system-summary-unraid-data.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 || '' });
Expand Down
30 changes: 24 additions & 6 deletions ui/js/service-link.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
25 changes: 24 additions & 1 deletion ui/test/service-link.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
2 changes: 1 addition & 1 deletion ui/widgets/nowplaying/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading