Merge pull request #4 from ChrisChrome/copilot/restrict-api-key-input
Implementing restrictions for place ID and API key management
This commit is contained in:
commit
ddc35c038f
23
lib/generators.js
Normal file
23
lib/generators.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
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).
|
||||
// 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) {
|
||||
let key = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
key += API_KEY_CHARS[crypto.randomInt(API_KEY_CHARS.length)];
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
module.exports = { generatePlaceId, generateApiKey };
|
||||
1
migrations/0005_place_name.sql
Normal file
1
migrations/0005_place_name.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE places ADD COLUMN name TEXT;
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -19,10 +19,14 @@
|
|||
<div class="card" style="margin-top: 16px;">
|
||||
<h3 style="margin-top:0;font-size:14px;">Add place</h3>
|
||||
<form id="add-place-form">
|
||||
<label for="place-id">Place ID</label>
|
||||
<input type="text" id="place-id" required>
|
||||
<label for="place-api-key">API Key</label>
|
||||
<input type="text" id="place-api-key" required>
|
||||
<label for="place-name">Name <span style="color:var(--muted);font-weight:normal;">(optional)</span></label>
|
||||
<input type="text" id="place-name">
|
||||
<div id="add-place-custom-fields" class="hidden">
|
||||
<label for="place-id">Place ID <span style="color:var(--muted);font-weight:normal;">(leave blank to auto-generate)</span></label>
|
||||
<input type="text" id="place-id">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -39,6 +43,41 @@
|
|||
<button class="danger" id="delete-place-btn">Delete place</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Name</h3>
|
||||
<form id="set-place-name-form" class="inline-form">
|
||||
<div class="field-group">
|
||||
<label for="place-name-custom">Place name <span style="color:var(--muted);font-weight:normal;">(leave blank to show the place ID instead)</span></label>
|
||||
<input type="text" id="place-name-custom">
|
||||
</div>
|
||||
<button type="submit" class="primary">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>API Key</h3>
|
||||
<div class="inline-form">
|
||||
<div class="field-group">
|
||||
<label for="place-id-display">Place ID</label>
|
||||
<input type="text" id="place-id-display" readonly>
|
||||
</div>
|
||||
<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">
|
||||
<h3>Add reader</h3>
|
||||
<form id="add-reader-form" class="inline-form">
|
||||
|
|
|
|||
|
|
@ -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.name || 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);
|
||||
});
|
||||
|
|
@ -68,7 +82,16 @@ 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 || '';
|
||||
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);
|
||||
|
|
@ -157,18 +180,24 @@ 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 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',
|
||||
body: JSON.stringify({ id, apiKey })
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('place-id').value = '';
|
||||
document.getElementById('place-api-key').value = '';
|
||||
document.getElementById('place-name').value = '';
|
||||
await loadPlaces();
|
||||
selectPlace(id);
|
||||
selectPlace(data.place.id);
|
||||
} else {
|
||||
alert(data.message || 'Failed to add place');
|
||||
}
|
||||
|
|
@ -207,6 +236,77 @@ 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;
|
||||
|
||||
const btn = document.getElementById('copy-api-key-btn');
|
||||
const originalLabel = btn.textContent;
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
btn.textContent = 'Copied!';
|
||||
} catch (err) {
|
||||
btn.textContent = 'Copy failed';
|
||||
}
|
||||
setTimeout(() => { btn.textContent = originalLabel; }, 1500);
|
||||
});
|
||||
|
||||
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-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;
|
||||
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -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,26 @@ 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, 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" });
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
db.get(`SELECT id FROM places WHERE id = ?`, [id], (err, existing) => {
|
||||
if (err) {
|
||||
console.error('Failed to check for existing place:', err.message);
|
||||
|
|
@ -101,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 } });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -122,12 +141,24 @@ 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 settings = req.body.settings !== undefined ? req.body.settings : req.place.settings;
|
||||
const canSetCustomValues = req.user.role >= ROLES.ADMIN;
|
||||
|
||||
db.run(`UPDATE places SET apiKey = ?, settings = ? WHERE id = ?`, [apiKey, settings, req.place.id], (err) => {
|
||||
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 name = req.body.name !== undefined ? (String(req.body.name).trim() || null) : req.place.name;
|
||||
|
||||
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" });
|
||||
|
|
@ -136,6 +167,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) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue