From 1500839ef5130447053f128ddec004f063322080 Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Sun, 14 Jun 2026 18:22:17 +0100 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20REST=20API=20for=20dashboard=20?= =?UTF-8?q?management=20(#2011)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 3 + docs/api.md | 94 ++++++++++++ docs/configuring.md | 2 +- docs/readme.md | 1 + services/api/config-files.js | 102 ++++++++++++ services/api/index.js | 183 ++++++++++++++++++++++ services/app.js | 9 ++ tests/server/api-auth.test.js | 57 +++++++ tests/server/api.test.js | 282 ++++++++++++++++++++++++++++++++++ 9 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 docs/api.md create mode 100644 services/api/config-files.js create mode 100644 services/api/index.js create mode 100644 tests/server/api-auth.test.js create mode 100644 tests/server/api.test.js diff --git a/.env b/.env index 20f181d468..86a5306682 100644 --- a/.env +++ b/.env @@ -67,6 +67,9 @@ # 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 + # 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 diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000000..36d9801448 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,94 @@ +# 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 + +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 ' http://localhost:8080/api/config +``` + +## 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 diff --git a/docs/configuring.md b/docs/configuring.md index bf1c536991..0561cc362c 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -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 diff --git a/docs/readme.md b/docs/readme.md index 1192cbafb5..12f4c15e04 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -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 diff --git a/services/api/config-files.js b/services/api/config-files.js new file mode 100644 index 0000000000..c12bd2eac6 --- /dev/null +++ b/services/api/config-files.js @@ -0,0 +1,102 @@ +/** + * File-system + YAML helpers for the REST API. + * Reads config files from USER_DATA_DIR, and reuses save-config.js for + * writes, so backups, size limits and filename validation stay in one place + */ +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, +}; diff --git a/services/api/index.js b/services/api/index.js new file mode 100644 index 0000000000..a872339986 --- /dev/null +++ b/services/api/index.js @@ -0,0 +1,183 @@ +/** + * Opt-in REST API for reading + writing config files (see docs/api.md). + * Disabled unless ENABLE_API=true. Mounted at /api by app.js, which passes + * in its auth middleware: reads require any user, writes require an admin + */ +const express = require('express'); + +const { + ApiError, safeFilename, listConfigFiles, readConfig, writeConfig, resolveIndex, +} = require('./config-files'); + +/* The editable top-level config keys (matches ConfigSchema.json) */ +const TOP_LEVEL_KEYS = ['pageInfo', 'appConfig', 'sections', 'pages']; + +/* Renders errors raised before route handlers (e.g. malformed JSON or + oversized bodies from express.json) as JSON, instead of Express's HTML */ +const apiErrorHandler = (err, req, res, next) => { + if (res.headersSent) return next(err); + return res.status(err.status || 400).json({ success: false, message: err.message }); +}; + +/* Responds with a 404 unless the API has been explicitly enabled */ +const apiEnabledGate = (req, res, next) => { + if (process.env.ENABLE_API === 'true') return next(); + return res.status(404).json({ + success: false, + message: 'API not enabled. Set ENABLE_API=true to use the REST API.', + }); +}; + +/* Throws a 400 unless the request body is a plain object */ +const requireObjectBody = (body, what) => { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new ApiError(`Request body must be ${what}`); + } +}; + +/* Throws a 400 if key isn't one of the editable top-level config keys */ +const checkKey = (key) => { + if (!TOP_LEVEL_KEYS.includes(key)) { + throw new ApiError(`Invalid key '${key}', must be one of: ${TOP_LEVEL_KEYS.join(', ')}`); + } +}; + +/* Returns parent[key] as an array, creating it if requested + missing */ +const getArray = (parent, key, create) => { + if (parent[key] === undefined && create) parent[key] = []; + const arr = parent[key] === undefined ? [] : parent[key]; + if (!Array.isArray(arr)) throw new ApiError(`'${key}' is not a list`); + return arr; +}; + +/* Locates a section by index or name, throwing a 404 when not found */ +const findSection = (config, sid) => { + const sections = getArray(config, 'sections'); + const index = resolveIndex(sections, sid, 'name'); + if (index === -1) throw new ApiError(`Section '${sid}' not found`, 404); + return { sections, index, section: sections[index] }; +}; + +/* Locates an item within a section by index or title */ +const findItem = (section, iid) => { + const items = getArray(section, 'items'); + const index = resolveIndex(items, iid, 'title'); + if (index === -1) throw new ApiError(`Item '${iid}' not found`, 404); + return { items, index, item: items[index] }; +}; + +const createApiRouter = ({ requireAuth, requireAdmin, onConfigSaved }) => { + const router = express.Router(); + + /* Wraps an async handler, rendering thrown ApiErrors as JSON */ + const route = (handler) => async (req, res) => { + try { + await handler(req, res); + } catch (e) { + res.status(e.status || 500).json({ success: false, message: e.message }); + } + }; + + /* Read-modify-write handler: applies mutate() to the parsed config, writes + it back, and responds with the save message + anything mutate returned */ + const update = (mutate, status = 200) => route(async (req, res) => { + const config = await readConfig(req.params.filename); + const result = mutate(req, config); + const message = await writeConfig(req.params.filename, config); + if (onConfigSaved) onConfigSaved(safeFilename(req.params.filename), config); + res.status(status).json({ success: true, message, ...result }); + }); + + router.get('/config', requireAuth, route(async (req, res) => { + res.json({ success: true, files: await listConfigFiles() }); + })); + + router.get('/config/:filename', requireAuth, route(async (req, res) => { + res.json(await readConfig(req.params.filename)); + })); + + router.put('/config/:filename', requireAdmin, route(async (req, res) => { + requireObjectBody(req.body, 'a config object'); + const message = await writeConfig(req.params.filename, req.body); + if (onConfigSaved) onConfigSaved(safeFilename(req.params.filename), req.body); + res.json({ success: true, message }); + })); + + router.post('/config/:filename/sections', requireAdmin, update((req, config) => { + requireObjectBody(req.body, 'a section object'); + if (!req.body.name) throw new ApiError("Section must have a 'name'"); + const sections = getArray(config, 'sections', true); + sections.push(req.body); + return { index: sections.length - 1, section: req.body }; + }, 201)); + + router.get('/config/:filename/sections/:sid', requireAuth, route(async (req, res) => { + const config = await readConfig(req.params.filename); + res.json(findSection(config, req.params.sid).section); + })); + + router.patch('/config/:filename/sections/:sid', requireAdmin, update((req, config) => { + requireObjectBody(req.body, 'a partial section object'); + const { section } = findSection(config, req.params.sid); + Object.assign(section, req.body); + return { section }; + })); + + router.delete('/config/:filename/sections/:sid', requireAdmin, update((req, config) => { + const { sections, index } = findSection(config, req.params.sid); + sections.splice(index, 1); + })); + + router.get('/config/:filename/sections/:sid/items', requireAuth, route(async (req, res) => { + const config = await readConfig(req.params.filename); + res.json(getArray(findSection(config, req.params.sid).section, 'items')); + })); + + router.post('/config/:filename/sections/:sid/items', requireAdmin, update((req, config) => { + requireObjectBody(req.body, 'an item object'); + if (!req.body.title) throw new ApiError("Item must have a 'title'"); + const items = getArray(findSection(config, req.params.sid).section, 'items', true); + items.push(req.body); + return { index: items.length - 1, item: req.body }; + }, 201)); + + router.get('/config/:filename/sections/:sid/items/:iid', requireAuth, route(async (req, res) => { + const config = await readConfig(req.params.filename); + const { section } = findSection(config, req.params.sid); + res.json(findItem(section, req.params.iid).item); + })); + + router.patch('/config/:filename/sections/:sid/items/:iid', requireAdmin, update((req, config) => { + requireObjectBody(req.body, 'a partial item object'); + const { section } = findSection(config, req.params.sid); + const { item } = findItem(section, req.params.iid); + Object.assign(item, req.body); + return { item }; + })); + + router.delete('/config/:filename/sections/:sid/items/:iid', requireAdmin, update((req, config) => { + const { section } = findSection(config, req.params.sid); + const { items, index } = findItem(section, req.params.iid); + items.splice(index, 1); + })); + + router.get('/config/:filename/:key', requireAuth, route(async (req, res) => { + checkKey(req.params.key); + const config = await readConfig(req.params.filename); + if (config[req.params.key] === undefined) { + throw new ApiError(`'${req.params.key}' not present in config`, 404); + } + res.json(config[req.params.key]); + })); + + router.put('/config/:filename/:key', requireAdmin, update((req, config) => { + checkKey(req.params.key); + config[req.params.key] = req.body; + })); + + router.use((req, res) => res.status(404).json({ success: false, message: 'Not found' })); + + return router; +}; + +module.exports = { apiEnabledGate, apiErrorHandler, createApiRouter }; diff --git a/services/app.js b/services/app.js index b46d3eb3dc..fd24b874bf 100644 --- a/services/app.js +++ b/services/app.js @@ -31,6 +31,7 @@ const systemInfo = require('./system-info'); // Basic system info, for resource const sslServer = require('./ssl-server'); // TLS-enabled web server const corsProxy = require('./cors-proxy'); // Enables API requests to CORS-blocked services const getUser = require('./get-user'); // Enables server side user lookup +const { apiEnabledGate, apiErrorHandler, createApiRouter } = require('./api'); // Opt-in REST API const { loadOidcSettings, createOidcMiddleware, maybeBootstrapConfig } = require('./auth-oidc'); @@ -44,6 +45,7 @@ const ENDPOINTS = { systemInfo: '/system-info', corsProxy: '/cors-proxy', getUser: '/get-user', + api: '/api', }; /* Read package version once at startup, so healthcheck never touches the disk per-request */ @@ -298,6 +300,13 @@ const app = express() safeEnd(res, errBody(e)); } })) + // REST API for reading / writing config files (no-op 404 unless ENABLE_API=true) + .use(ENDPOINTS.api, apiEnabledGate, protectConfig, createApiRouter({ + requireAuth, + requireAdmin, + onConfigSaved: (filename, newConf) => { if (filename === 'conf.yml') config = newConf; }, + })) + .use(ENDPOINTS.api, apiErrorHandler) // 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/tests/server/api-auth.test.js b/tests/server/api-auth.test.js new file mode 100644 index 0000000000..a7352c586a --- /dev/null +++ b/tests/server/api-auth.test.js @@ -0,0 +1,57 @@ +// @vitest-environment node +import { describe, it, expect, afterAll } from 'vitest'; +import request from 'supertest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// Auth strategy is chosen when the app module loads, so env must be set first +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashy-api-auth-')); +process.env.USER_DATA_DIR = tmpDir; +process.env.ENABLE_API = 'true'; +process.env.BASIC_AUTH_USERNAME = 'admin'; +process.env.BASIC_AUTH_PASSWORD = 'test-pass'; +fs.writeFileSync(path.join(tmpDir, 'conf.yml'), 'pageInfo:\n title: Test\nsections: []\n'); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + delete process.env.ENABLE_API; + delete process.env.BASIC_AUTH_USERNAME; + delete process.env.BASIC_AUTH_PASSWORD; +}); + +const app = require('../../services/app'); + +describe('API auth', () => { + it('rejects unauthenticated requests', async () => { + const res = await request(app).get('/api/config'); + expect(res.status).toBe(401); + }); + + it('rejects incorrect credentials', async () => { + const res = await request(app).get('/api/config').auth('admin', 'wrong'); + expect(res.status).toBe(401); + }); + + it('allows authenticated reads', async () => { + const res = await request(app).get('/api/config').auth('admin', 'test-pass'); + expect(res.status).toBe(200); + expect(res.body.files).toContain('conf.yml'); + }); + + it('allows authenticated writes', async () => { + const res = await request(app).put('/api/config/conf.yml') + .auth('admin', 'test-pass') + .send({ pageInfo: { title: 'Updated' }, sections: [] }); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it('responds with the disabled message before any auth challenge', async () => { + process.env.ENABLE_API = 'false'; + const res = await request(app).get('/api/config'); + expect(res.status).toBe(404); + expect(res.headers['www-authenticate']).toBeUndefined(); + process.env.ENABLE_API = 'true'; + }); +}); diff --git a/tests/server/api.test.js b/tests/server/api.test.js new file mode 100644 index 0000000000..9a38c35111 --- /dev/null +++ b/tests/server/api.test.js @@ -0,0 +1,282 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import request from 'supertest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import yaml from 'js-yaml'; + +// Isolate writes to a temp dir so the real user-data/conf.yml is never touched +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashy-api-')); +process.env.USER_DATA_DIR = tmpDir; +process.env.ENABLE_API = 'true'; +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + delete process.env.ENABLE_API; +}); + +const app = require('../../services/app'); + +const seedConf = `pageInfo: + title: Test Dashboard +sections: + - name: Section One + items: + - title: Item A + url: https://example.com/a + - title: Item B + - name: Section Two +`; + +const readDisk = (file) => yaml.load(fs.readFileSync(path.join(tmpDir, file), 'utf8')); + +beforeEach(() => { + fs.writeFileSync(path.join(tmpDir, 'conf.yml'), seedConf); + fs.writeFileSync(path.join(tmpDir, 'page2.yml'), 'pageInfo:\n title: Page Two\n'); +}); + +describe('API enablement gate', () => { + it('returns 404 when ENABLE_API is not true', async () => { + process.env.ENABLE_API = 'false'; + const res = await request(app).get('/api/config'); + expect(res.status).toBe(404); + expect(res.body.message).toContain('API not enabled'); + process.env.ENABLE_API = 'true'; + }); + + it('returns 404 when ENABLE_API is unset', async () => { + delete process.env.ENABLE_API; + const res = await request(app).get('/api/config'); + expect(res.status).toBe(404); + process.env.ENABLE_API = 'true'; + }); + + it('returns 404 for unknown API routes', async () => { + const res = await request(app).get('/api/nonsense'); + expect(res.status).toBe(404); + expect(res.body.message).toBe('Not found'); + }); +}); + +describe('List and read config', () => { + it('lists config files, excluding backups', async () => { + const res = await request(app).get('/api/config'); + expect(res.status).toBe(200); + expect(res.body.files).toContain('conf.yml'); + expect(res.body.files).toContain('page2.yml'); + expect(res.body.files).not.toContain('config-backups'); + }); + + it('returns a config file as JSON', async () => { + const res = await request(app).get('/api/config/conf.yml'); + expect(res.status).toBe(200); + expect(res.body.pageInfo.title).toBe('Test Dashboard'); + expect(res.body.sections).toHaveLength(2); + }); + + it('404s for a missing file', async () => { + const res = await request(app).get('/api/config/nope.yml'); + expect(res.status).toBe(404); + }); + + it('rejects path traversal', async () => { + const res = await request(app).get('/api/config/..%2F..%2Fetc%2Fpasswd'); + expect(res.status).toBe(400); + }); + + it('rejects non-yaml filenames', async () => { + const res = await request(app).get('/api/config/evil.txt'); + expect(res.status).toBe(400); + }); + + it('returns a top-level key', async () => { + const res = await request(app).get('/api/config/conf.yml/pageInfo'); + expect(res.status).toBe(200); + expect(res.body.title).toBe('Test Dashboard'); + }); + + it('rejects a non-whitelisted key', async () => { + const res = await request(app).get('/api/config/conf.yml/banana'); + expect(res.status).toBe(400); + }); + + it('404s for an absent key', async () => { + const res = await request(app).get('/api/config/conf.yml/appConfig'); + expect(res.status).toBe(404); + }); + + it('500s for unparseable YAML', async () => { + fs.writeFileSync(path.join(tmpDir, 'broken.yml'), 'foo: [unclosed'); + const res = await request(app).get('/api/config/broken.yml'); + expect(res.status).toBe(500); + }); +}); + +describe('Replace config and keys', () => { + it('replaces a whole config file and makes a backup', async () => { + const res = await request(app).put('/api/config/conf.yml') + .send({ pageInfo: { title: 'Replaced' }, sections: [] }); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(readDisk('conf.yml').pageInfo.title).toBe('Replaced'); + const backups = fs.readdirSync(path.join(tmpDir, 'config-backups')); + expect(backups.some((f) => f.startsWith('conf-'))).toBe(true); + }); + + it('rejects schema-invalid conf.yml writes', async () => { + const res = await request(app).put('/api/config/conf.yml') + .send({ sections: 'not-a-list' }); + expect(res.status).toBe(400); + expect(res.body.message).toContain('schema'); + expect(readDisk('conf.yml').sections).toHaveLength(2); + }); + + it('accepts a pageInfo-only sub-page write', async () => { + const res = await request(app).put('/api/config/page2.yml') + .send({ pageInfo: { title: 'Updated Page' } }); + expect(res.status).toBe(200); + expect(readDisk('page2.yml').pageInfo.title).toBe('Updated Page'); + }); + + it('rejects oversized configs', async () => { + const res = await request(app).put('/api/config/conf.yml') + .send({ sections: [{ name: 'x'.repeat(300 * 1024) }] }); + expect(res.status).toBe(400); + expect(res.body.message).toContain('256'); + }); + + it('replaces a top-level key', async () => { + const res = await request(app).put('/api/config/conf.yml/pageInfo') + .send({ title: 'New Title' }); + expect(res.status).toBe(200); + expect(readDisk('conf.yml').pageInfo.title).toBe('New Title'); + }); +}); + +describe('Sections CRUD', () => { + it('adds a section', async () => { + const res = await request(app).post('/api/config/conf.yml/sections') + .send({ name: 'Section Three' }); + expect(res.status).toBe(201); + expect(res.body.index).toBe(2); + expect(readDisk('conf.yml').sections).toHaveLength(3); + }); + + it('rejects a section without a name', async () => { + const res = await request(app).post('/api/config/conf.yml/sections').send({}); + expect(res.status).toBe(400); + }); + + it('gets a section by index', async () => { + const res = await request(app).get('/api/config/conf.yml/sections/0'); + expect(res.body.name).toBe('Section One'); + }); + + it('gets a section by name', async () => { + const res = await request(app).get('/api/config/conf.yml/sections/Section%20Two'); + expect(res.body.name).toBe('Section Two'); + }); + + it('patches a section, leaving other fields untouched', async () => { + const res = await request(app).patch('/api/config/conf.yml/sections/0') + .send({ icon: 'fas fa-rocket' }); + expect(res.status).toBe(200); + const section = readDisk('conf.yml').sections[0]; + expect(section.icon).toBe('fas fa-rocket'); + expect(section.items).toHaveLength(2); + }); + + it('deletes a section', async () => { + const res = await request(app).delete('/api/config/conf.yml/sections/1'); + expect(res.status).toBe(200); + expect(readDisk('conf.yml').sections).toHaveLength(1); + }); + + it('404s for an unknown section', async () => { + expect((await request(app).get('/api/config/conf.yml/sections/99')).status).toBe(404); + expect((await request(app).get('/api/config/conf.yml/sections/Nope')).status).toBe(404); + }); + + it('400s when sections is not a list', async () => { + fs.writeFileSync(path.join(tmpDir, 'scalar.yml'), 'sections: just-a-string\n'); + const res = await request(app).get('/api/config/scalar.yml/sections/0'); + expect(res.status).toBe(400); + }); +}); + +describe('Items CRUD', () => { + it('lists items, defaulting to empty for a section without any', async () => { + expect((await request(app).get('/api/config/conf.yml/sections/0/items')).body).toHaveLength(2); + expect((await request(app).get('/api/config/conf.yml/sections/1/items')).body).toEqual([]); + }); + + it('adds an item, creating the items array if missing', async () => { + const res = await request(app).post('/api/config/conf.yml/sections/1/items') + .send({ title: 'New Item' }); + expect(res.status).toBe(201); + expect(readDisk('conf.yml').sections[1].items[0].title).toBe('New Item'); + }); + + it('rejects an item without a title', async () => { + const res = await request(app).post('/api/config/conf.yml/sections/0/items').send({}); + expect(res.status).toBe(400); + }); + + it('gets an item by index and by title', async () => { + expect((await request(app).get('/api/config/conf.yml/sections/0/items/1')).body.title).toBe('Item B'); + expect((await request(app).get('/api/config/conf.yml/sections/0/items/Item%20A')).body.title).toBe('Item A'); + }); + + it('patches an item', async () => { + const res = await request(app).patch('/api/config/conf.yml/sections/0/items/0') + .send({ url: 'https://example.com/new' }); + expect(res.status).toBe(200); + const item = readDisk('conf.yml').sections[0].items[0]; + expect(item.url).toBe('https://example.com/new'); + expect(item.title).toBe('Item A'); + }); + + it('deletes an item', async () => { + const res = await request(app).delete('/api/config/conf.yml/sections/0/items/0'); + expect(res.status).toBe(200); + expect(readDisk('conf.yml').sections[0].items).toHaveLength(1); + }); + + it('404s for an unknown item', async () => { + const res = await request(app).get('/api/config/conf.yml/sections/0/items/99'); + expect(res.status).toBe(404); + }); +}); + +describe('Robustness', () => { + it('returns JSON for malformed request bodies', async () => { + const res = await request(app).post('/api/config/conf.yml/sections') + .set('Content-Type', 'application/json').send('{not json'); + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('returns JSON for bodies over the 1mb parser limit', async () => { + const res = await request(app).put('/api/config/conf.yml') + .set('Content-Type', 'application/json') + .send(`{"sections": [{"name": "${'x'.repeat(1100 * 1024)}"}]}`); + expect(res.status).toBe(413); + expect(res.body.success).toBe(false); + }); + + + it('does not write when the existing file is unparseable', async () => { + fs.writeFileSync(path.join(tmpDir, 'broken.yml'), 'foo: [unclosed'); + const res = await request(app).patch('/api/config/broken.yml/sections/0').send({ name: 'x' }); + expect(res.status).toBe(500); + expect(fs.readFileSync(path.join(tmpDir, 'broken.yml'), 'utf8')).toBe('foo: [unclosed'); + }); + + it('handles concurrent writes without corrupting the file', async () => { + const patch = (icon) => request(app) + .patch('/api/config/conf.yml/sections/0').send({ icon }); + const results = await Promise.all(['a', 'b', 'c', 'd', 'e'].map(patch)); + results.forEach((res) => expect(res.status).toBe(200)); + expect(() => readDisk('conf.yml')).not.toThrow(); + }); +}); From c32b4a84d029f524390b21e95b14cd48524da7f8 Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Mon, 15 Jun 2026 11:13:46 +0100 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=94=91=20Add=20support=20for=20custom?= =?UTF-8?q?=20API=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/api/config-files.js | 5 ++- services/api/index.js | 60 +++++++++++++++++++++++++++++++----- services/app.js | 5 +-- 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/services/api/config-files.js b/services/api/config-files.js index c12bd2eac6..a8266178a5 100644 --- a/services/api/config-files.js +++ b/services/api/config-files.js @@ -1,7 +1,6 @@ /** - * File-system + YAML helpers for the REST API. - * Reads config files from USER_DATA_DIR, and reuses save-config.js for - * writes, so backups, size limits and filename validation stay in one place + * 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'); diff --git a/services/api/index.js b/services/api/index.js index a872339986..fb56d58e4d 100644 --- a/services/api/index.js +++ b/services/api/index.js @@ -1,8 +1,9 @@ /** - * Opt-in REST API for reading + writing config files (see docs/api.md). - * Disabled unless ENABLE_API=true. Mounted at /api by app.js, which passes - * in its auth middleware: reads require any user, writes require an admin + * Opt-in REST API for reading + writing config files (see docs/api.md) + * Disabled unless ENABLE_API=true + * Uses the same auth as rest of Dashy or API_TOKEN as bearer token */ +const crypto = require('crypto'); const express = require('express'); const { @@ -12,8 +13,14 @@ const { /* The editable top-level config keys (matches ConfigSchema.json) */ const TOP_LEVEL_KEYS = ['pageInfo', 'appConfig', 'sections', 'pages']; -/* Renders errors raised before route handlers (e.g. malformed JSON or - oversized bodies from express.json) as JSON, instead of Express's HTML */ +/* Check specified token matches allowed token */ +const tokensMatch = (a, b) => { + const x = Buffer.from(String(a)); + const y = Buffer.from(String(b)); + return x.length === y.length && crypto.timingSafeEqual(x, y); +}; + +/* Renders errors raised before route handlers as jason */ const apiErrorHandler = (err, req, res, next) => { if (res.headersSent) return next(err); return res.status(err.status || 400).json({ success: false, message: err.message }); @@ -66,9 +73,44 @@ const findItem = (section, iid) => { return { items, index, item: items[index] }; }; -const createApiRouter = ({ requireAuth, requireAdmin, onConfigSaved }) => { +const createApiRouter = ({ + protectConfig, authIsConfigured, onConfigSaved, requireAdmin: delegateAdmin, +}) => { const router = express.Router(); + /* The API is gated when Dashy auth, or an API token, is configured */ + const secured = () => authIsConfigured || Boolean(process.env.API_TOKEN); + + /* Either authenticate with API_TOKEN bearer if set, or defer to user's auth */ + const apiAuth = (req, res, next) => { + const token = process.env.API_TOKEN; + if (token) { + const header = req.headers.authorization || ''; + const provided = header.startsWith('Bearer ') ? header.slice(7) : ''; + if (provided && tokensMatch(provided, token)) { + req.auth = { user: 'api-token', isAdmin: true }; + return next(); + } + } + return protectConfig(req, res, next); + }; + + /* Require any authenticated identity */ + const requireAuth = (req, res, next) => { + if (!secured() || req.auth) return next(); + return res.status(401).json({ success: false, message: 'Unauthorized' }); + }; + + /* Require an admin identity, delegating the role check to Dashy's auth */ + const requireAdmin = (req, res, next) => { + if (!secured()) return next(); + if (req.auth?.isAdmin === true) return next(); + if (!req.auth) return res.status(401).json({ success: false, message: 'Unauthorized' }); + return delegateAdmin(req, res, next); + }; + + router.use(apiAuth); + /* Wraps an async handler, rendering thrown ApiErrors as JSON */ const route = (handler) => async (req, res) => { try { @@ -78,8 +120,10 @@ const createApiRouter = ({ requireAuth, requireAdmin, onConfigSaved }) => { } }; - /* Read-modify-write handler: applies mutate() to the parsed config, writes - it back, and responds with the save message + anything mutate returned */ + /* Read-modify-write handler + * Applies mutate() to the parsed config, writes it back + * Responds with the save message + anything mutate returned + */ const update = (mutate, status = 200) => route(async (req, res) => { const config = await readConfig(req.params.filename); const result = mutate(req, config); diff --git a/services/app.js b/services/app.js index fd24b874bf..f957938b38 100644 --- a/services/app.js +++ b/services/app.js @@ -301,9 +301,10 @@ const app = express() } })) // REST API for reading / writing config files (no-op 404 unless ENABLE_API=true) - .use(ENDPOINTS.api, apiEnabledGate, protectConfig, createApiRouter({ - requireAuth, + .use(ENDPOINTS.api, apiEnabledGate, createApiRouter({ + protectConfig, requireAdmin, + authIsConfigured, onConfigSaved: (filename, newConf) => { if (filename === 'conf.yml') config = newConf; }, })) .use(ENDPOINTS.api, apiErrorHandler) From 2bfae4dc03f29e4dc1d58aa4ffd0dcccece109f9 Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Mon, 15 Jun 2026 11:14:22 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=93=9D=20Adds=20docs=20and=20swagger?= =?UTF-8?q?=20spec=20for=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 3 + docs/api.md | 19 +- services/api/openapi.yml | 638 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 659 insertions(+), 1 deletion(-) create mode 100644 services/api/openapi.yml diff --git a/.env b/.env index 86a5306682..619b503d5b 100644 --- a/.env +++ b/.env @@ -70,6 +70,9 @@ # 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 diff --git a/docs/api.md b/docs/api.md index 36d9801448..f8cc841d67 100644 --- a/docs/api.md +++ b/docs/api.md @@ -18,7 +18,7 @@ Or with `docker run`, pass `-e ENABLE_API=true`. While disabled, all `/api/*` re ## Authentication -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. +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) @@ -28,6 +28,23 @@ curl -u alice:hunter2 http://localhost:8080/api/config curl -H 'Authorization: Bearer ' 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`. diff --git a/services/api/openapi.yml b/services/api/openapi.yml new file mode 100644 index 0000000000..a2c3ed8e8c --- /dev/null +++ b/services/api/openapi.yml @@ -0,0 +1,638 @@ +openapi: 3.1.0 + +info: + title: Dashy REST API + version: 1.0.0 + summary: Read and write your Dashy config over HTTP. + description: | + The Dashy API is a simple opt-in REST API for CRUD actions over your config. + For full usage guide, examples and architecture, see the [API Docs](https://dashy.to/docs/api). + +
+ Enabling the API + + The API is off by default. To enable it, set the `ENABLE_API` env var to true, + and restart Dashy. + +
+ +
+ Authenticating + + By default, the API will use the same auth system as the rest of your Dashy instance. + If you don't have auth configured, or would prefer to use bearer token for the API instead, + then you can do so, by setting an `API_TOKEN` environmental variable, + and then passing that as a bearer token in the authorization header when making requests, + like `curl -H 'Authorization: Bearer ' http://dashy.local/api/config` + +
+ + > [!IMPORTANT] + > There is no warranty. It is your responsibility to correctly configure and protect your instance. + > The API is experimental. + + contact: + name: GitHub + url: https://github.com/Lissy93/dashy + license: + name: MIT + url: https://github.com/Lissy93/dashy/blob/master/LICENSE + x-logo: + url: https://raw.githubusercontent.com/Lissy93/dashy/master/public/web-icons/dashy-logo.png + altText: Dashy + href: https://dashy.to + +externalDocs: + description: API guide + url: https://dashy.to/docs/api + +servers: + - url: "{scheme}://{host}/api" + description: Your Dashy instance + variables: + scheme: + enum: [http, https] + default: http + host: + default: localhost:4000 + +tags: + - name: Files + description: List, read and replace whole config files. + - name: Keys + description: Read and replace a single top-level config key. + - name: Sections + description: Create, read, update and delete sections. + - name: Items + description: Create, read, update and delete items within a section. + +security: + - bearerAuth: [] + - basicAuth: [] + - {} + +paths: + /config: + get: + tags: [Files] + summary: List config files + operationId: listConfigFiles + description: Lists every YAML config file in the user-data directory. + responses: + "200": + description: Config files + content: + application/json: + schema: { $ref: "#/components/schemas/FileList" } + example: { success: true, files: [conf.yml, home-lab.yml] } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/Disabled" } + + /config/{filename}: + parameters: + - $ref: "#/components/parameters/Filename" + get: + tags: [Files] + summary: Get config file + operationId: getConfigFile + description: Returns the parsed config file as JSON. + responses: + "200": + description: Parsed config + content: + application/json: + schema: { $ref: "#/components/schemas/Config" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/ParseError" } + put: + tags: [Files] + summary: Replace config file + operationId: replaceConfigFile + description: | + Overwrites the whole file. Writes to `conf.yml` are validated against the + config schema; sub-pages are not. A timestamped backup is taken first. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/Config" } + example: { pageInfo: { title: My Dashboard }, sections: [] } + responses: + "200": { $ref: "#/components/responses/Saved" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "413": { $ref: "#/components/responses/TooLarge" } + + /config/{filename}/{key}: + parameters: + - $ref: "#/components/parameters/Filename" + - $ref: "#/components/parameters/Key" + get: + tags: [Keys] + summary: Get top-level key + operationId: getConfigKey + description: Returns a single top-level key from the config. + responses: + "200": + description: Key value + content: + application/json: + schema: { $ref: "#/components/schemas/KeyValue" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + put: + tags: [Keys] + summary: Replace top-level key + operationId: replaceConfigKey + description: Replaces a single top-level key, leaving the rest of the file untouched. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/KeyValue" } + example: { theme: nord-frost, layout: auto } + responses: + "200": { $ref: "#/components/responses/Saved" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "413": { $ref: "#/components/responses/TooLarge" } + + /config/{filename}/sections: + parameters: + - $ref: "#/components/parameters/Filename" + post: + tags: [Sections] + summary: Add section + operationId: addSection + description: Appends a new section. A `name` is required. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/Section" } + example: { name: Monitoring, icon: fas fa-chart-line, items: [] } + responses: + "201": + description: Section added + content: + application/json: + schema: { $ref: "#/components/schemas/SectionResult" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + + /config/{filename}/sections/{sid}: + parameters: + - $ref: "#/components/parameters/Filename" + - $ref: "#/components/parameters/SectionId" + get: + tags: [Sections] + summary: Get section + operationId: getSection + responses: + "200": + description: Section + content: + application/json: + schema: { $ref: "#/components/schemas/Section" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + tags: [Sections] + summary: Update section + operationId: updateSection + description: Shallow-merges the supplied fields. Sending `items` replaces the whole array. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SectionFields" } + example: { name: Renamed Section } + responses: + "200": + description: Updated section + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/WriteResult" + - type: object + properties: + section: { $ref: "#/components/schemas/Section" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [Sections] + summary: Delete section + operationId: deleteSection + responses: + "200": { $ref: "#/components/responses/Saved" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /config/{filename}/sections/{sid}/items: + parameters: + - $ref: "#/components/parameters/Filename" + - $ref: "#/components/parameters/SectionId" + get: + tags: [Items] + summary: List items + operationId: listItems + responses: + "200": + description: Items in the section + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/Item" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + post: + tags: [Items] + summary: Add item + operationId: addItem + description: Appends a new item to the section. A `title` is required. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/Item" } + example: { title: Grafana, url: https://grafana.local, icon: hl-grafana } + responses: + "201": + description: Item added + content: + application/json: + schema: { $ref: "#/components/schemas/ItemResult" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /config/{filename}/sections/{sid}/items/{iid}: + parameters: + - $ref: "#/components/parameters/Filename" + - $ref: "#/components/parameters/SectionId" + - $ref: "#/components/parameters/ItemId" + get: + tags: [Items] + summary: Get item + operationId: getItem + responses: + "200": + description: Item + content: + application/json: + schema: { $ref: "#/components/schemas/Item" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + tags: [Items] + summary: Update item + operationId: updateItem + description: Shallow-merges the supplied fields onto the item. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ItemFields" } + example: { url: https://grafana.example.com } + responses: + "200": + description: Updated item + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/WriteResult" + - type: object + properties: + item: { $ref: "#/components/schemas/Item" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [Items] + summary: Delete item + operationId: deleteItem + responses: + "200": { $ref: "#/components/responses/Saved" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: | + Send `Authorization: Bearer `. Use either an `API_TOKEN` (grants full + admin access) or, with OIDC/Keycloak, your ID token. + basicAuth: + type: http + scheme: basic + description: Dashy's HTTP basic auth (`ENABLE_HTTP_AUTH` users or `BASIC_AUTH_USERNAME`/`PASSWORD`). + + parameters: + Filename: + name: filename + in: path + required: true + description: A YAML config file in your user-data directory. + schema: { type: string, pattern: "^[^/\\\\]+\\.ya?ml$" } + example: conf.yml + Key: + name: key + in: path + required: true + description: A top-level config key. + schema: { type: string, enum: [pageInfo, appConfig, sections, pages] } + example: appConfig + SectionId: + name: sid + in: path + required: true + description: Section index (zero-based) or exact `name` (URL-encoded). + schema: { type: string } + example: "0" + ItemId: + name: iid + in: path + required: true + description: Item index (zero-based) or exact `title` (URL-encoded). + schema: { type: string } + example: "0" + + responses: + Saved: + description: Saved + content: + application/json: + schema: { $ref: "#/components/schemas/WriteResult" } + example: { success: true, message: Config has been written to disk } + Disabled: + description: API not enabled + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + example: { success: false, message: "API not enabled. Set ENABLE_API=true to use the REST API." } + BadRequest: + description: Invalid filename, key, body or schema + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + example: { success: false, message: "Section must have a 'name'" } + Unauthorized: + description: Authentication required or invalid + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + example: { success: false, message: Unauthorized } + Forbidden: + description: Authenticated, but not an admin + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + NotFound: + description: File, key, section or item not found + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + example: { success: false, message: conf.yml not found } + ParseError: + description: File could not be read or parsed + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + TooLarge: + description: Body exceeds the size limit (256 KB) + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + schemas: + Error: + type: object + properties: + success: { type: boolean, enum: [false] } + message: { type: string } + required: [success, message] + WriteResult: + type: object + properties: + success: { type: boolean, enum: [true] } + message: { type: string } + required: [success, message] + FileList: + type: object + properties: + success: { type: boolean, enum: [true] } + files: + type: array + items: { type: string } + required: [success, files] + SectionResult: + allOf: + - $ref: "#/components/schemas/WriteResult" + - type: object + properties: + index: { type: integer, description: Array index of the section } + section: { $ref: "#/components/schemas/Section" } + ItemResult: + allOf: + - $ref: "#/components/schemas/WriteResult" + - type: object + properties: + index: { type: integer, description: Array index of the item } + item: { $ref: "#/components/schemas/Item" } + + Config: + type: object + description: A full config file. `conf.yml` requires `sections`; sub-pages may hold any subset. + additionalProperties: false + properties: + pageInfo: { $ref: "#/components/schemas/PageInfo" } + appConfig: { $ref: "#/components/schemas/AppConfig" } + sections: + type: array + items: { $ref: "#/components/schemas/Section" } + pages: + type: array + items: { $ref: "#/components/schemas/Page" } + KeyValue: + description: The value of one top-level key. + oneOf: + - $ref: "#/components/schemas/PageInfo" + - $ref: "#/components/schemas/AppConfig" + - type: array + items: { $ref: "#/components/schemas/Section" } + - type: array + items: { $ref: "#/components/schemas/Page" } + + PageInfo: + type: object + description: Branding shown in the header and footer. + additionalProperties: false + properties: + title: { type: string, description: Dashboard title } + description: { type: string, description: Sub-title } + navLinks: { type: array, description: Header navigation links, items: { type: object } } + footer: { type: string, description: Footer HTML or text } + logo: { type: string, description: Path or URL to the header logo } + favicon: { type: string, description: Path or URL to the favicon } + color: { type: string, description: Header text color } + + AppConfig: + type: object + description: | + Global app settings (theme, layout, status checks, auth, etc.). Many fields are + supported. See the [configuring docs](https://github.com/Lissy93/dashy/blob/master/docs/configuring.md). + properties: + theme: { type: string, description: Default theme name } + layout: { type: string, enum: [horizontal, vertical, auto, masonry, sidebar], description: Section layout } + iconSize: { type: string, enum: [small, medium, large], description: Item icon size } + language: { type: string, description: UI language code } + startingView: { type: string, enum: [home, default, minimal, workspace], description: Initial view } + statusCheck: { type: boolean, description: Enable status checks globally } + + SectionFields: + type: object + description: Any subset of a section's fields (used for PATCH). + additionalProperties: false + properties: + name: + type: string + description: Section heading (unique) + icon: + type: string + description: Section icon + displayData: + type: object + description: Per-section display options + items: + type: array + description: The links/apps in this section + items: { $ref: "#/components/schemas/Item" } + widgets: + type: array + description: Widgets shown in this section + items: { type: object } + filteredItems: + type: array + description: Items pulled from another source + items: { type: object } + Section: + description: A group of items, shown as a card. Requires a `name`. + allOf: + - $ref: "#/components/schemas/SectionFields" + - { type: object, required: [name] } + + ItemFields: + type: object + description: Any subset of an item's fields (used for PATCH). + additionalProperties: false + properties: + title: + type: string + description: Display name + description: + type: string + description: Text shown on hover + icon: + type: string + description: "Icon (URL, favicon, font-awesome, etc.)" + url: + type: string + description: Target URL + target: + type: string + enum: [newtab, sametab, parent, top, modal, workspace, clipboard, newwindow] + description: How the link opens + provider: + type: string + description: Hosting provider name + id: + type: string + description: Unique identifier + tags: + type: array + description: Tags for filtering + items: { type: string } + hotkey: + type: integer + description: Numeric keyboard shortcut + rel: + type: string + description: Anchor rel attribute + color: + type: string + description: Text color + backgroundColor: + type: string + description: Background color + displayData: + type: object + description: Per-item display options + subItems: + type: array + description: Nested child items + items: { type: object } + statusCheck: + type: boolean + description: Enable status check for this item + statusCheckUrl: + type: string + description: Override URL to ping + statusCheckHeaders: + type: object + description: Headers sent with the status check + statusCheckAllowInsecure: + type: boolean + description: Allow invalid TLS certs + statusCheckAcceptCodes: + type: string + description: Extra HTTP codes treated as up + statusCheckMaxRedirects: + type: integer + description: Max redirects to follow + pingCheckEnabled: + type: boolean + description: Enable ICMP ping check + pingCheckHost: + type: string + description: Host to ping + pingCheckInterval: + type: integer + description: Ping interval (seconds) + pingCheckCount: + type: integer + description: Number of pings + pingCheckTimeout: + type: integer + description: Ping timeout (ms) + Item: + description: A single link, app or service. Requires a `title`. + allOf: + - $ref: "#/components/schemas/ItemFields" + - { type: object, required: [title] } + + Page: + type: object + description: An extra config file loaded as a separate page. + additionalProperties: false + required: [name, path] + properties: + name: { type: string, description: Unique page identifier } + path: { type: string, description: File name or path of the page's config } + displayData: { type: object, description: Page visibility and display options }