mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
database admin yay
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
const { app } = require('../../globals/http');
|
||||
const { renderIndexHtml, renderOgImage } = require('../embedService');
|
||||
|
||||
app.get(['/', '/spectate', '/mini', '/display', '/scanner'], async (req, res) => {
|
||||
app.get(['/', '/spectate', '/mini', '/display', '/scanner', '/database'], async (req, res) => {
|
||||
try {
|
||||
const html = await renderIndexHtml(req);
|
||||
res.type('html').send(html);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Identity Admin Service
|
||||
// Purpose: Exposes lockdown-admin socket operations for inspecting and editing the central identity database.
|
||||
// Scope: Owns transport-level authorization and delegates every database mutation to identityService.
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('identityAdminService');
|
||||
const { getRole } = require('../roleService');
|
||||
const {
|
||||
listUsersForAdmin,
|
||||
getUserForAdmin,
|
||||
addUserSignal,
|
||||
removeUserSignal,
|
||||
setVerified,
|
||||
setDeterrence,
|
||||
setFeatureState,
|
||||
deleteFeatureState,
|
||||
} = require('../identityService');
|
||||
|
||||
const MAX_FEATURE_STATE_JSON_BYTES = 64 * 1024;
|
||||
|
||||
function isLockdownAdminSocket(socket) {
|
||||
const role = getRole(socket);
|
||||
return role === 'lockdown' || role === 'lockdown-admin';
|
||||
}
|
||||
|
||||
function requireLockdownAdmin(socket) {
|
||||
if (!isLockdownAdminSocket(socket)) {
|
||||
throw new Error('Lockdown admin required.');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFeaturePayload(namespace, value) {
|
||||
const ns = String(namespace || '').trim();
|
||||
if (!ns) throw new Error('Feature namespace required.');
|
||||
if (!/^[a-zA-Z0-9_.:-]{1,80}$/.test(ns)) {
|
||||
throw new Error('Feature namespace contains invalid characters.');
|
||||
}
|
||||
|
||||
/*
|
||||
Feature state is intentionally JSON-shaped. Strings are not accepted as the
|
||||
stored value because they make the admin editor ambiguous: a textarea JSON
|
||||
string should represent a real JSON object/array, not an escaped blob.
|
||||
*/
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new Error('Feature state must be a JSON object or array.');
|
||||
}
|
||||
const encoded = JSON.stringify(value);
|
||||
if (Buffer.byteLength(encoded, 'utf8') > MAX_FEATURE_STATE_JSON_BYTES) {
|
||||
throw new Error('Feature state is too large.');
|
||||
}
|
||||
return { namespace: ns, value };
|
||||
}
|
||||
|
||||
function ackHandler(socket, eventName, handler) {
|
||||
socket.on(eventName, (payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
requireLockdownAdmin(socket);
|
||||
cb({ success: true, ...handler(payload || {}) });
|
||||
} catch (err) {
|
||||
logger.warn('Identity admin request failed', {
|
||||
eventName,
|
||||
socketId: socket.id,
|
||||
role: getRole(socket),
|
||||
error: err.message,
|
||||
});
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
ackHandler(socket, 'identityAdmin:listUsers', () => ({
|
||||
users: listUsersForAdmin(),
|
||||
}));
|
||||
|
||||
ackHandler(socket, 'identityAdmin:getUser', ({ userId }) => {
|
||||
const user = getUserForAdmin(userId);
|
||||
if (!user) throw new Error('User not found.');
|
||||
return { user };
|
||||
});
|
||||
|
||||
ackHandler(socket, 'identityAdmin:addSignal', ({ userId, type, value }) => ({
|
||||
user: addUserSignal(userId, type, value),
|
||||
}));
|
||||
|
||||
ackHandler(socket, 'identityAdmin:removeSignal', ({ userId, type, value }) => ({
|
||||
user: removeUserSignal(userId, type, value),
|
||||
}));
|
||||
|
||||
ackHandler(socket, 'identityAdmin:setVerified', ({ userId, enabled }) => ({
|
||||
user: getUserForAdmin(setVerified(userId, {
|
||||
enabled: Boolean(enabled),
|
||||
actor: socket?.data?.user?.username || socket.id,
|
||||
at: Date.now(),
|
||||
}).id),
|
||||
}));
|
||||
|
||||
ackHandler(socket, 'identityAdmin:setDeterrence', ({ userId, enabled, reason }) => ({
|
||||
user: getUserForAdmin(setDeterrence(userId, {
|
||||
enabled: Boolean(enabled),
|
||||
reason: String(reason || '').trim() || null,
|
||||
actor: socket?.data?.user?.username || socket.id,
|
||||
at: Date.now(),
|
||||
}).id),
|
||||
}));
|
||||
|
||||
ackHandler(socket, 'identityAdmin:updateFeatureState', ({ userId, namespace, value }) => {
|
||||
const normalized = normalizeFeaturePayload(namespace, value);
|
||||
setFeatureState(userId, normalized.namespace, normalized.value);
|
||||
return { user: getUserForAdmin(userId) };
|
||||
});
|
||||
|
||||
ackHandler(socket, 'identityAdmin:deleteFeatureState', ({ userId, namespace }) => ({
|
||||
user: deleteFeatureState(userId, namespace),
|
||||
}));
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
isLockdownAdminSocket,
|
||||
};
|
||||
@@ -473,6 +473,106 @@ function getUserById(userId, { conn = getDb(), includeFeatures = true } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function listUsersForAdmin() {
|
||||
const conn = getDb();
|
||||
return conn.prepare('select id from users order by coalesce(last_seen_at, updated_at, created_at) desc').all()
|
||||
.map((row) => getUserById(row.id, { conn, includeFeatures: true }))
|
||||
.filter(Boolean)
|
||||
.map((user) => ({
|
||||
...user,
|
||||
featureNamespaces: Object.keys(user.features || {}).sort(),
|
||||
}));
|
||||
}
|
||||
|
||||
function getUserForAdmin(userId) {
|
||||
const user = getUserById(userId, { includeFeatures: true });
|
||||
return user ? { ...user, featureNamespaces: Object.keys(user.features || {}).sort() } : null;
|
||||
}
|
||||
|
||||
function normalizeAdminSignal(type, value) {
|
||||
const normalizedType = String(type || '').trim();
|
||||
if (normalizedType === 'cookieUserId') {
|
||||
const cookieUserId = normalizeCookieUserId(value);
|
||||
if (!isValidCookieUserId(cookieUserId)) throw new Error('Invalid cookie identity key.');
|
||||
return { type: normalizedType, value: cookieUserId };
|
||||
}
|
||||
if (normalizedType === 'fingerprintId') {
|
||||
const fingerprintId = normalizeFingerprintId(value);
|
||||
if (!isValidFingerprintId(fingerprintId)) throw new Error('Invalid fingerprint id.');
|
||||
return { type: normalizedType, value: fingerprintId };
|
||||
}
|
||||
if (normalizedType === 'nickname') {
|
||||
const nickname = sanitizeNickname(value);
|
||||
if (!nickname) throw new Error('Nickname required.');
|
||||
return { type: normalizedType, value: nickname };
|
||||
}
|
||||
if (normalizedType === 'knownIp') {
|
||||
const ip = normalizeIp(value);
|
||||
if (!ip) throw new Error('Valid IP required.');
|
||||
return { type: normalizedType, value: ip };
|
||||
}
|
||||
throw new Error('Unknown identity signal type.');
|
||||
}
|
||||
|
||||
function addUserSignal(userId, type, value) {
|
||||
const id = String(userId || '').trim();
|
||||
const user = getUserById(id);
|
||||
if (!user) throw new Error('User not found.');
|
||||
const signal = normalizeAdminSignal(type, value);
|
||||
|
||||
/*
|
||||
Adding a strong signal is allowed to merge users. If the new cookie key or
|
||||
fingerprint already belongs to another user, the same global equality rule
|
||||
applies here and the two records converge under the selected user id.
|
||||
*/
|
||||
const conn = getDb();
|
||||
const nextUser = conn.transaction(() => {
|
||||
const matchedUserId =
|
||||
signal.type === 'cookieUserId'
|
||||
? findUserIdByCookie(conn, signal.value)
|
||||
: signal.type === 'fingerprintId'
|
||||
? findUserIdByFingerprint(conn, signal.value)
|
||||
: null;
|
||||
const targetUserId = matchedUserId && matchedUserId !== id ? mergeUsers(conn, id, matchedUserId) : id;
|
||||
const identity = {
|
||||
cookieUserId: signal.type === 'cookieUserId' ? signal.value : '',
|
||||
fingerprintId: signal.type === 'fingerprintId' ? signal.value : '',
|
||||
nickname: signal.type === 'nickname' ? signal.value : '',
|
||||
ip: signal.type === 'knownIp' ? signal.value : '',
|
||||
};
|
||||
return attachIdentitySignals(targetUserId, identity, { conn, ts: nowMs() });
|
||||
})();
|
||||
|
||||
identityEvents.emit('change', { reason: 'admin_signal_add', userId: nextUser.id, signalType: signal.type });
|
||||
return getUserForAdmin(nextUser.id);
|
||||
}
|
||||
|
||||
function removeUserSignal(userId, type, value) {
|
||||
const id = String(userId || '').trim();
|
||||
if (!getUserById(id)) throw new Error('User not found.');
|
||||
const signal = normalizeAdminSignal(type, value);
|
||||
const conn = getDb();
|
||||
|
||||
/*
|
||||
Removing a signal only detaches that one identifier. The canonical user row
|
||||
remains because verification, deterrence, feature state, and legacy imports
|
||||
may still refer to it even if all strong signals are removed.
|
||||
*/
|
||||
if (signal.type === 'cookieUserId') {
|
||||
conn.prepare('delete from user_cookie_ids where user_id = ? and cookie_user_id = ?').run(id, signal.value);
|
||||
} else if (signal.type === 'fingerprintId') {
|
||||
conn.prepare('delete from user_fingerprint_ids where user_id = ? and fingerprint_id = ?').run(id, signal.value);
|
||||
} else if (signal.type === 'nickname') {
|
||||
conn.prepare('delete from user_nicknames where user_id = ? and nickname = ?').run(id, signal.value);
|
||||
} else if (signal.type === 'knownIp') {
|
||||
conn.prepare('delete from user_known_ips where user_id = ? and ip = ?').run(id, signal.value);
|
||||
}
|
||||
|
||||
conn.prepare('update users set updated_at = ? where id = ?').run(nowMs(), id);
|
||||
identityEvents.emit('change', { reason: 'admin_signal_remove', userId: id, signalType: signal.type });
|
||||
return getUserForAdmin(id);
|
||||
}
|
||||
|
||||
function getUserForSocket(socket) {
|
||||
if (!socket?.data?.userId) return null;
|
||||
return getUserById(socket.data.userId);
|
||||
@@ -516,6 +616,15 @@ function setFeatureState(userId, namespace, nextState) {
|
||||
return nextState || {};
|
||||
}
|
||||
|
||||
function deleteFeatureState(userId, namespace) {
|
||||
const id = String(userId || '').trim();
|
||||
const ns = String(namespace || '').trim();
|
||||
if (!id || !ns) throw new Error('userId and namespace required');
|
||||
getDb().prepare('delete from user_feature_state where user_id = ? and namespace = ?').run(id, ns);
|
||||
identityEvents.emit('change', { reason: 'feature_state_delete', userId: id, namespace: ns });
|
||||
return getUserForAdmin(id);
|
||||
}
|
||||
|
||||
function updateFeatureState(userId, namespace, updater, defaults = {}) {
|
||||
const current = getFeatureState(userId, namespace, defaults);
|
||||
const next = typeof updater === 'function' ? updater(current) : updater;
|
||||
@@ -846,11 +955,16 @@ module.exports = {
|
||||
resolveUserIdForIdentity,
|
||||
attachIdentitySignals,
|
||||
getUserById,
|
||||
listUsersForAdmin,
|
||||
getUserForAdmin,
|
||||
addUserSignal,
|
||||
removeUserSignal,
|
||||
getUserForSocket,
|
||||
getUserIdForSocket,
|
||||
getIdentitySummary,
|
||||
getFeatureState,
|
||||
setFeatureState,
|
||||
deleteFeatureState,
|
||||
updateFeatureState,
|
||||
listFeatureStates,
|
||||
setVerified,
|
||||
|
||||
@@ -9,6 +9,7 @@ const { publishEvent } = require('../eventBus');
|
||||
const { getNickname, setNickname } = require('../nicknameService');
|
||||
const { getRole, roleEvents } = require('../roleService');
|
||||
const {
|
||||
identityEvents,
|
||||
getDb,
|
||||
identifySocket: identifyCanonicalSocket,
|
||||
getUserById,
|
||||
@@ -513,6 +514,12 @@ roleEvents.on('change', ({ socket }) => {
|
||||
}
|
||||
});
|
||||
|
||||
identityEvents.on('change', ({ userId, reason } = {}) => {
|
||||
if (!userId) return;
|
||||
refreshSocketsForUser(userId);
|
||||
emitChange('identity_change', { userId, reason });
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
io.sockets.sockets.forEach((socket) => {
|
||||
|
||||
Reference in New Issue
Block a user