From aa02393ec7027fc0e49f31074e3235dadf6758a0 Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Fri, 25 Sep 2026 11:32:21 +0100 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20Adds=20alias=20functionality,?= =?UTF-8?q?=20to=20jump=20strait=20to=20app=20from=20URL=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/configuring.md | 1 + docs/searching.md | 24 ++++++- src/main.js | 5 +- src/router.js | 14 ++++- src/utils/Search.js | 6 +- src/utils/config/ConfigHelpers.js | 29 +++++++++ src/utils/config/ConfigSchema.json | 9 +++ tests/unit/alias-visibility.test.js | 67 ++++++++++++++++++++ tests/unit/config-helpers.test.js | 97 +++++++++++++++++++++++++++++ tests/unit/search.test.js | 10 +++ 10 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 tests/unit/alias-visibility.test.js diff --git a/docs/configuring.md b/docs/configuring.md index 2942726236..5dc2867190 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -270,6 +270,7 @@ For more info, see the **[Authentication Docs](/docs/authentication.md)** **`icon`** | `string` | _Optional_ | The icon for a given item. Can be a font-awesome icon, favicon, remote URL or local URL. See [`item.icon`](#sectionicon-and-sectionitemicon) **`target`** | `string` | _Optional_ | The opening method for when the item is clicked, either `newtab`, `sametab`, `modal`, `workspace`, `clipboard`, `top` or `parent`. Where `newtab` will open the link in a new tab, `sametab` will open it in the current tab, and `modal` will open a pop-up modal, `workspace` will open in the Workspace view and `clipboard` will copy the URL to system clipboard (but not launch app). Defaults to `newtab` **`hotkey`** | `number` | _Optional_ | Give frequently opened applications a numeric hotkey, between `0 - 9`. You can then just press that key to launch that application. +**`alias`** | `string` | _Optional_ | A short lowercase word which, when visited as a path on your dashboard (e.g. `/some-app`), redirects straight to this item's URL. See [URL Aliases](/docs/searching.md#url-aliases) **`tags`** | `string[]` | _Optional_ | A list of tags, which can be used for improved search **`statusCheck`** | `boolean` | _Optional_ | When set to `true`, Dashy will ping the URL associated with the current service, and display its status as a dot next to the item. The value here will override `appConfig.statusCheck` so you can turn off or on checks for a given service. Defaults to `appConfig.statusCheck`, falls back to `false` **`statusCheckUrl`** | `string` | _Optional_ | If you've enabled `statusCheck`, and want to use a different URL to what is defined under the item, then specify it here diff --git a/docs/searching.md b/docs/searching.md index 7dd10e5dff..f055c437f7 100644 --- a/docs/searching.md +++ b/docs/searching.md @@ -14,7 +14,7 @@ You can launch a elected app by hitting Enter. This will open the app ## Tags -By default, when searching items are filtered by the `title`, (as well as the `url`, `provider` and `description`). If you need to find results based on text which isn't included in these attributes, then you can add `tags` to a given item. +By default, when searching items are filtered by the `title`, (as well as the `url`, `provider`, `description` and `alias`). If you need to find results based on text which isn't included in these attributes, then you can add `tags` to a given item. ```yaml items: @@ -65,6 +65,28 @@ For apps that you use regularly, you can set a custom keybinding. Use the `hotke In the above example, pressing 2 will launch Bookstack. Or hitting 3 will open Git in the workspace view. +## URL Aliases + +Set an `alias` on an item, and visiting that word as a path on your dashboard will redirect you straight to the item's URL. This lets you jump to an app by typing `dashy/jelly` into the address bar, without loading the dashboard first. + +```yaml +- title: Jellyfin + icon: sh-jellyfin + url: https://jellyfin.lab.local + alias: jelly +``` + +Above, visiting `https://dashy.lab.local/jelly` sends you to Jellyfin. Matching ignores case, and an alias which doesn't match any item shows the 404 page. Aliases are also searchable, so typing `jelly` into Dashy's search will surface Jellyfin. + +Some limitations to be aware of: +- Aliases must be lowercase, may only contain letters, numbers, hyphens and underscores +- Only items in your main config file are reachable - items on [multi-page](/docs/pages-and-sections.md) sub-pages are not +- Only `http://` and `https://` item URLs can be redirected to +- Items hidden from the current user (via `displayData`) are not reachable by their alias (you need to login to a user with access first) +- Requires history routing (the default). Under `VITE_APP_ROUTING_MODE=hash` the URL would need to be `dashy/#/jelly` +- It's not possible to create aliases for pages Dashy already uses, including: 'home', 'minimal', 'workspace', 'login', 'download' and '404' +- If two items share an alias, the first one in the config wins + ## Web Search It's possible to launch a web search directly from Dashy, which might be useful if you're using Dashy as your start page. This can be done by typing your query as normal, and then pressing ⏎/Enter. Web search options are configured under `appConfig.webSearch`. diff --git a/src/main.js b/src/main.js index 3d290e75c8..b61360e876 100644 --- a/src/main.js +++ b/src/main.js @@ -72,6 +72,9 @@ const handleAuthFailure = (provider, err) => { const needsOidcLoginPage = () => isOidcLoginPageEnabled() && !isLoggedIn() && router.currentRoute.value.name !== 'login'; +/* A guard can abort the first navigation (like an item alias redirect), leaving no route to render */ +const skipMount = () => {}; + router.isReady().then(() => { if (isOidcEnabled()) { initOidcAuth().then((reloading) => { @@ -86,4 +89,4 @@ router.isReady().then(() => { } else { mount(); } -}); +}, skipMount); diff --git a/src/router.js b/src/router.js index f47d232f69..a71a9ae3bc 100644 --- a/src/router.js +++ b/src/router.js @@ -20,7 +20,7 @@ import { isOidcEnabled } from '@/utils/auth/OidcAuth'; import { isKeycloakEnabled } from '@/utils/auth/KeycloakAuth'; import { isHeaderAuthEnabled } from '@/utils/auth/HeaderAuth'; import { startingView as defaultStartingView, routePaths } from '@/utils/config/defaults'; -import { VIEW_META } from '@/utils/config/ConfigHelpers'; +import { VIEW_META, getUrlForAlias } from '@/utils/config/ConfigHelpers'; import ErrorHandler from '@/utils/logging/ErrorHandler'; const progress = new Progress({ color: 'var(--progress-bar)' }); @@ -123,6 +123,18 @@ const router = createRouter({ next(); }, }, + { // Item aliases, where / redirects straight to that item's URL + path: '/:alias', + name: 'alias', + component: () => import('./views/404.vue'), + beforeEnter: (to, from, next) => { + const url = getUrlForAlias(store.state.rootConfig?.sections, to.params.alias); + if (!url) { next('/404'); return; } + window.location.replace(url); + progress.end(); + next(false); + }, + }, { // Redirect any not-found routed to the 404 view path: '/:pathMatch(.*)*', redirect: '/404', diff --git a/src/utils/Search.js b/src/utils/Search.js index e3feabb768..71c4f8a0b7 100644 --- a/src/utils/Search.js +++ b/src/utils/Search.js @@ -12,13 +12,13 @@ const haystackCache = new WeakMap(); const buildHaystack = (tile) => { const { - title, description, provider, url, tags, subItems, + title, description, provider, url, tags, alias, subItems, } = tile; const tagsStr = Array.isArray(tags) ? tags.join(' ') : (tags || ''); const subText = Array.isArray(subItems) ? subItems.map((s) => `${s.title || ''} ${s.url || ''}`).join(' ') : ''; - return normalize(`${title || ''} ${provider || ''} ${description || ''} ${tagsStr} ${url || ''} ${subText}`); + return normalize(`${title || ''} ${provider || ''} ${description || ''} ${tagsStr} ${url || ''} ${alias || ''} ${subText}`); }; const getHaystack = (tile) => { @@ -32,7 +32,7 @@ const getHaystack = (tile) => { /** * Filter tiles based on users search term, and returns a filtered list - * Will match based on title, description, provider, hostname from url and tags + * Will match based on title, description, provider, hostname from url, tags and alias * Ignores case, special characters and other irrelevant things * @param {array} allTiles An array of tiles * @param {string} searchTerm The users search term diff --git a/src/utils/config/ConfigHelpers.js b/src/utils/config/ConfigHelpers.js index 42c1bb783f..0a2b17a54e 100644 --- a/src/utils/config/ConfigHelpers.js +++ b/src/utils/config/ConfigHelpers.js @@ -1,9 +1,11 @@ import ConfigAccumulator from '@/utils/config/ConfigAccumalator'; import filterUserSections from '@/utils/CheckSectionVisibility'; +import checkItemVisibility from '@/utils/CheckItemVisibility'; import { languages } from '@/utils/languages'; import { visibleComponents, localStorageKeys, + routePaths, language as defaultLanguage, } from '@/utils/config/defaults'; @@ -189,6 +191,33 @@ export const getCustomKeyShortcuts = (sections) => (sections || []) .filter((item) => item.hotkey) .map((item) => ({ hotkey: item.hotkey, url: item.url }))); +/* Paths owned by Dashy's own views, so can never be claimed as an item alias */ +export const RESERVED_ALIASES = Object.freeze( + Object.values(routePaths).map((path) => path.replace(/^\//, '')), +); + +/* Lowercases and strips any surrounding slashes, so alias matching is forgiving */ +const normalizeAlias = (alias) => String(alias ?? '').trim().toLowerCase().replace(/^\/+|\/+$/g, ''); + +/* Redirects must go somewhere absolute and browsable, never to javascript: or a relative path */ +const isRedirectable = (url) => { + try { return ['http:', 'https:'].includes(new URL(url).protocol); } catch { return false; } +}; + +/** + * Returns the URL of the item assigned the given alias, used for / redirects + * Pass the root config's sections, so a redirect never depends on the page being viewed + */ +export const getUrlForAlias = (sections, alias) => { + const target = normalizeAlias(alias); + if (!target || RESERVED_ALIASES.includes(target)) return undefined; + const match = filterUserSections(sections || []) + .flatMap((section) => section.items || []) + .find((item) => item.alias && normalizeAlias(item.alias) === target); + if (!match || !checkItemVisibility(match) || !isRedirectable(match.url)) return undefined; + return match.url; +}; + /** * Gets the users chosen language. Defaults to English. * If for any reason a lang code changes, add to legacyAliases for backwards compat diff --git a/src/utils/config/ConfigSchema.json b/src/utils/config/ConfigSchema.json index 1b951f1d3c..f22857369a 100644 --- a/src/utils/config/ConfigSchema.json +++ b/src/utils/config/ConfigSchema.json @@ -1311,6 +1311,15 @@ "type": "number", "description": "A numeric shortcut key, between 0 and 9. Useful for quickly launching frequently used applications" }, + "alias": { + "title": "URL Alias", + "type": "string", + "pattern": "^[a-z0-9_-]+$", + "not": { + "enum": ["home", "minimal", "workspace", "login", "download", "404"] + }, + "description": "A short lowercase word which, when visited as a path on your dashboard (e.g. /my-app), redirects straight to this item" + }, "rel": { "title": "rel", "type": "string", diff --git a/tests/unit/alias-visibility.test.js b/tests/unit/alias-visibility.test.js new file mode 100644 index 0000000000..9facbcc6f3 --- /dev/null +++ b/tests/unit/alias-visibility.test.js @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/utils/auth/Auth', () => ({ + getCurrentUser: () => mockUser, + isLoggedInAsGuest: () => mockIsGuest, +})); + +let mockUser = false; +let mockIsGuest = false; + +const { getUrlForAlias } = await import('@/utils/config/ConfigHelpers'); + +const withItem = (displayData) => [{ items: [{ alias: 'app', url: 'https://app.local', displayData }] }]; +const withSection = (displayData) => [{ displayData, items: [{ alias: 'app', url: 'https://app.local' }] }]; +const setGroups = (info) => { localStorage.getItem.mockReturnValue(info ? JSON.stringify(info) : null); }; + +describe('getUrlForAlias - visibility rules', () => { + beforeEach(() => { + mockUser = false; + mockIsGuest = false; + setGroups(null); + }); + + it('resolves an item with no visibility rules', () => { + expect(getUrlForAlias(withItem(undefined), 'app')).toBe('https://app.local'); + }); + + it('honours hideForUsers on the item', () => { + mockUser = { user: 'alice' }; + expect(getUrlForAlias(withItem({ hideForUsers: ['Alice'] }), 'app')).toBeUndefined(); + expect(getUrlForAlias(withItem({ hideForUsers: ['bob'] }), 'app')).toBe('https://app.local'); + }); + + it('honours showForUsers on the item', () => { + mockUser = { user: 'alice' }; + expect(getUrlForAlias(withItem({ showForUsers: ['bob'] }), 'app')).toBeUndefined(); + expect(getUrlForAlias(withItem({ showForUsers: ['alice'] }), 'app')).toBe('https://app.local'); + }); + + it('honours hideForGuests when browsing as a guest', () => { + mockIsGuest = true; + expect(getUrlForAlias(withItem({ hideForGuests: true }), 'app')).toBeUndefined(); + mockIsGuest = false; + expect(getUrlForAlias(withItem({ hideForGuests: true }), 'app')).toBe('https://app.local'); + }); + + it('honours hideForGroups and hideForRoles', () => { + setGroups({ groups: ['devs'], roles: ['viewer'] }); + expect(getUrlForAlias(withItem({ hideForGroups: ['devs'] }), 'app')).toBeUndefined(); + expect(getUrlForAlias(withItem({ hideForRoles: ['viewer'] }), 'app')).toBeUndefined(); + expect(getUrlForAlias(withItem({ hideForGroups: ['admins'] }), 'app')).toBe('https://app.local'); + }); + + it('honours showForGroups and showForRoles', () => { + setGroups({ groups: ['devs'], roles: ['viewer'] }); + expect(getUrlForAlias(withItem({ showForGroups: ['admins'] }), 'app')).toBeUndefined(); + expect(getUrlForAlias(withItem({ showForGroups: ['devs'] }), 'app')).toBe('https://app.local'); + expect(getUrlForAlias(withItem({ showForRoles: ['viewer'] }), 'app')).toBe('https://app.local'); + }); + + it('honours the same rules applied at section level', () => { + mockUser = { user: 'alice' }; + expect(getUrlForAlias(withSection({ hideForUsers: ['alice'] }), 'app')).toBeUndefined(); + expect(getUrlForAlias(withSection({ showForUsers: ['bob'] }), 'app')).toBeUndefined(); + expect(getUrlForAlias(withSection({ hideForUsers: ['bob'] }), 'app')).toBe('https://app.local'); + }); +}); diff --git a/tests/unit/config-helpers.test.js b/tests/unit/config-helpers.test.js index 48a7d112b7..33fadf3963 100644 --- a/tests/unit/config-helpers.test.js +++ b/tests/unit/config-helpers.test.js @@ -10,7 +10,10 @@ import { formatConfigPath, componentVisibility, getCustomKeyShortcuts, + getUrlForAlias, + RESERVED_ALIASES, } from '@/utils/config/ConfigHelpers'; +import schema from '@/utils/config/ConfigSchema.json'; describe('ConfigHelpers - makePageName', () => { it('converts page name to lowercase', () => { @@ -400,3 +403,97 @@ describe('ConfigHelpers - getCustomKeyShortcuts', () => { expect(getCustomKeyShortcuts(undefined)).toEqual([]); }); }); + +describe('ConfigHelpers - getUrlForAlias', () => { + const sections = [ + { name: 'Media', items: [{ title: 'Jellyfin', alias: 'Jelly', url: 'https://jelly.local' }] }, + { name: 'Widgets Only', widgets: [{ type: 'embed' }] }, + { name: 'Tools', items: [{ title: 'No alias', url: 'https://nope.local' }] }, + ]; + + it('returns the URL of the item with a matching alias', () => { + expect(getUrlForAlias(sections, 'jelly')).toBe('https://jelly.local'); + }); + + it('matches regardless of case, whitespace or surrounding slashes', () => { + expect(getUrlForAlias(sections, ' /JELLY/ ')).toBe('https://jelly.local'); + }); + + it('returns undefined when no item claims the alias', () => { + expect(getUrlForAlias(sections, 'plex')).toBeUndefined(); + }); + + it('never matches items that have no alias', () => { + const noAliases = [{ items: [{ title: 'No alias', url: 'https://nope.local' }] }]; + expect(getUrlForAlias(noAliases, 'anything')).toBeUndefined(); + expect(getUrlForAlias(noAliases, '')).toBeUndefined(); + }); + + it('does not let a blank alias be reached by a blank lookup', () => { + const blank = [{ items: [{ alias: ' ', url: 'https://blank.local' }] }]; + expect(getUrlForAlias(blank, '')).toBeUndefined(); + expect(getUrlForAlias(blank, ' ')).toBeUndefined(); + }); + + it('ignores aliases YAML has coerced to a non-string', () => { + const coerced = [{ items: [{ alias: 0, url: 'https://zero.local' }, { alias: false, url: 'https://no.local' }] }]; + expect(getUrlForAlias(coerced, '0')).toBeUndefined(); + expect(getUrlForAlias(coerced, 'false')).toBeUndefined(); + }); + + it('refuses to redirect anywhere that is not an absolute http(s) URL', () => { + const unsafe = [{ + items: [ + { alias: 'xss', url: "javascript:alert('x')" }, + { alias: 'loop', url: 'loop' }, + { alias: 'mail', url: 'mailto:someone@example.com' }, + { alias: 'none', title: 'No URL' }, + ], + }]; + ['xss', 'loop', 'mail', 'none'].forEach((a) => expect(getUrlForAlias(unsafe, a)).toBeUndefined()); + }); + + it('skips items the current user is not allowed to see', () => { + const hidden = [{ + items: [{ alias: 'secret', url: 'https://secret.local', displayData: { showForGroups: ['admins'] } }], + }]; + expect(getUrlForAlias(hidden, 'secret')).toBeUndefined(); + }); + + it('skips items inside a section the current user cannot see', () => { + const hidden = [{ + displayData: { showForGroups: ['admins'] }, + items: [{ alias: 'secret', url: 'https://secret.local' }], + }]; + expect(getUrlForAlias(hidden, 'secret')).toBeUndefined(); + }); + + it('refuses every reserved word, even when an item claims it', () => { + const shadowing = [{ + items: RESERVED_ALIASES.map((word) => ({ alias: word, url: `https://evil.local/${word}` })), + }]; + RESERVED_ALIASES.forEach((word) => expect(getUrlForAlias(shadowing, word)).toBeUndefined()); + }); + + it('returns undefined for null or undefined sections', () => { + expect(getUrlForAlias(null, 'jelly')).toBeUndefined(); + expect(getUrlForAlias(undefined, 'jelly')).toBeUndefined(); + }); +}); + +describe('ConfigSchema - item alias', () => { + const aliasSchema = schema.properties.sections.items.properties.items.items.properties.alias; + const RESERVED = ['404', 'download', 'home', 'login', 'minimal', 'workspace']; + + it('reserves the same words in the schema as the router does at runtime', () => { + expect([...RESERVED_ALIASES].sort()).toEqual(RESERVED); + expect([...aliasSchema.not.enum].sort()).toEqual(RESERVED); + }); + + it.each([ + ['jelly', true], ['git-tea', true], ['my_app', true], ['app2', true], + ['Jelly', false], ['has space', false], ['a/b', false], ['', false], + ])('accepts %j in the config: %s', (alias, valid) => { + expect(new RegExp(aliasSchema.pattern).test(alias)).toBe(valid); + }); +}); diff --git a/tests/unit/search.test.js b/tests/unit/search.test.js index 5d98efdfaf..6a8f3e5442 100644 --- a/tests/unit/search.test.js +++ b/tests/unit/search.test.js @@ -58,6 +58,16 @@ describe('Search - searchTiles', () => { expect(searchTiles([t], 'primary')).toHaveLength(1); }); + it('matches an item by its url alias', () => { + const t = tile({ + title: 'Jellyfin', description: '', provider: '', url: 'https://media.lab', tags: [], alias: 'watchstuff', + }); + expect(searchTiles([t], 'watchstuff')).toHaveLength(1); + expect(searchTiles([t], 'WATCHSTUFF')).toHaveLength(1); + expect(searchTiles([t], 'watchstuff media')).toHaveLength(1); + expect(searchTiles([{ ...t, alias: undefined }], 'watchstuff')).toHaveLength(0); + }); + it('matches across multiple fields when the query has several words', () => { const t = tile({ title: 'Plex', description: 'Media server', tags: [] }); expect(searchTiles([t], 'plex media')).toHaveLength(1); From 0af537c5d41a9ef0202dc1508bb491821ebf2aba Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Fri, 25 Sep 2026 15:46:18 +0100 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20Implements=20OpenSearch=20proto?= =?UTF-8?q?col=20for=20browser=20address=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/searching.md | 11 ++++++ index.html | 2 + services/app.js | 12 ++++++ services/endpoints/opensearch.js | 35 +++++++++++++++++ src/components/LinkItems/Item.vue | 6 ++- src/main.js | 6 ++- src/router.js | 15 +++++++- tests/server/opensearch.test.js | 63 +++++++++++++++++++++++++++++++ vite.config.mjs | 2 +- 9 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 services/endpoints/opensearch.js create mode 100644 tests/server/opensearch.test.js diff --git a/docs/searching.md b/docs/searching.md index f055c437f7..ad565efa86 100644 --- a/docs/searching.md +++ b/docs/searching.md @@ -87,6 +87,17 @@ Some limitations to be aware of: - It's not possible to create aliases for pages Dashy already uses, including: 'home', 'minimal', 'workspace', 'login', 'download' and '404' - If two items share an alias, the first one in the config wins + +## Searching From The Address Bar + +Dashy publishes an [OpenSearch](https://developer.mozilla.org/en-US/docs/Web/XML/Guides/OpenSearch) descriptor (at `/opensearch.xml`), so your browser can offer it as a search keyword. Once added, typing `dashy jelly` into the address bar jumps straight to Jellyfin if you've setup an `alias` for this. + +Most browsers pick this up after you've visited your dashboard, then need the keyword assigning by hand: +- **Chrome / Edge**: Settings → Search engines → Site search, find your dashboard and set a shortcut +- **Firefox**: Settings → Search → Search Shortcuts, or right-click the address bar and choose "Add search engine" + +You can also skip OpenSearch and add the engine manually, using `https://dashy.lab.local/%s` as the URL. + ## Web Search It's possible to launch a web search directly from Dashy, which might be useful if you're using Dashy as your start page. This can be done by typing your query as normal, and then pressing ⏎/Enter. Web search options are configured under `appConfig.webSearch`. diff --git a/index.html b/index.html index 5d7371534e..71a7903a9b 100644 --- a/index.html +++ b/index.html @@ -13,6 +13,8 @@ + + Dashy diff --git a/services/app.js b/services/app.js index edbf9d065f..787b72f369 100644 --- a/services/app.js +++ b/services/app.js @@ -34,6 +34,7 @@ const systemInfo = require('./endpoints/system-info'); // Basic system info, for const sslServer = require('./utils/ssl-server'); // TLS-enabled web server const corsProxy = require('./endpoints/cors-proxy'); // Enables API requests to CORS-blocked services const getUser = require('./endpoints/get-user'); // Enables server side user lookup +const openSearch = require('./endpoints/opensearch'); // Descriptor for browser keyword search const { apiEnabledGate, apiErrorHandler, createApiRouter } = require('./endpoints/api'); // Opt-in REST API const { loadOidcSettings, createOidcMiddleware, maybeBootstrapConfig } = require('./utils/auth-oidc'); @@ -49,6 +50,7 @@ const ENDPOINTS = { corsProxy: '/cors-proxy', getUser: '/get-user', configSchema: '/schema.json', + openSearch: '/opensearch.xml', api: '/api', }; @@ -329,6 +331,16 @@ const app = express() .use(ENDPOINTS.api, apiErrorHandler) // Serves the config schema, for use by external editors and validators .get(ENDPOINTS.configSchema, (req, res) => res.json(configSchema)) + // OpenSearch descriptor, so browsers can offer Dashy as a keyword search engine + // Not cached, since the search template is built from the requesting host + .get(ENDPOINTS.openSearch, (req, res) => { + try { + res.set('Cache-Control', 'no-store') + .type('application/opensearchdescription+xml').send(openSearch(config, req)); + } catch (e) { + safeEnd(res, errBody(e), 500); + } + }) // Middleware to serve any .yml/.yaml files in USER_DATA_DIR with optional protection // Note: returns stripped version if auth configured but not yet authenticated .get(/\.ya?ml$/i, bootstrapAuth, (req, res) => { diff --git a/services/endpoints/opensearch.js b/services/endpoints/opensearch.js new file mode 100644 index 0000000000..fff65f715c --- /dev/null +++ b/services/endpoints/opensearch.js @@ -0,0 +1,35 @@ +/** + * Builds the OpenSearch descriptor document, which lets browsers offer + * Dashy as a keyword search engine, for jumping to items by their alias + */ + +/* Must match the title on index.html's , which browsers check on discovery */ +const SHORT_NAME = 'Dashy'; + +/* Escape a value for safe inclusion in XML text or an attribute */ +const xmlEscape = (input) => String(input ?? '').replace( + /[<>&'"]/g, + (c) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[c]), +); + +/* The public origin this request arrived on, honouring a reverse proxy's forwarded headers */ +const originFromRequest = (req) => { + const firstHeader = (name) => (req.headers[name] || '').split(',')[0].trim(); + const proto = firstHeader('x-forwarded-proto') || (req.socket.encrypted ? 'https' : 'http'); + const host = firstHeader('x-forwarded-host') || req.headers.host || 'localhost'; + return `${proto}://${host}`; +}; + +module.exports = (config, req) => { + const origin = xmlEscape(originFromRequest(req)); + const title = xmlEscape(config?.pageInfo?.title || SHORT_NAME); + return ` + + ${SHORT_NAME} + Jump to an app on ${title} by its alias + UTF-8 + ${origin}/favicon.ico + + +`; +}; diff --git a/src/components/LinkItems/Item.vue b/src/components/LinkItems/Item.vue index b8cd28054b..7b46d2a099 100644 --- a/src/components/LinkItems/Item.vue +++ b/src/components/LinkItems/Item.vue @@ -175,14 +175,16 @@ export default { /* Returns configuration object for the tooltip */ getTooltipOptions() { const { - title, description, provider, hotkey, + title, description, provider, hotkey, alias, } = this.item; - if (!description && !provider && !this.titleTruncated) return {}; // Nothing to show + const hasShortcut = hotkey || alias; + if (!description && !provider && !hasShortcut && !this.titleTruncated) return {}; // Nothing to show const parts = []; if (this.titleTruncated) parts.push(`${title}`); if (provider) parts.push(`Provider: ${provider}`); if (description) parts.push(description); if (hotkey) parts.push(`Press '${hotkey}' to launch`); + if (alias) parts.push(`Visit '/${alias}' to launch`); const editKey = this.appConfig.disableContextMenu ? 'interactive-editor.edit-section.edit-tooltip-basic' : 'interactive-editor.edit-section.edit-tooltip'; diff --git a/src/main.js b/src/main.js index b61360e876..3872fb9970 100644 --- a/src/main.js +++ b/src/main.js @@ -72,8 +72,10 @@ const handleAuthFailure = (provider, err) => { const needsOidcLoginPage = () => isOidcLoginPageEnabled() && !isLoggedIn() && router.currentRoute.value.name !== 'login'; -/* A guard can abort the first navigation (like an item alias redirect), leaving no route to render */ -const skipMount = () => {}; +/* An alias redirect aborts the first navigation on purpose, anything else failing is worth logging */ +const skipMount = (failure) => { + if (failure?.to?.name !== 'alias') ErrorHandler('Initial navigation failed', failure); +}; router.isReady().then(() => { if (isOidcEnabled()) { diff --git a/src/router.js b/src/router.js index a71a9ae3bc..70246ae88d 100644 --- a/src/router.js +++ b/src/router.js @@ -51,6 +51,16 @@ const resolveStartingView = () => { return VIEW_META[view] ? view : 'home'; }; +/* True when a URL points back at the page we're already on, which would redirect forever */ +const isSelfReferential = (url) => { + try { + const target = new URL(url, window.location.href); + const path = (p) => p.replace(/\/+$/, ''); + return target.origin === window.location.origin + && path(target.pathname) === path(window.location.pathname); + } catch { return false; } +}; + /* Build the canonical //:page?/:section? routes for a given view + component. * withSection=false for workspace (no single-section view yet). Page meta is * owned by App.vue's watcher via PageMeta.js — routes don't carry titles. */ @@ -129,9 +139,10 @@ const router = createRouter({ component: () => import('./views/404.vue'), beforeEnter: (to, from, next) => { const url = getUrlForAlias(store.state.rootConfig?.sections, to.params.alias); - if (!url) { next('/404'); return; } + const loops = !!url && isSelfReferential(url); + if (loops) ErrorHandler(`Alias '${to.params.alias}' points back at itself, not redirecting`); + if (!url || loops) { next('/404'); return; } window.location.replace(url); - progress.end(); next(false); }, }, diff --git a/tests/server/opensearch.test.js b/tests/server/opensearch.test.js new file mode 100644 index 0000000000..6e889ce388 --- /dev/null +++ b/tests/server/opensearch.test.js @@ -0,0 +1,63 @@ +// @vitest-environment node +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { describe, it, expect } from 'vitest'; +import request from 'supertest'; + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashy-opensearch-test-')); +fs.writeFileSync(path.join(tmpDir, 'conf.yml'), "pageInfo:\n title: Alicia & Co's Very Long Dashboard\nsections: []\n"); +process.env.USER_DATA_DIR = tmpDir; + +const app = require('../../services/app'); + +describe('OpenSearch descriptor', () => { + it('is served as an OpenSearch document', async () => { + const res = await request(app).get('/opensearch.xml'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/application\/opensearchdescription\+xml/); + }); + + it('builds the search template from the requesting host', async () => { + const res = await request(app).get('/opensearch.xml').set('Host', 'dash.lab.local'); + expect(res.text).toContain('template="http://dash.lab.local/{searchTerms}"'); + }); + + it('honours a reverse proxy\'s forwarded protocol and host', async () => { + const res = await request(app).get('/opensearch.xml') + .set('X-Forwarded-Proto', 'https').set('X-Forwarded-Host', 'dash.example.com'); + expect(res.text).toContain('template="https://dash.example.com/{searchTerms}"'); + }); + + it('takes the first value when a proxy chain sends a list', async () => { + const res = await request(app).get('/opensearch.xml') + .set('X-Forwarded-Proto', 'https, http').set('X-Forwarded-Host', 'outer.example.com, inner.local'); + expect(res.text).toContain('template="https://outer.example.com/{searchTerms}"'); + }); + + it('uses a ShortName matching the title, so browsers accept the descriptor', async () => { + const res = await request(app).get('/opensearch.xml'); + const indexHtml = fs.readFileSync(path.join(__dirname, '../../index.html'), 'utf8'); + const linkTitle = indexHtml.match(/]*title="([^"]+)"/)[1]; + expect(res.text).toContain(`${linkTitle}`); + }); + + it('escapes the configured title in the description', async () => { + const res = await request(app).get('/opensearch.xml'); + expect(res.text).toContain('Alicia & Co's Very Long Dashboard by its alias'); + }); + + it.each([[2024, '2024'], [true, 'true'], [3.14, '3.14']])( + 'renders a title YAML coerced to the non-string %s', (title, expected) => { + const openSearch = require('../../services/endpoints/opensearch'); + const xml = openSearch({ pageInfo: { title } }, { headers: { host: 'x.local' }, socket: {} }); + expect(xml).toContain(`Jump to an app on ${expected} by its alias`); + }, + ); + + it('escapes a hostile Host header rather than emitting raw XML', async () => { + const res = await request(app).get('/opensearch.xml').set('Host', 'evil">'); + expect(res.text).not.toContain('