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
1 change: 1 addition & 0 deletions docs/configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 34 additions & 1 deletion docs/searching.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ You can launch a elected app by hitting <kbd>Enter</kbd>. 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:
Expand Down Expand Up @@ -65,6 +65,39 @@ For apps that you use regularly, you can set a custom keybinding. Use the `hotke

In the above example, pressing <kbd>2</kbd> will launch Bookstack. Or hitting <kbd>3</kbd> 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


## 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 <kbd>⏎</kbd>/Enter. Web search options are configured under `appConfig.webSearch`.
Expand Down
2 changes: 2 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="stylesheet" type="text/css" href="/loading-screen.css" />
<link rel="stylesheet" type="text/css" href="/theme-fonts.css" media="print" onload="this.media='all'" />
<!-- Lets the browser offer Dashy as a keyword search engine, for jumping to apps by alias -->
<link rel="search" type="application/opensearchdescription+xml" title="Dashy" href="/opensearch.xml" />
<!-- Default Page Title -->
<title>Dashy</title>
</head>
Expand Down
26 changes: 26 additions & 0 deletions services/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ 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 aliasTarget = require('./endpoints/alias-target'); // Resolves /<alias> to an item's URL
const { apiEnabledGate, apiErrorHandler, createApiRouter } = require('./endpoints/api'); // Opt-in REST API

const { loadOidcSettings, createOidcMiddleware, maybeBootstrapConfig } = require('./utils/auth-oidc');
Expand All @@ -49,6 +51,7 @@ const ENDPOINTS = {
corsProxy: '/cors-proxy',
getUser: '/get-user',
configSchema: '/schema.json',
openSearch: '/opensearch.xml',
api: '/api',
};

Expand Down Expand Up @@ -192,6 +195,9 @@ const authIsConfigured = Boolean(
);
const guestAccessOn = Boolean(initialAuthConfig?.enableGuestAccess);

/* Dashy's own login page is client-side, so users[] gates access even without ENABLE_HTTP_AUTH */
const anyLoginConfigured = authIsConfigured || Boolean(initialAuthConfig.users?.length);

/* Require an authenticated identity on this request. No-op for zero-auth deploys. */
function requireAuth(req, res, next) {
if (!authIsConfigured) return next();
Expand Down Expand Up @@ -329,6 +335,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) => {
Expand Down Expand Up @@ -361,6 +377,16 @@ const app = express()
.use(express.static(path.resolve(rootDir, process.env.USER_DATA_DIR || 'user-data')))
.use(express.static(path.join(rootDir, 'dist')))
.use(express.static(path.join(rootDir, 'public'), { index: 'initialization.html' }))
// Jump straight to an aliased item, skipping the SPA boot (deploys with no login only)
.use(method('GET', (req, res, next) => {
try {
const url = anyLoginConfigured ? undefined : aliasTarget(config, req);
if (url) return res.set('Cache-Control', 'no-store').redirect(302, url);
} catch (e) {
printWarning('Could not resolve alias, falling back to the app', e);
}
return next();
}))
// If no other route is matched, serve up the index.html with a 404 status
.use((req, res) => {
res.status(404).sendFile('index.html', { root: path.join(rootDir, 'dist') }, (err) => {
Expand Down
36 changes: 36 additions & 0 deletions services/endpoints/alias-target.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Resolves a request path like /jelly to the URL of the item that claims
* that alias, so the server can redirect without the browser booting the app
*/

const configSchema = require('../../src/utils/config/ConfigSchema.json');
const { hostFromRequest } = require('../utils/request-origin');

/* Taken from the schema, so the server and the client can't disagree on what's reserved */
const RESERVED = configSchema.properties.sections.items
.properties.items.items.properties.alias.not.enum;

/* Anything carrying visibility rules is left to the client, which can evaluate them */
const isUnrestricted = (entity) => !entity.displayData;

/* Only redirect somewhere absolute, browsable, and not back at this same path */
const isSafeTarget = (url, req) => {
try {
const target = new URL(url);
if (!['http:', 'https:'].includes(target.protocol)) return false;
return target.host.toLowerCase() !== hostFromRequest(req)
|| target.pathname.replace(/\/+$/, '') !== req.path;
} catch {
return false;
}
};

module.exports = (config, req) => {
const alias = req.path.slice(1).toLowerCase();
if (!alias || alias.includes('/') || RESERVED.includes(alias)) return undefined;
const item = (config?.sections || [])
.filter(isUnrestricted)
.flatMap((section) => section.items || [])
.find((i) => isUnrestricted(i) && String(i.alias || '').toLowerCase() === alias);
return item && isSafeTarget(item.url, req) ? item.url : undefined;
};
29 changes: 29 additions & 0 deletions services/endpoints/opensearch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Builds the OpenSearch descriptor document, which lets browsers offer
* Dashy as a keyword search engine, for jumping to items by their alias
*/

const { originFromRequest } = require('../utils/request-origin');

/* Must match the title on index.html's <link rel="search">, 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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' }[c]),
);

module.exports = (config, req) => {
const origin = xmlEscape(originFromRequest(req));
const title = xmlEscape(config?.pageInfo?.title || SHORT_NAME);
return `<?xml version="1.0" encoding="UTF-8"?>
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
<ShortName>${SHORT_NAME}</ShortName>
<Description>Jump to an app on ${title} by its alias</Description>
<InputEncoding>UTF-8</InputEncoding>
<Image width="16" height="16" type="image/x-icon">${origin}/favicon.ico</Image>
<Url type="text/html" method="get" template="${origin}/{searchTerms}"/>
</OpenSearchDescription>
`;
};
19 changes: 19 additions & 0 deletions services/utils/request-origin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Works out the public host and origin a request arrived on,
* honouring the headers a reverse proxy sets in front of Dashy
*/

/* Proxy chains send a comma-separated list, the first entry is the original client-facing value */
const firstHeader = (req, name) => (req.headers[name] || '').split(',')[0].trim();

/* Hosts are case-insensitive, so always compare and emit them lowercased */
const hostFromRequest = (req) => (
firstHeader(req, 'x-forwarded-host') || req.headers.host || 'localhost'
).toLowerCase();

const originFromRequest = (req) => {
const proto = firstHeader(req, 'x-forwarded-proto') || (req.socket.encrypted ? 'https' : 'http');
return `${proto}://${hostFromRequest(req)}`;
};

module.exports = { hostFromRequest, originFromRequest };
8 changes: 6 additions & 2 deletions src/components/LinkItems/Item.vue
Original file line number Diff line number Diff line change
Expand Up @@ -175,14 +175,18 @@ 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
// Aliases only resolve against the root config, so never promise one on a sub-page
const aliasWorksHere = alias && !this.$store.getters.isSubConfig;
const hasShortcut = hotkey || aliasWorksHere;
if (!description && !provider && !hasShortcut && !this.titleTruncated) return {}; // Nothing to show
const parts = [];
if (this.titleTruncated) parts.push(`<b>${title}</b>`);
if (provider) parts.push(`<b>Provider</b>: ${provider}`);
if (description) parts.push(description);
if (hotkey) parts.push(`Press '${hotkey}' to launch`);
if (aliasWorksHere) parts.push(`Visit '/${alias}' to launch`);
const editKey = this.appConfig.disableContextMenu
? 'interactive-editor.edit-section.edit-tooltip-basic'
: 'interactive-editor.edit-section.edit-tooltip';
Expand Down
9 changes: 8 additions & 1 deletion src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ const handleAuthFailure = (provider, err) => {
const needsOidcLoginPage = () => isOidcLoginPageEnabled() && !isLoggedIn()
&& router.currentRoute.value.name !== 'login';

/* An alias redirect aborts the first navigation on purpose, anything else should still render */
const handleAbortedNavigation = (failure) => {
if (failure?.to?.name === 'alias') return;
ErrorHandler('Initial navigation failed', failure);
router.replace({ name: '404' }).catch(() => {}).finally(mount);
};

router.isReady().then(() => {
if (isOidcEnabled()) {
initOidcAuth().then((reloading) => {
Expand All @@ -86,4 +93,4 @@ router.isReady().then(() => {
} else {
mount();
}
});
}, handleAbortedNavigation);
34 changes: 32 additions & 2 deletions src/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ import Keys from '@/utils/StoreMutations';
import { isAuthEnabled, isLoggedIn, isGuestAccessEnabled } from '@/utils/auth/Auth';
import { isOidcEnabled } from '@/utils/auth/OidcAuth';
import { isKeycloakEnabled } from '@/utils/auth/KeycloakAuth';
import { isHeaderAuthEnabled } from '@/utils/auth/HeaderAuth';
import { initHeaderAuth, 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)' });
Expand Down Expand Up @@ -51,6 +51,22 @@ 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; }
};

/* Checks wheaather header auth is enabled, before the alias is allowed to access items */
const identityResolved = async () => {
if (!isHeaderAuthEnabled() || isLoggedIn()) return true;
return initHeaderAuth().then(() => true, () => false);
};

/* Build the canonical /<view>/: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. */
Expand Down Expand Up @@ -123,6 +139,20 @@ const router = createRouter({
next();
},
},
{ // Item aliases, where /<alias> redirects straight to that item's URL
path: '/:alias',
name: 'alias',
component: () => import('./views/404.vue'),
beforeEnter: async (to, from, next) => {
const url = (await identityResolved())
? getUrlForAlias(store.state.rootConfig?.sections, to.params.alias) : undefined;
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);
next(false);
},
},
{ // Redirect any not-found routed to the 404 view
path: '/:pathMatch(.*)*',
redirect: '/404',
Expand Down
6 changes: 3 additions & 3 deletions src/utils/Search.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions src/utils/config/ConfigHelpers.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 /<alias> 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
Expand Down
Loading
Loading