Skip to content
Open
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
2 changes: 2 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,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.
Expand Down Expand Up @@ -448,7 +448,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:

Expand Down
24 changes: 24 additions & 0 deletions scripts/smoke-features.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
33 changes: 33 additions & 0 deletions src/lib/tags.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -126,7 +156,10 @@ async function detachFromSites(siteIds, tagId) {
module.exports = {
COLOR_PALETTE,
normalizeColor,
normalizeName,
listTags,
getTagListed,
getTagByName,
getTag,
createTag,
updateTag,
Expand Down
144 changes: 144 additions & 0 deletions src/routes/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +89 to +101

function safeJsonParse(s) {
if (s == null) return null;
if (typeof s === 'object') return s;
Expand Down Expand Up @@ -289,6 +312,127 @@ function jsonError(res, status, message, details) {
return res.status(status).json(body);
}

function isUniqueConstraintError(err) {
return String(err?.message || '').toLowerCase().includes('unique');
}
Comment on lines +315 to +317

// 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
Expand Down
8 changes: 8 additions & 0 deletions views/settings-api-tokens.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@
<div class="text-secondary small mb-1">Pause a monitor (write)</div>
<pre class="bg-body-tertiary p-2 small mb-0" style="white-space: pre-wrap;">curl -X POST -H 'Authorization: Bearer &lt;token&gt;' <%= publicBaseUrl %>/api/v1/sites/123/pause</pre>
</div>
<div class="mb-3">
<div class="text-secondary small mb-1">Create HTTP monitor (write, admin or editor)</div>
<pre class="bg-body-tertiary p-2 small mb-0" style="white-space: pre-wrap;">curl -X POST -H 'Authorization: Bearer &lt;token&gt;' -H 'Content-Type: application/json' <%= publicBaseUrl %>/api/v1/sites -d '{"name":"My API","monitor_type":"active","url":"https://example.com/health","interval_seconds":60}'</pre>
</div>
<div class="mb-3">
<div class="text-secondary small mb-1">Create tag (write, admin)</div>
<pre class="bg-body-tertiary p-2 small mb-0" style="white-space: pre-wrap;">curl -X POST -H 'Authorization: Bearer &lt;token&gt;' -H 'Content-Type: application/json' <%= publicBaseUrl %>/api/v1/tags -d '{"name":"production"}'</pre>
</div>
<div>
<div class="text-secondary small mb-1">Prometheus scrape</div>
<pre class="bg-body-tertiary p-2 small mb-0" style="white-space: pre-wrap;">scrape_configs:
Expand Down