From 8e694202953d933203de2190fc9ddbebc2a8f29f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:53:34 +0000 Subject: [PATCH 1/4] Restrict manual place id/apiKey entry, add regenerate/reveal, show place owner --- lib/generators.js | 22 +++++++++++ public/css/style.css | 13 ++++++ public/dashboard/index.html | 30 ++++++++++++-- public/js/dashboard.js | 79 +++++++++++++++++++++++++++++++++++-- routes/dashboard.js | 69 ++++++++++++++++++++++++++------ 5 files changed, 192 insertions(+), 21 deletions(-) create mode 100644 lib/generators.js diff --git a/lib/generators.js b/lib/generators.js new file mode 100644 index 0000000..4d69e2d --- /dev/null +++ b/lib/generators.js @@ -0,0 +1,22 @@ +const crypto = require('crypto'); + +// Characters used when generating a random API key: letters, digits and a handful of symbols. +const API_KEY_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+'; +const API_KEY_LENGTH = 64; + +// Generates a new place id as a random UUID (v4). +function generatePlaceId() { + return crypto.randomUUID(); +} + +// Generates a random alphanumeric+symbol API key of the given length (default 64 characters). +function generateApiKey(length = API_KEY_LENGTH) { + const bytes = crypto.randomBytes(length); + let key = ''; + for (let i = 0; i < length; i++) { + key += API_KEY_CHARS[bytes[i] % API_KEY_CHARS.length]; + } + return key; +} + +module.exports = { generatePlaceId, generateApiKey }; diff --git a/public/css/style.css b/public/css/style.css index 7ba063c..cbcf5a5 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -170,6 +170,19 @@ button.danger { font-size: 14px; margin-bottom: 4px; color: var(--muted); + display: flex; + flex-direction: column; + gap: 2px; +} + +.place-list-id { + font-size: 14px; +} + +.place-list-owner { + font-size: 11px; + color: var(--muted); + opacity: 0.75; } .place-list li:hover { diff --git a/public/dashboard/index.html b/public/dashboard/index.html index b36d986..dea4ded 100644 --- a/public/dashboard/index.html +++ b/public/dashboard/index.html @@ -19,10 +19,12 @@

Add place

- - - - +
@@ -39,6 +41,26 @@ +
+

API Key

+
+
+ + +
+ + + +
+ +
+

Add reader

diff --git a/public/js/dashboard.js b/public/js/dashboard.js index b23968b..efe52d0 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -34,6 +34,8 @@ async function init() { document.getElementById('username-display').textContent = me.user.username; document.getElementById('role-badge').textContent = ROLE_LABELS[me.user.role] || 'User'; document.getElementById('admin-link').classList.toggle('hidden', me.user.role < 3); + document.getElementById('add-place-custom-fields').classList.toggle('hidden', me.user.role < 3); + document.getElementById('set-api-key-form').classList.toggle('hidden', me.user.role < 3); await loadPlaces(); } @@ -50,8 +52,20 @@ function renderPlaceList() { places.forEach((place) => { const li = document.createElement('li'); - li.textContent = place.id; li.className = place.id === currentPlaceId ? 'active' : ''; + + const idSpan = document.createElement('span'); + idSpan.className = 'place-list-id'; + idSpan.textContent = place.id; + li.appendChild(idSpan); + + if (place.ownerUsername) { + const ownerSpan = document.createElement('span'); + ownerSpan.className = 'place-list-owner'; + ownerSpan.textContent = `Owner: ${place.ownerUsername}`; + li.appendChild(ownerSpan); + } + li.addEventListener('click', () => selectPlace(place.id)); list.appendChild(li); }); @@ -70,6 +84,12 @@ async function selectPlace(placeId) { document.getElementById('place-view').classList.remove('hidden'); document.getElementById('place-title').textContent = placeId; + const apiKeyInput = document.getElementById('place-api-key-display'); + apiKeyInput.value = data.place.apiKey || ''; + apiKeyInput.type = 'password'; + document.getElementById('toggle-api-key-btn').textContent = 'Show'; + document.getElementById('place-api-key-custom').value = ''; + const canManageOwnership = currentPlaceAccessLevel === 'owner' || currentPlaceAccessLevel === 'bypass'; document.getElementById('delete-place-btn').classList.toggle('hidden', !canManageOwnership); document.getElementById('shared-access-card').classList.toggle('hidden', !canManageOwnership); @@ -157,18 +177,21 @@ document.getElementById('add-place-form').addEventListener('submit', async (e) = e.preventDefault(); const id = document.getElementById('place-id').value.trim(); const apiKey = document.getElementById('place-api-key').value.trim(); - if (!id || !apiKey) return; + + const body = {}; + if (id) body.id = id; + if (apiKey) body.apiKey = apiKey; const data = await api('/dashboard/places', { method: 'POST', - body: JSON.stringify({ id, apiKey }) + body: JSON.stringify(body) }); if (data.success) { document.getElementById('place-id').value = ''; document.getElementById('place-api-key').value = ''; await loadPlaces(); - selectPlace(id); + selectPlace(data.place.id); } else { alert(data.message || 'Failed to add place'); } @@ -207,6 +230,54 @@ document.getElementById('delete-place-btn').addEventListener('click', async () = await loadPlaces(); }); +document.getElementById('toggle-api-key-btn').addEventListener('click', () => { + const input = document.getElementById('place-api-key-display'); + const btn = document.getElementById('toggle-api-key-btn'); + const revealed = input.type === 'text'; + input.type = revealed ? 'password' : 'text'; + btn.textContent = revealed ? 'Show' : 'Hide'; +}); + +document.getElementById('copy-api-key-btn').addEventListener('click', async () => { + const value = document.getElementById('place-api-key-display').value; + if (!value) return; + try { + await navigator.clipboard.writeText(value); + } catch (err) { + // Clipboard API may be unavailable; fail silently. + } +}); + +document.getElementById('regenerate-api-key-btn').addEventListener('click', async () => { + if (!currentPlaceId) return; + if (!confirm('Regenerate the API key for this place? The old key will stop working immediately.')) return; + + const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/regenerate-key`, { method: 'POST' }); + if (data.success) { + document.getElementById('place-api-key-display').value = data.apiKey; + } else { + alert(data.message || 'Failed to regenerate API key'); + } +}); + +document.getElementById('set-api-key-form').addEventListener('submit', async (e) => { + e.preventDefault(); + if (!currentPlaceId) return; + + const apiKey = document.getElementById('place-api-key-custom').value.trim(); + const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}`, { + method: 'PUT', + body: JSON.stringify({ apiKey }) + }); + + if (data.success) { + document.getElementById('place-api-key-custom').value = ''; + selectPlace(currentPlaceId); + } else { + alert(data.message || 'Failed to set API key'); + } +}); + document.getElementById('logout-btn').addEventListener('click', async () => { await api('/auth/logout', { method: 'POST' }); window.location.href = '/login'; diff --git a/routes/dashboard.js b/routes/dashboard.js index 0154728..d0741e6 100644 --- a/routes/dashboard.js +++ b/routes/dashboard.js @@ -1,6 +1,7 @@ const express = require('express'); const router = express.Router(); const { requireAuth, ROLES } = require('../lib/auth'); +const { generatePlaceId, generateApiKey } = require('../lib/generators'); let db; @@ -61,19 +62,24 @@ function requireOwnerOrBypass(req, res, next) { router.use(requireAuth(() => db)); // List places owned by, or shared with, the current user. Elevated/admin users see every place. +// Includes the owner's username (as `ownerUsername`) so the sidebar can display who owns each place. router.get('/places', (req, res) => { if (req.user.role >= ROLES.ELEVATED) { - return db.all(`SELECT * FROM places`, [], (err, places) => { - if (err) { - console.error('Failed to retrieve places:', err.message); - return res.status(500).json({ success: false, message: "Internal server error" }); + return db.all( + `SELECT places.*, users.username AS ownerUsername FROM places LEFT JOIN users ON users.id = places.ownerId`, + [], + (err, places) => { + if (err) { + console.error('Failed to retrieve places:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + res.json({ success: true, places }); } - res.json({ success: true, places }); - }); + ); } db.all( - `SELECT * FROM places WHERE ownerId = ? OR id IN (SELECT placeId FROM place_access WHERE userId = ?)`, + `SELECT places.*, users.username AS ownerUsername FROM places LEFT JOIN users ON users.id = places.ownerId WHERE places.ownerId = ? OR places.id IN (SELECT placeId FROM place_access WHERE userId = ?)`, [req.user.id, req.user.id], (err, places) => { if (err) { @@ -85,13 +91,24 @@ router.get('/places', (req, res) => { ); }); -// Create a new place +// Create a new place. Only admins (and superadmins) may supply a custom id/apiKey - everyone else +// always gets a generated (random UUID / random 64-char) id and apiKey. Admins that leave either +// field blank also get one generated for them. router.post('/places', (req, res) => { - const { id, apiKey, settings } = req.body || {}; - if (!id || !apiKey) { - return res.status(400).json({ success: false, message: "Place id and apiKey are required" }); + const canSetCustomValues = req.user.role >= ROLES.ADMIN; + const { settings } = req.body || {}; + let { id, apiKey } = req.body || {}; + + if (!canSetCustomValues && (id || apiKey)) { + return res.status(403).json({ success: false, message: "Only admins can set a custom place id or API key" }); } + id = canSetCustomValues && id ? String(id).trim() : ''; + apiKey = canSetCustomValues && apiKey ? String(apiKey).trim() : ''; + + if (!id) id = generatePlaceId(); + if (!apiKey) apiKey = generateApiKey(); + db.get(`SELECT id FROM places WHERE id = ?`, [id], (err, existing) => { if (err) { console.error('Failed to check for existing place:', err.message); @@ -122,9 +139,20 @@ router.get('/places/:placeId', loadPlace, (req, res) => { }); }); -// Update a place's apiKey/settings +// Update a place's apiKey/settings. Only admins (and superadmins) may set a custom apiKey value - +// everyone else must use the regenerate-key endpoint below. Admins that explicitly send a blank +// apiKey get a freshly generated one instead of clearing it. router.put('/places/:placeId', loadPlace, (req, res) => { - const apiKey = req.body.apiKey !== undefined ? req.body.apiKey : req.place.apiKey; + const canSetCustomValues = req.user.role >= ROLES.ADMIN; + + if (req.body.apiKey !== undefined && !canSetCustomValues) { + return res.status(403).json({ success: false, message: "Only admins can set a custom API key; use regenerate instead" }); + } + + let apiKey = req.place.apiKey; + if (req.body.apiKey !== undefined) { + apiKey = String(req.body.apiKey).trim() || generateApiKey(); + } const settings = req.body.settings !== undefined ? req.body.settings : req.place.settings; db.run(`UPDATE places SET apiKey = ?, settings = ? WHERE id = ?`, [apiKey, settings, req.place.id], (err) => { @@ -136,6 +164,21 @@ router.put('/places/:placeId', loadPlace, (req, res) => { }); }); +// Regenerate a place's API key to a new random value. Anyone with access to the place (owner, +// shared access, or elevated/admin bypass) may do this - unlike setting a custom value, this +// doesn't require admin privileges since it never lets the caller choose the resulting key. +router.post('/places/:placeId/regenerate-key', loadPlace, (req, res) => { + const apiKey = generateApiKey(); + + db.run(`UPDATE places SET apiKey = ? WHERE id = ?`, [apiKey, req.place.id], (err) => { + if (err) { + console.error('Failed to regenerate API key:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + res.json({ success: true, apiKey }); + }); +}); + // Delete a place (and its readers/acl entries/access groups/scan logs/shared access grants) router.delete('/places/:placeId', loadPlace, requireOwnerOrBypass, (req, res) => { db.all(`SELECT id FROM access_points WHERE placeId = ?`, [req.place.id], (err, accessPoints) => { From a1ee29a1b843d51f4e714a8bf0253c52fae32e49 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:54:57 +0000 Subject: [PATCH 2/4] Fix biased random API key generation and improve review feedback --- lib/generators.js | 5 +++-- public/js/dashboard.js | 7 ++++++- routes/dashboard.js | 9 +++++++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/generators.js b/lib/generators.js index 4d69e2d..b542bd7 100644 --- a/lib/generators.js +++ b/lib/generators.js @@ -10,11 +10,12 @@ function generatePlaceId() { } // Generates a random alphanumeric+symbol API key of the given length (default 64 characters). +// Uses crypto.randomInt for unbiased selection of each character (rather than bytes % length, +// which would skew towards characters at the start of the alphabet). function generateApiKey(length = API_KEY_LENGTH) { - const bytes = crypto.randomBytes(length); let key = ''; for (let i = 0; i < length; i++) { - key += API_KEY_CHARS[bytes[i] % API_KEY_CHARS.length]; + key += API_KEY_CHARS[crypto.randomInt(API_KEY_CHARS.length)]; } return key; } diff --git a/public/js/dashboard.js b/public/js/dashboard.js index efe52d0..201455a 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -241,11 +241,16 @@ document.getElementById('toggle-api-key-btn').addEventListener('click', () => { document.getElementById('copy-api-key-btn').addEventListener('click', async () => { const value = document.getElementById('place-api-key-display').value; if (!value) return; + + const btn = document.getElementById('copy-api-key-btn'); + const originalLabel = btn.textContent; try { await navigator.clipboard.writeText(value); + btn.textContent = 'Copied!'; } catch (err) { - // Clipboard API may be unavailable; fail silently. + btn.textContent = 'Copy failed'; } + setTimeout(() => { btn.textContent = originalLabel; }, 1500); }); document.getElementById('regenerate-api-key-btn').addEventListener('click', async () => { diff --git a/routes/dashboard.js b/routes/dashboard.js index d0741e6..bb004f5 100644 --- a/routes/dashboard.js +++ b/routes/dashboard.js @@ -103,8 +103,13 @@ router.post('/places', (req, res) => { return res.status(403).json({ success: false, message: "Only admins can set a custom place id or API key" }); } - id = canSetCustomValues && id ? String(id).trim() : ''; - apiKey = canSetCustomValues && apiKey ? String(apiKey).trim() : ''; + if (!canSetCustomValues) { + id = ''; + apiKey = ''; + } else { + id = id ? String(id).trim() : ''; + apiKey = apiKey ? String(apiKey).trim() : ''; + } if (!id) id = generatePlaceId(); if (!apiKey) apiKey = generateApiKey(); From 689f2b5a6eefee6aaafc52a3992637f81ca6597d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:56:45 +0000 Subject: [PATCH 3/4] Simplify place-id/apiKey validation logic per review feedback --- routes/dashboard.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/routes/dashboard.js b/routes/dashboard.js index bb004f5..430c066 100644 --- a/routes/dashboard.js +++ b/routes/dashboard.js @@ -103,13 +103,9 @@ router.post('/places', (req, res) => { return res.status(403).json({ success: false, message: "Only admins can set a custom place id or API key" }); } - if (!canSetCustomValues) { - id = ''; - apiKey = ''; - } else { - id = id ? String(id).trim() : ''; - apiKey = apiKey ? String(apiKey).trim() : ''; - } + // If we reach here, either the caller is an admin, or both id and apiKey were already falsy. + id = id ? String(id).trim() : ''; + apiKey = apiKey ? String(apiKey).trim() : ''; if (!id) id = generatePlaceId(); if (!apiKey) apiKey = generateApiKey(); From 8287b7ed6d4137ab8254fef16fcbea2c0713ec94 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:05:28 +0000 Subject: [PATCH 4/4] Add name field for places; show place ID in API key section --- migrations/0005_place_name.sql | 1 + public/dashboard/index.html | 17 +++++++++++++++++ public/js/dashboard.js | 28 ++++++++++++++++++++++++++-- routes/dashboard.js | 10 ++++++---- 4 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 migrations/0005_place_name.sql diff --git a/migrations/0005_place_name.sql b/migrations/0005_place_name.sql new file mode 100644 index 0000000..ebc9fa4 --- /dev/null +++ b/migrations/0005_place_name.sql @@ -0,0 +1 @@ +ALTER TABLE places ADD COLUMN name TEXT; diff --git a/public/dashboard/index.html b/public/dashboard/index.html index dea4ded..da596b9 100644 --- a/public/dashboard/index.html +++ b/public/dashboard/index.html @@ -19,6 +19,8 @@

Add place

+ + +
+

Name

+ +
+ + +
+ + +
+

API Key

+
+ + +
diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 201455a..7f26033 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -56,7 +56,7 @@ function renderPlaceList() { const idSpan = document.createElement('span'); idSpan.className = 'place-list-id'; - idSpan.textContent = place.id; + idSpan.textContent = place.name || place.id; li.appendChild(idSpan); if (place.ownerUsername) { @@ -82,7 +82,10 @@ async function selectPlace(placeId) { document.getElementById('no-place').classList.add('hidden'); document.getElementById('place-view').classList.remove('hidden'); - document.getElementById('place-title').textContent = placeId; + document.getElementById('place-title').textContent = data.place.name || placeId; + + document.getElementById('place-id-display').value = placeId; + document.getElementById('place-name-custom').value = data.place.name || ''; const apiKeyInput = document.getElementById('place-api-key-display'); apiKeyInput.value = data.place.apiKey || ''; @@ -177,10 +180,12 @@ document.getElementById('add-place-form').addEventListener('submit', async (e) = e.preventDefault(); const id = document.getElementById('place-id').value.trim(); const apiKey = document.getElementById('place-api-key').value.trim(); + const name = document.getElementById('place-name').value.trim(); const body = {}; if (id) body.id = id; if (apiKey) body.apiKey = apiKey; + if (name) body.name = name; const data = await api('/dashboard/places', { method: 'POST', @@ -190,6 +195,7 @@ document.getElementById('add-place-form').addEventListener('submit', async (e) = if (data.success) { document.getElementById('place-id').value = ''; document.getElementById('place-api-key').value = ''; + document.getElementById('place-name').value = ''; await loadPlaces(); selectPlace(data.place.id); } else { @@ -265,6 +271,24 @@ document.getElementById('regenerate-api-key-btn').addEventListener('click', asyn } }); +document.getElementById('set-place-name-form').addEventListener('submit', async (e) => { + e.preventDefault(); + if (!currentPlaceId) return; + + const name = document.getElementById('place-name-custom').value.trim(); + const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}`, { + method: 'PUT', + body: JSON.stringify({ name }) + }); + + if (data.success) { + await loadPlaces(); + selectPlace(currentPlaceId); + } else { + alert(data.message || 'Failed to set place name'); + } +}); + document.getElementById('set-api-key-form').addEventListener('submit', async (e) => { e.preventDefault(); if (!currentPlaceId) return; diff --git a/routes/dashboard.js b/routes/dashboard.js index 430c066..96cccef 100644 --- a/routes/dashboard.js +++ b/routes/dashboard.js @@ -97,7 +97,7 @@ router.get('/places', (req, res) => { router.post('/places', (req, res) => { const canSetCustomValues = req.user.role >= ROLES.ADMIN; const { settings } = req.body || {}; - let { id, apiKey } = req.body || {}; + let { id, apiKey, name } = req.body || {}; if (!canSetCustomValues && (id || apiKey)) { return res.status(403).json({ success: false, message: "Only admins can set a custom place id or API key" }); @@ -106,6 +106,7 @@ router.post('/places', (req, res) => { // If we reach here, either the caller is an admin, or both id and apiKey were already falsy. id = id ? String(id).trim() : ''; apiKey = apiKey ? String(apiKey).trim() : ''; + name = name ? String(name).trim() : ''; if (!id) id = generatePlaceId(); if (!apiKey) apiKey = generateApiKey(); @@ -119,12 +120,12 @@ router.post('/places', (req, res) => { return res.status(409).json({ success: false, message: "A place with that id already exists" }); } - db.run(`INSERT INTO places (id, apiKey, settings, ownerId) VALUES (?, ?, ?, ?)`, [id, apiKey, settings || '{}', req.user.id], (err) => { + db.run(`INSERT INTO places (id, apiKey, settings, ownerId, name) VALUES (?, ?, ?, ?, ?)`, [id, apiKey, settings || '{}', req.user.id, name || null], (err) => { if (err) { console.error('Failed to create place:', err.message); return res.status(500).json({ success: false, message: "Internal server error" }); } - res.json({ success: true, place: { id, apiKey, settings: settings || '{}', ownerId: req.user.id } }); + res.json({ success: true, place: { id, apiKey, settings: settings || '{}', ownerId: req.user.id, name: name || null } }); }); }); }); @@ -155,8 +156,9 @@ router.put('/places/:placeId', loadPlace, (req, res) => { apiKey = String(req.body.apiKey).trim() || generateApiKey(); } const settings = req.body.settings !== undefined ? req.body.settings : req.place.settings; + const name = req.body.name !== undefined ? (String(req.body.name).trim() || null) : req.place.name; - db.run(`UPDATE places SET apiKey = ?, settings = ? WHERE id = ?`, [apiKey, settings, req.place.id], (err) => { + db.run(`UPDATE places SET apiKey = ?, settings = ?, name = ? WHERE id = ?`, [apiKey, settings, name, req.place.id], (err) => { if (err) { console.error('Failed to update place:', err.message); return res.status(500).json({ success: false, message: "Internal server error" });