Restrict manual place id/apiKey entry, add regenerate/reveal, show place owner

This commit is contained in:
copilot-swe-agent[bot] 2026-07-02 15:53:34 +00:00 committed by GitHub
parent 5fe386ff63
commit 8e69420295
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 192 additions and 21 deletions

22
lib/generators.js Normal file
View file

@ -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 };

View file

@ -170,6 +170,19 @@ button.danger {
font-size: 14px; font-size: 14px;
margin-bottom: 4px; margin-bottom: 4px;
color: var(--muted); 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 { .place-list li:hover {

View file

@ -19,10 +19,12 @@
<div class="card" style="margin-top: 16px;"> <div class="card" style="margin-top: 16px;">
<h3 style="margin-top:0;font-size:14px;">Add place</h3> <h3 style="margin-top:0;font-size:14px;">Add place</h3>
<form id="add-place-form"> <form id="add-place-form">
<label for="place-id">Place ID</label> <div id="add-place-custom-fields" class="hidden">
<input type="text" id="place-id" required> <label for="place-id">Place ID <span style="color:var(--muted);font-weight:normal;">(leave blank to auto-generate)</span></label>
<label for="place-api-key">API Key</label> <input type="text" id="place-id">
<input type="text" id="place-api-key" required> <label for="place-api-key">API Key <span style="color:var(--muted);font-weight:normal;">(leave blank to auto-generate)</span></label>
<input type="text" id="place-api-key">
</div>
<button type="submit" class="primary">Add place</button> <button type="submit" class="primary">Add place</button>
</form> </form>
</div> </div>
@ -39,6 +41,26 @@
<button class="danger" id="delete-place-btn">Delete place</button> <button class="danger" id="delete-place-btn">Delete place</button>
</div> </div>
<div class="card">
<h3>API Key</h3>
<div class="inline-form">
<div class="field-group">
<label for="place-api-key-display">Current key</label>
<input type="password" id="place-api-key-display" readonly>
</div>
<button type="button" class="secondary" id="toggle-api-key-btn">Show</button>
<button type="button" class="secondary" id="copy-api-key-btn">Copy</button>
<button type="button" class="secondary" id="regenerate-api-key-btn">Regenerate</button>
</div>
<form id="set-api-key-form" class="inline-form hidden" style="margin-top:12px;">
<div class="field-group">
<label for="place-api-key-custom">Set custom key <span style="color:var(--muted);font-weight:normal;">(leave blank to auto-generate)</span></label>
<input type="text" id="place-api-key-custom">
</div>
<button type="submit" class="primary">Save</button>
</form>
</div>
<div class="card"> <div class="card">
<h3>Add reader</h3> <h3>Add reader</h3>
<form id="add-reader-form" class="inline-form"> <form id="add-reader-form" class="inline-form">

View file

@ -34,6 +34,8 @@ async function init() {
document.getElementById('username-display').textContent = me.user.username; document.getElementById('username-display').textContent = me.user.username;
document.getElementById('role-badge').textContent = ROLE_LABELS[me.user.role] || 'User'; document.getElementById('role-badge').textContent = ROLE_LABELS[me.user.role] || 'User';
document.getElementById('admin-link').classList.toggle('hidden', me.user.role < 3); 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(); await loadPlaces();
} }
@ -50,8 +52,20 @@ function renderPlaceList() {
places.forEach((place) => { places.forEach((place) => {
const li = document.createElement('li'); const li = document.createElement('li');
li.textContent = place.id;
li.className = place.id === currentPlaceId ? 'active' : ''; 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)); li.addEventListener('click', () => selectPlace(place.id));
list.appendChild(li); list.appendChild(li);
}); });
@ -70,6 +84,12 @@ async function selectPlace(placeId) {
document.getElementById('place-view').classList.remove('hidden'); document.getElementById('place-view').classList.remove('hidden');
document.getElementById('place-title').textContent = placeId; 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'; const canManageOwnership = currentPlaceAccessLevel === 'owner' || currentPlaceAccessLevel === 'bypass';
document.getElementById('delete-place-btn').classList.toggle('hidden', !canManageOwnership); document.getElementById('delete-place-btn').classList.toggle('hidden', !canManageOwnership);
document.getElementById('shared-access-card').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(); e.preventDefault();
const id = document.getElementById('place-id').value.trim(); const id = document.getElementById('place-id').value.trim();
const apiKey = document.getElementById('place-api-key').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', { const data = await api('/dashboard/places', {
method: 'POST', method: 'POST',
body: JSON.stringify({ id, apiKey }) body: JSON.stringify(body)
}); });
if (data.success) { if (data.success) {
document.getElementById('place-id').value = ''; document.getElementById('place-id').value = '';
document.getElementById('place-api-key').value = ''; document.getElementById('place-api-key').value = '';
await loadPlaces(); await loadPlaces();
selectPlace(id); selectPlace(data.place.id);
} else { } else {
alert(data.message || 'Failed to add place'); alert(data.message || 'Failed to add place');
} }
@ -207,6 +230,54 @@ document.getElementById('delete-place-btn').addEventListener('click', async () =
await loadPlaces(); 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 () => { document.getElementById('logout-btn').addEventListener('click', async () => {
await api('/auth/logout', { method: 'POST' }); await api('/auth/logout', { method: 'POST' });
window.location.href = '/login'; window.location.href = '/login';

View file

@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { requireAuth, ROLES } = require('../lib/auth'); const { requireAuth, ROLES } = require('../lib/auth');
const { generatePlaceId, generateApiKey } = require('../lib/generators');
let db; let db;
@ -61,19 +62,24 @@ function requireOwnerOrBypass(req, res, next) {
router.use(requireAuth(() => db)); router.use(requireAuth(() => db));
// List places owned by, or shared with, the current user. Elevated/admin users see every place. // 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) => { router.get('/places', (req, res) => {
if (req.user.role >= ROLES.ELEVATED) { if (req.user.role >= ROLES.ELEVATED) {
return db.all(`SELECT * FROM places`, [], (err, places) => { return db.all(
if (err) { `SELECT places.*, users.username AS ownerUsername FROM places LEFT JOIN users ON users.id = places.ownerId`,
console.error('Failed to retrieve places:', err.message); [],
return res.status(500).json({ success: false, message: "Internal server error" }); (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( 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], [req.user.id, req.user.id],
(err, places) => { (err, places) => {
if (err) { 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) => { router.post('/places', (req, res) => {
const { id, apiKey, settings } = req.body || {}; const canSetCustomValues = req.user.role >= ROLES.ADMIN;
if (!id || !apiKey) { const { settings } = req.body || {};
return res.status(400).json({ success: false, message: "Place id and apiKey are required" }); 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) => { db.get(`SELECT id FROM places WHERE id = ?`, [id], (err, existing) => {
if (err) { if (err) {
console.error('Failed to check for existing place:', err.message); 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) => { 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; 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) => { 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) // Delete a place (and its readers/acl entries/access groups/scan logs/shared access grants)
router.delete('/places/:placeId', loadPlace, requireOwnerOrBypass, (req, res) => { router.delete('/places/:placeId', loadPlace, requireOwnerOrBypass, (req, res) => {
db.all(`SELECT id FROM access_points WHERE placeId = ?`, [req.place.id], (err, accessPoints) => { db.all(`SELECT id FROM access_points WHERE placeId = ?`, [req.place.id], (err, accessPoints) => {