From 3f597132d0b746f2ca0dcafec320f0d1a44cfff2 Mon Sep 17 00:00:00 2001 From: Arturo Avendano Date: Fri, 5 Jun 2026 13:20:49 -0400 Subject: [PATCH] Updated tags management via API [OBS-707] --- PLAN.md | 2 + README.md | 24 +++++- scripts/smoke-features.sh | 24 ++++++ src/lib/tags.js | 33 ++++++++ src/routes/api.js | 144 ++++++++++++++++++++++++++++++++++ views/settings-api-tokens.ejs | 8 ++ 6 files changed, 233 insertions(+), 2 deletions(-) diff --git a/PLAN.md b/PLAN.md index 877a440..9fe4663 100644 --- a/PLAN.md +++ b/PLAN.md @@ -139,6 +139,8 @@ Dashboard: filter pills for TCP / Ping / DNS, type-aware meta line on every card - `GET /api/v1/incidents?limit=` (read) - `GET /api/v1/tags` (read) - `GET /api/v1/stats` (read) + - `POST /api/v1/tags` | `PATCH /api/v1/tags/:id` | `DELETE /api/v1/tags/:id` (write; admin role) + - `POST /api/v1/sites` | `PATCH /api/v1/sites/:id` (write; admin or editor) - `POST /api/v1/sites/:id/pause` | `/resume` | `/check-now` (write) - `DELETE /api/v1/sites/:id` (write) - Read tokens are denied on write endpoints with HTTP 403. diff --git a/README.md b/README.md index 37b6cf8..36d6998 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ If you want a **lightweight self-hosted Uptime Kuma alternative** that you can ` ### REST API & metrics - Bearer-token authenticated REST under `/api/v1/` with `read` / `write` scopes (admins create them at `/settings/api-tokens`; users mint personal ones at `/settings/account`). **Full CRUD for monitors via JSON** — `POST /api/v1/sites` creates monitors of any of the 7 types, `PATCH /api/v1/sites/:id` does partial updates, `DELETE /api/v1/sites/:id` removes them, plus the existing pause / resume / check-now write actions. Strict per-type validation, ACL inheritance, owner-only ownership reassignment. -- Endpoints: `health`, `sites`, `sites/:id`, `sites/:id/checks`, `sites/:id/incidents`, `incidents`, `tags`, `stats`, plus `pause` / `resume` / `check-now` / `DELETE` on a site. +- Endpoints: `health`, `sites`, `sites/:id`, `sites/:id/checks`, `sites/:id/incidents`, `incidents`, `tags` (GET + POST/PATCH/DELETE), `stats`, plus `pause` / `resume` / `check-now` / `DELETE` on a site. - Every `/api/v1` response is filtered through the token owner's ACL, so non-admins can only see / act on monitors they have access to. - **Prometheus exporter** at `/metrics` — series for `uptime_monitor_up`, `uptime_monitor_response_time_ms`, `uptime_monitor_last_check_age_seconds`, `uptime_monitor_uptime_pct_24h`, `uptime_cert_days_remaining`, `uptime_domain_days_remaining` (registered-domain WHOIS / RDAP expiry, emitted for `domain` monitors), `uptime_monitors_total{state}`, `uptime_open_incidents`. Public until the first API token is created; token-gated thereafter and ACL-filtered. - Tokens stored as SHA-256 with last-used timestamp tracked; the plaintext token is shown exactly once at creation. @@ -442,7 +442,27 @@ curl -s -X PATCH .../api/v1/sites/123 -d '{"interval_seconds": 120, "failure_thr curl -s -X PATCH .../api/v1/sites/123 -d '{"channel_ids": [4, 7]}' ``` -Validation is strict — unknown enum values, missing required fields per monitor type, and malformed `request_headers` all return **400 {"error","details":[…]}**. Tokens inherit the creator's ACL — a viewer's token can only read monitors they have access to, write actions require both the `write` scope **and** `manage` permission on the target monitor, and `POST /api/v1/sites` additionally requires `admin` or `editor` role. +### Tags via API + +`GET /api/v1/tags` lists tags (read scope). Create, update, and delete require **write** scope and **admin** role (same as Settings → Tags). `POST` is idempotent: creating an existing name returns **200** with the tag. + +```bash +# List tags (read) +curl -s -H "Authorization: Bearer $TOKEN" https://uptime.example.com/api/v1/tags | jq + +# Create tag (write + admin) — 201, or 200 if name already exists +curl -s -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -X POST https://uptime.example.com/api/v1/tags -d '{"name":"production","color":"green"}' + +# Update tag +curl -s -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -X PATCH https://uptime.example.com/api/v1/tags/3 -d '{"color":"blue"}' + +# Delete tag +curl -s -H "Authorization: Bearer $TOKEN" -X DELETE https://uptime.example.com/api/v1/tags/3 +``` + +Validation is strict — unknown enum values, missing required fields per monitor type, and malformed `request_headers` all return **400 {"error","details":[…]}**. Tokens inherit the creator's ACL — a viewer's token can only read monitors they have access to, write actions require both the `write` scope **and** `manage` permission on the target monitor, and `POST /api/v1/sites` additionally requires `admin` or `editor` role. Tag write endpoints require **admin** role. Scrape config for Prometheus: diff --git a/scripts/smoke-features.sh b/scripts/smoke-features.sh index ea82fcd..3febb2d 100755 --- a/scripts/smoke-features.sh +++ b/scripts/smoke-features.sh @@ -413,9 +413,33 @@ grep -q '"total"' <<<"$API_STATS" || fail "GET /api/v1/stats missing 'total'" [[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: Bearer $ENV_TOKEN" "$BASE/api/v1/sites/$TCP_ID/resume")" = "200" ]] || fail "API resume" [[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: Bearer $ENV_TOKEN" "$BASE/api/v1/sites/$TCP_ID/check-now")" = "200" ]] || fail "API check-now" +# Tags API (admin write token) +TAG_CREATE=$(curl -s -w '\n%{http_code}' -X POST -H "Authorization: Bearer $ENV_TOKEN" -H "Content-Type: application/json" \ + -d '{"name":"ft-smoke-tag","color":"blue"}' "$BASE/api/v1/tags") +TAG_CREATE_BODY=$(sed '$d' <<<"$TAG_CREATE") +TAG_CREATE_CODE=$(tail -n1 <<<"$TAG_CREATE") +[[ "$TAG_CREATE_CODE" = "201" ]] || fail "POST /api/v1/tags should 201 (got $TAG_CREATE_CODE)" +grep -q '"name":"ft-smoke-tag"' <<<"$TAG_CREATE_BODY" || fail "POST /api/v1/tags missing name" +TAG_ID=$(grep -oE '"id":[0-9]+' <<<"$TAG_CREATE_BODY" | head -1 | grep -oE '[0-9]+') +[[ -n "$TAG_ID" ]] || fail "POST /api/v1/tags missing id" + +TAG_DUP_CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: Bearer $ENV_TOKEN" -H "Content-Type: application/json" \ + -d '{"name":"ft-smoke-tag"}' "$BASE/api/v1/tags") +[[ "$TAG_DUP_CODE" = "200" ]] || fail "duplicate POST /api/v1/tags should 200 (got $TAG_DUP_CODE)" + +[[ "$(curl -s -o /dev/null -w '%{http_code}' -X PATCH -H "Authorization: Bearer $ENV_TOKEN" -H "Content-Type: application/json" \ + -d '{"color":"green"}' "$BASE/api/v1/tags/$TAG_ID")" = "200" ]] || fail "PATCH /api/v1/tags" + +API_TAGS=$(curl -s -H "Authorization: Bearer $ENV_TOKEN" "$BASE/api/v1/tags") +grep -q '"name":"ft-smoke-tag"' <<<"$API_TAGS" || fail "GET /api/v1/tags missing ft-smoke-tag" + # Read-scope token must be 403 on write. RO_TOKEN=$(node_run "require('./src/lib/apiTokens').createToken('ft-smoke-ro','read',null).then(r=>{console.log(r.token);process.exit(0)})") [[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: Bearer $RO_TOKEN" "$BASE/api/v1/sites/$TCP_ID/pause")" = "403" ]] || fail "read token should 403 on pause" +[[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: Bearer $RO_TOKEN" -H "Content-Type: application/json" \ + -d '{"name":"ft-ro-tag"}' "$BASE/api/v1/tags")" = "403" ]] || fail "read token should 403 on POST /api/v1/tags" + +[[ "$(curl -s -o /dev/null -w '%{http_code}' -X DELETE -H "Authorization: Bearer $ENV_TOKEN" "$BASE/api/v1/tags/$TAG_ID")" = "200" ]] || fail "DELETE /api/v1/tags" # Bad token → 401. [[ "$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer utk_bogus" "$BASE/api/v1/sites")" = "401" ]] || fail "bogus token should 401" pass "REST API: read, write, scope enforcement" diff --git a/src/lib/tags.js b/src/lib/tags.js index b168206..028cd4b 100644 --- a/src/lib/tags.js +++ b/src/lib/tags.js @@ -30,6 +30,36 @@ async function listTags() { `); } +async function getTagListed(id) { + const rows = await db.query(` + SELECT t.id, t.name, t.color, + COALESCE(c.cnt, 0) AS site_count + FROM tags t + LEFT JOIN ( + SELECT tag_id, COUNT(*) AS cnt FROM site_tags GROUP BY tag_id + ) c ON c.tag_id = t.id + WHERE t.id = ? + LIMIT 1 + `, [id]); + return rows[0] || null; +} + +async function getTagByName(name) { + const n = normalizeName(name); + if (!n) return null; + const rows = await db.query(` + SELECT t.id, t.name, t.color, + COALESCE(c.cnt, 0) AS site_count + FROM tags t + LEFT JOIN ( + SELECT tag_id, COUNT(*) AS cnt FROM site_tags GROUP BY tag_id + ) c ON c.tag_id = t.id + WHERE t.name = ? + LIMIT 1 + `, [n]); + return rows[0] || null; +} + async function getTag(id) { const rows = await db.query(`SELECT * FROM tags WHERE id = ?`, [id]); return rows[0] || null; @@ -126,7 +156,10 @@ async function detachFromSites(siteIds, tagId) { module.exports = { COLOR_PALETTE, normalizeColor, + normalizeName, listTags, + getTagListed, + getTagByName, getTag, createTag, updateTag, diff --git a/src/routes/api.js b/src/routes/api.js index f95de88..2c946f4 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -77,6 +77,29 @@ async function loadSiteWithAccess(req, res, mode) { return site; } +function tagToApi(row) { + return { + id: row.id, + name: row.name, + color: row.color, + site_count: row.site_count != null ? row.site_count : 0, + }; +} + +async function loadTag(req, res) { + const id = parseId(req.params.id); + if (id == null) { + res.status(404).json({ error: 'not found' }); + return null; + } + const row = await tagsLib.getTagListed(id); + if (!row) { + res.status(404).json({ error: 'not found' }); + return null; + } + return row; +} + function safeJsonParse(s) { if (s == null) return null; if (typeof s === 'object') return s; @@ -287,6 +310,127 @@ function jsonError(res, status, message, details) { return res.status(status).json(body); } +function isUniqueConstraintError(err) { + return String(err?.message || '').toLowerCase().includes('unique'); +} + +// POST /api/v1/tags — create tag (idempotent when name already exists). +// Required scope: write. Role: admin (tags are global, same as /settings/tags). +router.post('/api/v1/tags', requireApi('write'), async (req, res, next) => { + try { + if (!acl.isAdmin(req.apiUser)) { + return jsonError(res, 403, 'role must be admin'); + } + const body = req.body || {}; + const name = tagsLib.normalizeName(body.name); + if (!name) { + return jsonError(res, 400, 'tag name required'); + } + const color = Object.prototype.hasOwnProperty.call(body, 'color') + ? body.color + : undefined; + + const existing = await tagsLib.getTagByName(name); + if (existing) { + return res.json(tagToApi(existing)); + } + + try { + const id = await tagsLib.createTag(name, color); + const row = await tagsLib.getTagListed(id); + await audit.record({ + actor: req.apiUser.username || null, + actorUserId: req.apiUser.isEnv ? null : req.apiUser.id, + ip: req.ip || null, + action: 'tag.created', + targetType: 'tag', + targetId: id, + meta: { name, via: 'api' }, + }); + logger.info({ tagId: id, name, via: 'api' }, 'tags.created'); + return res.status(201).json(tagToApi(row)); + } catch (err) { + if (isUniqueConstraintError(err)) { + const row = await tagsLib.getTagByName(name); + if (row) return res.json(tagToApi(row)); + } + throw err; + } + } catch (err) { + next(err); + } +}); + +// PATCH /api/v1/tags/:id — partial update (name and/or color). +router.patch('/api/v1/tags/:id', requireApi('write'), async (req, res, next) => { + try { + if (!acl.isAdmin(req.apiUser)) { + return jsonError(res, 403, 'role must be admin'); + } + const existing = await loadTag(req, res); + if (!existing) return; + const body = req.body || {}; + const name = Object.prototype.hasOwnProperty.call(body, 'name') + ? tagsLib.normalizeName(body.name) + : existing.name; + if (!name) { + return jsonError(res, 400, 'tag name required'); + } + const color = Object.prototype.hasOwnProperty.call(body, 'color') + ? body.color + : existing.color; + + try { + await tagsLib.updateTag(existing.id, name, color); + } catch (err) { + if (isUniqueConstraintError(err)) { + return jsonError(res, 409, 'tag name already exists'); + } + throw err; + } + + const row = await tagsLib.getTagListed(existing.id); + await audit.record({ + actor: req.apiUser.username || null, + actorUserId: req.apiUser.isEnv ? null : req.apiUser.id, + ip: req.ip || null, + action: 'tag.updated', + targetType: 'tag', + targetId: existing.id, + meta: { name, via: 'api' }, + }); + logger.info({ tagId: existing.id, name, via: 'api' }, 'tags.updated'); + res.json(tagToApi(row)); + } catch (err) { + next(err); + } +}); + +// DELETE /api/v1/tags/:id +router.delete('/api/v1/tags/:id', requireApi('write'), async (req, res, next) => { + try { + if (!acl.isAdmin(req.apiUser)) { + return jsonError(res, 403, 'role must be admin'); + } + const existing = await loadTag(req, res); + if (!existing) return; + await tagsLib.deleteTag(existing.id); + await audit.record({ + actor: req.apiUser.username || null, + actorUserId: req.apiUser.isEnv ? null : req.apiUser.id, + ip: req.ip || null, + action: 'tag.deleted', + targetType: 'tag', + targetId: existing.id, + meta: { via: 'api' }, + }); + logger.info({ tagId: existing.id, via: 'api' }, 'tags.deleted'); + res.json({ ok: true, id: existing.id, deleted: true }); + } catch (err) { + next(err); + } +}); + // POST /api/v1/sites — create monitor of any type. // Required scope: write. Role: admin OR editor (env-admin token bypasses). // Body fields mirror the form-submit names so a single sitePayload helper diff --git a/views/settings-api-tokens.ejs b/views/settings-api-tokens.ejs index 457b138..6bdf690 100644 --- a/views/settings-api-tokens.ejs +++ b/views/settings-api-tokens.ejs @@ -108,6 +108,14 @@
Pause a monitor (write)
curl -X POST -H 'Authorization: Bearer <token>' <%= publicBaseUrl %>/api/v1/sites/123/pause
+
+
Create HTTP monitor (write, admin or editor)
+
curl -X POST -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' <%= publicBaseUrl %>/api/v1/sites -d '{"name":"My API","monitor_type":"active","url":"https://example.com/health","interval_seconds":60}'
+
+
+
Create tag (write, admin)
+
curl -X POST -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' <%= publicBaseUrl %>/api/v1/tags -d '{"name":"production"}'
+
Prometheus scrape
scrape_configs: