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
6 changes: 6 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@
# Set to 'true' to disable automatic backups before each config save
# DISABLE_CONFIG_BACKUPS=true

# Set to 'true' to enable the REST API for reading / writing config (see docs/api.md)
# ENABLE_API=true

# Optional bearer token granting full API access, as an alternative to Dashy's auth
# API_TOKEN=your-long-random-secret

# Setup any other user defined vars by prepending VITE_APP_ to the var name
# VITE_APP_pihole_ip=http://your.pihole.ip
# VITE_APP_pihole_key=your_pihole_secret_key
111 changes: 111 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# REST API

Dashy includes an optional REST API, for reading and writing your config programmatically — from the command line, scripts or third-party applications. It covers whole config files, as well as individual sections and items.

> [!NOTE]
> The API is served by Dashy's Node server, so it's available with Docker and bare-metal deployments, but not on static hosting providers (Netlify, Vercel, EdgeOne, CDN).

## Enabling the API

The API is disabled by default. To enable it, set the `ENABLE_API` environmental variable to `true`. For example, with Docker Compose:

```yaml
environment:
- ENABLE_API=true
```

Or with `docker run`, pass `-e ENABLE_API=true`. While disabled, all `/api/*` requests return a 404.

## Authentication

By default, the API uses Dashy's existing [server-side authentication](/docs/authentication.md). Read endpoints require any authenticated user, and write endpoints require an admin. If no auth is configured, the API is open — the same as Dashy's other endpoints.

```bash
# With HTTP Basic Auth (ENABLE_HTTP_AUTH or BASIC_AUTH_USERNAME / BASIC_AUTH_PASSWORD)
curl -u alice:hunter2 http://localhost:8080/api/config

# With OIDC / Keycloak, pass your ID token
curl -H 'Authorization: Bearer <id-token>' http://localhost:8080/api/config
```

### API token

If you have no auth configured, or would prefer a dedicated credential for the API, set the `API_TOKEN` environmental variable and send it as a bearer token. A valid token grants full (admin) access, and works alongside any other configured auth method.

```yaml
environment:
- ENABLE_API=true
- API_TOKEN=your-long-random-secret
```

```bash
curl -H 'Authorization: Bearer your-long-random-secret' http://localhost:8080/api/config
```

> [!NOTE]
> Setting `API_TOKEN` also secures the API on deployments that have no other auth — anonymous requests are then rejected. Use a long, random value (e.g. `openssl rand -hex 32`) and only send it over HTTPS. The token applies to the API only, not Dashy's other endpoints.

## Endpoints

`:filename` is any YAML config file in your user-data directory (e.g. `conf.yml`, or a sub-page like `home-lab.yml`). `:key` is one of the top-level config keys: `pageInfo`, `appConfig`, `sections` or `pages`.

**Method** | **Path** | **Description**
--- | --- | ---
`GET` | `/api/config` | List config files
`GET` | `/api/config/:filename` | Get a full config file, as JSON
`PUT` | `/api/config/:filename` | Replace a full config file
`GET` | `/api/config/:filename/:key` | Get a top-level key
`PUT` | `/api/config/:filename/:key` | Replace a top-level key
`POST` | `/api/config/:filename/sections` | Add a section (`name` required)
`GET` | `/api/config/:filename/sections/:sid` | Get a section
`PATCH` | `/api/config/:filename/sections/:sid` | Update fields on a section
`DELETE` | `/api/config/:filename/sections/:sid` | Delete a section
`GET` | `/api/config/:filename/sections/:sid/items` | List a section's items
`POST` | `/api/config/:filename/sections/:sid/items` | Add an item (`title` required)
`GET` | `/api/config/:filename/sections/:sid/items/:iid` | Get an item
`PATCH` | `/api/config/:filename/sections/:sid/items/:iid` | Update fields on an item
`DELETE` | `/api/config/:filename/sections/:sid/items/:iid` | Delete an item

All bodies are JSON. Errors return `{ "success": false, "message": "..." }` with an appropriate status code (400 bad input, 401/403 auth, 404 not found).

### Addressing Sections and Items

`:sid` and `:iid` can be either a zero-based index (`0`, `1`, ...) or an exact match on the section's `name` / item's `title` (URL-encoded). If multiple entries share a name, the first match wins. A section literally named `2` can only be addressed by index.

### Updating

`PATCH` does a shallow merge: only the fields you send are changed, but nested values (like a section's `items` array) are replaced wholesale if included. `PUT` replaces the target entirely.

## Examples

```bash
# List config files
curl http://localhost:8080/api/config

# Get your main config as JSON
curl http://localhost:8080/api/config/conf.yml

# Add an item to the first section
curl -X POST -H 'Content-Type: application/json' \
-d '{"title": "Grafana", "url": "https://grafana.local", "icon": "hl-grafana"}' \
http://localhost:8080/api/config/conf.yml/sections/0/items

# Rename a section
curl -X PATCH -H 'Content-Type: application/json' \
-d '{"name": "Monitoring"}' \
'http://localhost:8080/api/config/conf.yml/sections/Old%20Name'

# Update the theme
curl -X PUT -H 'Content-Type: application/json' \
-d '{"theme": "nord-frost"}' \
http://localhost:8080/api/config/conf.yml/appConfig
```

After modifying your config, refresh the page to see changes.

## Limitations & Notes

- Writes re-serialize the YAML file, so comments, anchors and custom formatting are discarded (the same applies to saving via the UI). A timestamped backup is saved to `user-data/config-backups/` before every write, unless `DISABLE_CONFIG_BACKUPS=true`
- Writes to `conf.yml` are validated against [the schema](https://github.com/Lissy93/dashy/blob/master/src/utils/config/ConfigSchema.json) and rejected if invalid. Sub-page files are not schema-validated, since they may contain only a subset of fields
- Config files are capped at 256 KB
- Concurrent writes are last-write-wins; there is no locking or optimistic concurrency
2 changes: 1 addition & 1 deletion docs/configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ All app configuration is specified in [`/user-data/conf.yml`](https://github.com
- From the UI, under the config menu there is a JSON editor, with built-in validation, documentation and advanced options
- **UI Visual Editor** _(3/5 reliability, 5/5 usability)_
- From the UI, enter the Interactive Edit Mode, then click any part of the page to edit. Changes are previewed live, and then saved to disk
- **REST API** _(Coming soon)_
- **REST API** _(see [API docs](/docs/api.md))_
- Programmatically edit config either through the command line, using a script or a third-party application

## Tips
Expand Down
1 change: 1 addition & 0 deletions docs/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
- [Icons](/docs/icons.md) - Outline of all available icon types for sections and items, with examples
- [Language Switching](/docs/multi-language-support.md) - Details on how to switch language, or add a new locale
- [Pages and Sections](/docs/pages-and-sections.md) - Multi-page support, sections, items and sub-items
- [REST API](/docs/api.md) - Programmatically read and update your config over HTTP
- [Status Indicators](/docs/status-indicators.md) - Using Dashy to monitor uptime and status of your apps/services and hosts
- [Searching & Shortcuts](/docs/searching.md) - Searching, launching methods + keyboard shortcuts
- [Theming](/docs/theming.md) - Complete guide to applying, writing and modifying themes + styles
Expand Down
101 changes: 101 additions & 0 deletions services/api/config-files.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* File-system + YAML helpers for the REST API
* Reads config files from USER_DATA_DIR, and reuses save-config.js for writes
*/
const fsPromises = require('fs').promises;
const path = require('path');
const yaml = require('js-yaml');
const Ajv = require('ajv');

const saveConfig = require('../save-config');
const schema = require('../../src/utils/config/ConfigSchema.json');

const rootDir = path.join(__dirname, '..', '..');

// Same rules as save-config.js: no path separators, control chars or ..
const SAFE_FILENAME = /^(?!\.+$)[^\\/\0\r\n]+\.ya?ml$/i;

const validateSchema = new Ajv({ strict: false, allowUnionTypes: true, allErrors: true })
.compile(schema);

/* An error with an associated HTTP status code, for the router to render */
class ApiError extends Error {
constructor(message, status = 400) {
super(message);
this.status = status;
}
}

const userDataDir = () => path.resolve(rootDir, process.env.USER_DATA_DIR || 'user-data');

/* Returns the validated basename of a config filename, or throws a 400 */
const safeFilename = (filename) => {
const base = path.basename(String(filename));
if (!SAFE_FILENAME.test(base)) {
throw new ApiError('Invalid filename: must be a basename ending in .yml or .yaml');
}
return base;
};

/* Lists all YAML config files in the user data directory */
const listConfigFiles = async () => {
const entries = await fsPromises.readdir(userDataDir(), { withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && /\.ya?ml$/i.test(entry.name))
.map((entry) => entry.name)
.sort();
};

/* Reads and parses a config file, returning it as a plain object */
const readConfig = async (filename) => {
const base = safeFilename(filename);
let raw;
try {
raw = await fsPromises.readFile(path.join(userDataDir(), base), 'utf8');
} catch (e) {
if (e.code === 'ENOENT') throw new ApiError(`${base} not found`, 404);
throw new ApiError(`Could not read ${base}`, 500);
}
let parsed;
try {
parsed = yaml.load(raw);
} catch (e) {
throw new ApiError(`Could not parse ${base}: ${e.reason || e.message}`, 500);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new ApiError(`${base} is not a valid config (expected a YAML mapping)`);
}
return parsed;
};

/* Serializes and writes a config, via save-config for backups + size limits.
The root conf.yml is validated against the schema; sub-pages are not,
since they may contain only a subset of fields (e.g. just pageInfo) */
const writeConfig = async (filename, config) => {
const base = safeFilename(filename);
if (base === 'conf.yml' && !validateSchema(config)) {
const issues = (validateSchema.errors || []).slice(0, 5)
.map((e) => `${e.instancePath || '/'} ${e.message}`).join('; ');
throw new ApiError(`Config does not conform to schema: ${issues}`);
}
const result = await new Promise((resolve) => {
saveConfig({ config: yaml.dump(config, { noRefs: true }), filename: base }, (jsonStr) => {
resolve(JSON.parse(jsonStr));
});
});
if (!result.success) throw new ApiError(result.message);
return result.message;
};

/* Resolves a section/item identifier (numeric index, or match on keyField)
to an array index, returning -1 when not found */
const resolveIndex = (arr, id, keyField) => {
if (/^\d+$/.test(id)) {
return Number(id) < arr.length ? Number(id) : -1;
}
return arr.findIndex((entry) => entry && entry[keyField] === id);
};

module.exports = {
ApiError, safeFilename, listConfigFiles, readConfig, writeConfig, resolveIndex,
};
Loading