mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
policy
This commit is contained in:
@@ -53,8 +53,8 @@
|
|||||||
- [ ] rover manager service (in progress: constants/state extracted)
|
- [ ] rover manager service (in progress: constants/state extracted)
|
||||||
- [x] session service
|
- [x] session service
|
||||||
- [x] turn service
|
- [x] turn service
|
||||||
- [ ] verification service
|
- [x] verification service
|
||||||
- [ ] video auth service
|
- [x] video auth service
|
||||||
- [x] All remaining services: reorganize to folder structure where needed
|
- [x] All remaining services: reorganize to folder structure where needed
|
||||||
|
|
||||||
### COMPLETED SERVICES
|
### COMPLETED SERVICES
|
||||||
@@ -81,6 +81,8 @@
|
|||||||
- Began `audioForwardService` decomposition by extracting permission/path policy helpers to `audioForwardService/policy.js` and rover/turn/socket event wiring to `audioForwardService/hooks.js`; rewired service entrypoint to use extracted modules.
|
- Began `audioForwardService` decomposition by extracting permission/path policy helpers to `audioForwardService/policy.js` and rover/turn/socket event wiring to `audioForwardService/hooks.js`; rewired service entrypoint to use extracted modules.
|
||||||
- Continued `audioForwardService` decomposition by extracting ffmpeg worker lifecycle, upload playback, and WHIP ownership/session control into `audioForwardService/workerEngine.js`; `audioForwardService/index.js` is now a thin composition layer.
|
- Continued `audioForwardService` decomposition by extracting ffmpeg worker lifecycle, upload playback, and WHIP ownership/session control into `audioForwardService/workerEngine.js`; `audioForwardService/index.js` is now a thin composition layer.
|
||||||
- Finished `buttonBoxService` decomposition by extracting persisted state management to `buttonBoxService/store.js`, reward/effect workflows to `buttonBoxService/core.js`, and HTTP transport wiring to `buttonBoxService/httpRoute.js`; `buttonBoxService/index.js` is now a thin composition layer.
|
- Finished `buttonBoxService` decomposition by extracting persisted state management to `buttonBoxService/store.js`, reward/effect workflows to `buttonBoxService/core.js`, and HTTP transport wiring to `buttonBoxService/httpRoute.js`; `buttonBoxService/index.js` is now a thin composition layer.
|
||||||
|
- Finished `verificationService` decomposition by extracting persisted store handling to `verificationService/store.js`, identity/selector normalization to `verificationService/identity.js`, verification/deterrence/request lifecycle logic to `verificationService/verificationFlow.js`, `verificationService/deterrenceFlow.js`, and `verificationService/requestFlow.js`, plus socket/role event wiring to `verificationService/hooks.js`; `verificationService/index.js` is now a thin composition layer.
|
||||||
|
- Finished `videoAuthService` decomposition by extracting MediaMTX stream parsing to `videoAuthService/streamParsing.js`, role/mode/stream policy checks to `videoAuthService/policy.js`, and auth HTTP transport wiring to `videoAuthService/httpRoute.js`; `videoAuthService/index.js` is now a thin composition layer.
|
||||||
|
|
||||||
## WebUI frontend
|
## WebUI frontend
|
||||||
### BIGGEST OFFENDERS
|
### BIGGEST OFFENDERS
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
// Deterrence Flow Module
|
||||||
|
// Purpose: Implements deterred-user matching and moderation operations that block abusive identities.
|
||||||
|
// Scope: Owns deter/undeter logic and socket deterrence reevaluation for all connected clients.
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
function createDeterrenceFlow(deps) {
|
||||||
|
const {
|
||||||
|
loadStore,
|
||||||
|
withStore,
|
||||||
|
io,
|
||||||
|
publishEvent,
|
||||||
|
emitChange,
|
||||||
|
getRole,
|
||||||
|
ensureSocketData,
|
||||||
|
identityFromSocket,
|
||||||
|
normalizeNicknameKey,
|
||||||
|
normalizeKnownIps,
|
||||||
|
isAdminRole,
|
||||||
|
parseDeterrenceSelector,
|
||||||
|
isRawIp,
|
||||||
|
normalizeCookieUserId,
|
||||||
|
isValidCookieUserId,
|
||||||
|
sanitizeNickname,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
function findDeterredMatch(store, { cookieUserId, nickname, ip }) {
|
||||||
|
const nicknameKey = normalizeNicknameKey(nickname);
|
||||||
|
return (
|
||||||
|
(store.deterredUsers || []).find((entry) => {
|
||||||
|
const entryCookie = normalizeCookieUserId(entry.cookieUserId);
|
||||||
|
if (cookieUserId && entryCookie && entryCookie === cookieUserId) return true;
|
||||||
|
const entryIps = normalizeKnownIps(entry.knownIps);
|
||||||
|
if (ip && entryIps.includes(ip)) return true;
|
||||||
|
const entryNicknameKey = normalizeNicknameKey(entry.nickname);
|
||||||
|
if (nicknameKey && entryNicknameKey && entryNicknameKey === nicknameKey) return true;
|
||||||
|
return false;
|
||||||
|
}) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reevaluateSocketDeterrence(socket) {
|
||||||
|
if (!socket) return { isDeterred: false, matchedRecordId: null, reason: 'missing_socket' };
|
||||||
|
const store = loadStore();
|
||||||
|
const data = ensureSocketData(socket);
|
||||||
|
const role = getRole(socket);
|
||||||
|
const { cookieUserId, nickname, ip } = identityFromSocket(socket);
|
||||||
|
|
||||||
|
if (isAdminRole(role)) {
|
||||||
|
data.isDeterred = false;
|
||||||
|
data.deterredRecordId = null;
|
||||||
|
return {
|
||||||
|
isDeterred: false,
|
||||||
|
matchedRecordId: null,
|
||||||
|
reason: 'admin_bypass',
|
||||||
|
cookieUserId,
|
||||||
|
nickname,
|
||||||
|
ip,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = findDeterredMatch(store, { cookieUserId, nickname, ip });
|
||||||
|
const isDeterred = Boolean(match);
|
||||||
|
data.isDeterred = isDeterred;
|
||||||
|
data.deterredRecordId = isDeterred ? match.id : null;
|
||||||
|
|
||||||
|
if (isDeterred) {
|
||||||
|
withStore((draft) => {
|
||||||
|
const record = (draft.deterredUsers || []).find((entry) => entry.id === match.id);
|
||||||
|
if (!record) return;
|
||||||
|
record.updatedAt = Date.now();
|
||||||
|
if (cookieUserId) {
|
||||||
|
record.cookieUserId = cookieUserId;
|
||||||
|
}
|
||||||
|
if (nickname) {
|
||||||
|
record.nickname = nickname;
|
||||||
|
}
|
||||||
|
record.knownIps = normalizeKnownIps(record.knownIps);
|
||||||
|
if (ip && !record.knownIps.includes(ip)) {
|
||||||
|
record.knownIps.push(ip);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDeterred,
|
||||||
|
matchedRecordId: isDeterred ? match.id : null,
|
||||||
|
reason: isDeterred ? 'matched' : 'no_match',
|
||||||
|
cookieUserId,
|
||||||
|
nickname,
|
||||||
|
ip,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModerationStateForSocket(socket) {
|
||||||
|
const data = socket?.data || {};
|
||||||
|
return {
|
||||||
|
isDeterred: Boolean(data.isDeterred),
|
||||||
|
recordId: data.deterredRecordId || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeResolveVerifiedRecordForSelector(store, parsed) {
|
||||||
|
if (parsed.cookieUserId) {
|
||||||
|
return store.verifiedUsers.find((entry) => normalizeCookieUserId(entry.cookieUserId) === parsed.cookieUserId) || null;
|
||||||
|
}
|
||||||
|
if (parsed.ip) {
|
||||||
|
return store.verifiedUsers.find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(parsed.ip)) || null;
|
||||||
|
}
|
||||||
|
const byNickname = store.verifiedUsers.filter((entry) => normalizeNicknameKey(entry.nickname) === normalizeNicknameKey(parsed.nickname));
|
||||||
|
if (byNickname.length === 1) return byNickname[0];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listDeterredUsers() {
|
||||||
|
const store = loadStore();
|
||||||
|
return (store.deterredUsers || []).map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deterUser(selector, options = {}) {
|
||||||
|
const parsed = parseDeterrenceSelector(selector);
|
||||||
|
const reasonRaw = String(options?.reason || '').trim();
|
||||||
|
const reason = reasonRaw ? reasonRaw.slice(0, 240) : null;
|
||||||
|
const actor = options?.actor ? String(options.actor) : null;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
let result = null;
|
||||||
|
|
||||||
|
withStore((draft) => {
|
||||||
|
const verified = maybeResolveVerifiedRecordForSelector(draft, parsed);
|
||||||
|
const cookieUserId = parsed.cookieUserId || normalizeCookieUserId(verified?.cookieUserId || '');
|
||||||
|
const nickname = parsed.nickname || sanitizeNickname(verified?.nickname || '');
|
||||||
|
const knownIps = normalizeKnownIps([
|
||||||
|
...(parsed.ip ? [parsed.ip] : []),
|
||||||
|
...((verified && Array.isArray(verified.knownIps)) ? verified.knownIps : []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let existing = findDeterredMatch(draft, { cookieUserId, nickname, ip: parsed.ip || null });
|
||||||
|
if (!existing && cookieUserId) {
|
||||||
|
existing = (draft.deterredUsers || []).find((entry) => normalizeCookieUserId(entry.cookieUserId) === cookieUserId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (cookieUserId) {
|
||||||
|
existing.cookieUserId = cookieUserId;
|
||||||
|
}
|
||||||
|
if (nickname) {
|
||||||
|
existing.nickname = nickname;
|
||||||
|
}
|
||||||
|
const mergedIps = normalizeKnownIps([...(existing.knownIps || []), ...knownIps]);
|
||||||
|
existing.knownIps = mergedIps;
|
||||||
|
if (reason) {
|
||||||
|
existing.reason = reason;
|
||||||
|
}
|
||||||
|
existing.updatedAt = now;
|
||||||
|
existing.updatedBy = actor;
|
||||||
|
result = { ...existing, knownIps: [...(existing.knownIps || [])], created: false };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = {
|
||||||
|
id: `du_${crypto.randomBytes(8).toString('hex')}`,
|
||||||
|
cookieUserId: cookieUserId || null,
|
||||||
|
nickname: nickname || null,
|
||||||
|
knownIps,
|
||||||
|
reason,
|
||||||
|
createdAt: now,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedAt: now,
|
||||||
|
updatedBy: actor,
|
||||||
|
};
|
||||||
|
draft.deterredUsers.push(created);
|
||||||
|
result = { ...created, knownIps: [...(created.knownIps || [])], created: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
io.sockets.sockets.forEach((socket) => {
|
||||||
|
reevaluateSocketDeterrence(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
emitChange('deter_update');
|
||||||
|
publishEvent({
|
||||||
|
source: 'moderation',
|
||||||
|
type: result?.created ? 'moderation.deterred' : 'moderation.deterrenceUpdated',
|
||||||
|
payload: {
|
||||||
|
id: result?.id || null,
|
||||||
|
cookieUserId: result?.cookieUserId || null,
|
||||||
|
nickname: result?.nickname || null,
|
||||||
|
knownIps: result?.knownIps || [],
|
||||||
|
reason: result?.reason || null,
|
||||||
|
actor,
|
||||||
|
ts: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDeterredSelector(selector) {
|
||||||
|
const store = loadStore();
|
||||||
|
const value = String(selector || '').trim();
|
||||||
|
if (!value) return { error: 'selector_required' };
|
||||||
|
|
||||||
|
const byId = (store.deterredUsers || []).find((entry) => String(entry.id) === value) || null;
|
||||||
|
if (byId) return { record: byId };
|
||||||
|
|
||||||
|
const cookie = normalizeCookieUserId(value);
|
||||||
|
if (cookie && isValidCookieUserId(cookie)) {
|
||||||
|
const byCookie = (store.deterredUsers || []).find((entry) => normalizeCookieUserId(entry.cookieUserId) === cookie) || null;
|
||||||
|
if (byCookie) return { record: byCookie };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipValue = typeof value === 'string' ? value.trim() : '';
|
||||||
|
if (isRawIp(ipValue)) {
|
||||||
|
const byIp =
|
||||||
|
(store.deterredUsers || []).find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(ipValue)) || null;
|
||||||
|
if (byIp) return { record: byIp };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nicknameKey = normalizeNicknameKey(value);
|
||||||
|
const byNickname = (store.deterredUsers || []).filter((entry) => normalizeNicknameKey(entry.nickname) === nicknameKey);
|
||||||
|
if (byNickname.length === 1) return { record: byNickname[0] };
|
||||||
|
if (byNickname.length > 1) return { error: 'ambiguous_nickname' };
|
||||||
|
|
||||||
|
return { error: 'not_found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function undeterUser(selector, removedBy = null) {
|
||||||
|
const resolved = resolveDeterredSelector(selector);
|
||||||
|
if (resolved.error) {
|
||||||
|
throw new Error(
|
||||||
|
resolved.error === 'ambiguous_nickname'
|
||||||
|
? 'Nickname matches multiple deterred users; remove by id or cookieUserId.'
|
||||||
|
: 'Deterred user not found.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = resolved.record;
|
||||||
|
let removed = null;
|
||||||
|
|
||||||
|
withStore((draft) => {
|
||||||
|
const before = draft.deterredUsers.length;
|
||||||
|
draft.deterredUsers = draft.deterredUsers.filter((entry) => entry.id !== target.id);
|
||||||
|
if (draft.deterredUsers.length !== before) {
|
||||||
|
removed = target;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!removed) {
|
||||||
|
throw new Error('Deterred user not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
io.sockets.sockets.forEach((socket) => {
|
||||||
|
reevaluateSocketDeterrence(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
const removedAt = Date.now();
|
||||||
|
emitChange('deter_remove');
|
||||||
|
publishEvent({
|
||||||
|
source: 'moderation',
|
||||||
|
type: 'moderation.undeterred',
|
||||||
|
payload: {
|
||||||
|
id: removed.id,
|
||||||
|
cookieUserId: removed.cookieUserId || null,
|
||||||
|
nickname: removed.nickname || null,
|
||||||
|
removedBy: removedBy ? String(removedBy) : null,
|
||||||
|
removedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...removed, knownIps: [...(removed.knownIps || [])] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDeterred(socket) {
|
||||||
|
return Boolean(socket?.data?.isDeterred);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
findDeterredMatch,
|
||||||
|
reevaluateSocketDeterrence,
|
||||||
|
getModerationStateForSocket,
|
||||||
|
listDeterredUsers,
|
||||||
|
deterUser,
|
||||||
|
undeterUser,
|
||||||
|
isDeterred,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createDeterrenceFlow,
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Verification Hooks Module
|
||||||
|
// Purpose: Registers socket and role-change event hooks for identity, verification requests, and reevaluation.
|
||||||
|
// Scope: Binds framework events to verification-service flows without owning verification business logic.
|
||||||
|
function registerVerificationHooks(deps) {
|
||||||
|
const {
|
||||||
|
io,
|
||||||
|
roleEvents,
|
||||||
|
logger,
|
||||||
|
identifySocket,
|
||||||
|
createVerificationRequest,
|
||||||
|
reevaluateSocketVerification,
|
||||||
|
reevaluateSocketDeterrence,
|
||||||
|
emitChange,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
identifySocket(socket, {});
|
||||||
|
|
||||||
|
socket.on('session:identify', (payload = {}, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
const result = identifySocket(socket, payload || {});
|
||||||
|
cb({ success: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('verification:request', (_, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
const request = createVerificationRequest(socket);
|
||||||
|
cb({ success: true, requestId: request.id, status: request.status });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
roleEvents.on('change', ({ socket }) => {
|
||||||
|
if (!socket) return;
|
||||||
|
try {
|
||||||
|
reevaluateSocketVerification(socket);
|
||||||
|
reevaluateSocketDeterrence(socket);
|
||||||
|
emitChange('role_change', { socketId: socket.id });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to reevaluate verification on role change', err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
registerVerificationHooks,
|
||||||
|
};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Verification Identity Helpers
|
||||||
|
// Purpose: Centralizes socket identity extraction and normalization for verification/moderation decisions.
|
||||||
|
// Scope: Keeps role checks and selector normalization consistent across all verification-service flows.
|
||||||
|
const net = require('net');
|
||||||
|
const { normalizeIp } = require('../../helpers/ipResolver');
|
||||||
|
const { getNickname } = require('../nicknameService');
|
||||||
|
const {
|
||||||
|
sanitizeNickname,
|
||||||
|
normalizeCookieUserId,
|
||||||
|
isValidCookieUserId,
|
||||||
|
generateCookieUserId,
|
||||||
|
getKnownIp,
|
||||||
|
} = require('../identityService');
|
||||||
|
|
||||||
|
function ensureSocketData(socket) {
|
||||||
|
socket.data = socket.data || {};
|
||||||
|
return socket.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityFromSocket(socket) {
|
||||||
|
const data = ensureSocketData(socket);
|
||||||
|
return {
|
||||||
|
cookieUserId: normalizeCookieUserId(data.cookieUserId),
|
||||||
|
nickname: sanitizeNickname(getNickname(socket)),
|
||||||
|
ip: getKnownIp(socket),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNicknameKey(value) {
|
||||||
|
return sanitizeNickname(value).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKnownIps(raw = []) {
|
||||||
|
const out = [];
|
||||||
|
(Array.isArray(raw) ? raw : []).forEach((value) => {
|
||||||
|
const ip = typeof value === 'string' ? value.trim() : '';
|
||||||
|
if (!ip) return;
|
||||||
|
if (!out.includes(ip)) {
|
||||||
|
out.push(ip);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdminRole(role) {
|
||||||
|
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDeterrenceSelector(selector) {
|
||||||
|
const value = String(selector || '').trim();
|
||||||
|
if (!value) {
|
||||||
|
throw new Error('Selector required.');
|
||||||
|
}
|
||||||
|
const cookie = normalizeCookieUserId(value);
|
||||||
|
if (cookie && isValidCookieUserId(cookie)) {
|
||||||
|
return { cookieUserId: cookie, nickname: '', ip: null };
|
||||||
|
}
|
||||||
|
const ip = normalizeIp(value);
|
||||||
|
if (ip && net.isIP(ip)) {
|
||||||
|
return { cookieUserId: '', nickname: '', ip };
|
||||||
|
}
|
||||||
|
const nickname = sanitizeNickname(value);
|
||||||
|
if (!nickname) {
|
||||||
|
throw new Error('Selector required.');
|
||||||
|
}
|
||||||
|
return { cookieUserId: '', nickname, ip: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRawIp(value) {
|
||||||
|
const ip = typeof value === 'string' ? value.trim() : '';
|
||||||
|
return Boolean(ip && net.isIP(ip));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ensureSocketData,
|
||||||
|
identityFromSocket,
|
||||||
|
normalizeNicknameKey,
|
||||||
|
normalizeKnownIps,
|
||||||
|
isAdminRole,
|
||||||
|
parseDeterrenceSelector,
|
||||||
|
isRawIp,
|
||||||
|
normalizeCookieUserId,
|
||||||
|
isValidCookieUserId,
|
||||||
|
generateCookieUserId,
|
||||||
|
sanitizeNickname,
|
||||||
|
};
|
||||||
@@ -1,231 +1,91 @@
|
|||||||
// verification Service
|
// Verification Service Module
|
||||||
// Purpose: Defines the verification Service module and the helpers/state used by this service unit.
|
// Purpose: Composes verification identity, request, and moderation deterrence flows into one public service API.
|
||||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
// Scope: Exposes stable verification operations while delegating behavior to focused submodules.
|
||||||
const crypto = require('crypto');
|
|
||||||
const net = require('net');
|
|
||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const logger = require('../../globals/logger').child('verificationService');
|
const logger = require('../../globals/logger').child('verificationService');
|
||||||
const { resolveDataPath } = require('../../helpers/dataPaths');
|
|
||||||
const { publishEvent } = require('../eventBus');
|
const { publishEvent } = require('../eventBus');
|
||||||
const { normalizeIp } = require('../../helpers/ipResolver');
|
|
||||||
const { getNickname, setNickname } = require('../nicknameService');
|
const { getNickname, setNickname } = require('../nicknameService');
|
||||||
const { getRole, roleEvents } = require('../roleService');
|
const { getRole, roleEvents } = require('../roleService');
|
||||||
|
|
||||||
|
const { loadStore, withStore } = require('./store');
|
||||||
const {
|
const {
|
||||||
sanitizeNickname,
|
ensureSocketData,
|
||||||
|
identityFromSocket,
|
||||||
|
normalizeNicknameKey,
|
||||||
|
normalizeKnownIps,
|
||||||
|
isAdminRole,
|
||||||
|
parseDeterrenceSelector,
|
||||||
|
isRawIp,
|
||||||
normalizeCookieUserId,
|
normalizeCookieUserId,
|
||||||
isValidCookieUserId,
|
isValidCookieUserId,
|
||||||
generateCookieUserId,
|
generateCookieUserId,
|
||||||
getKnownIp,
|
sanitizeNickname,
|
||||||
createJsonStore,
|
} = require('./identity');
|
||||||
} = require('../identityService');
|
const { createVerificationFlow } = require('./verificationFlow');
|
||||||
|
const { createDeterrenceFlow } = require('./deterrenceFlow');
|
||||||
const STORE_PATH = resolveDataPath('verified-users.json');
|
const { createRequestFlow } = require('./requestFlow');
|
||||||
|
const { registerVerificationHooks } = require('./hooks');
|
||||||
|
|
||||||
const verificationEvents = new EventEmitter();
|
const verificationEvents = new EventEmitter();
|
||||||
|
|
||||||
function normalizeStoreShape(store) {
|
|
||||||
const next = store && typeof store === 'object' ? store : {};
|
|
||||||
return {
|
|
||||||
verifiedUsers: Array.isArray(next.verifiedUsers) ? next.verifiedUsers : [],
|
|
||||||
pendingRequests: Array.isArray(next.pendingRequests) ? next.pendingRequests : [],
|
|
||||||
dmMessages: Array.isArray(next.dmMessages) ? next.dmMessages : [],
|
|
||||||
deterredUsers: Array.isArray(next.deterredUsers) ? next.deterredUsers : [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloneStore(current) {
|
|
||||||
return {
|
|
||||||
verifiedUsers: (current.verifiedUsers || []).map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] })),
|
|
||||||
pendingRequests: (current.pendingRequests || []).map((entry) => ({ ...entry })),
|
|
||||||
dmMessages: (current.dmMessages || []).map((entry) => ({ ...entry })),
|
|
||||||
deterredUsers: (current.deterredUsers || []).map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] })),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const storeApi = createJsonStore({
|
|
||||||
path: STORE_PATH,
|
|
||||||
normalizeStoreShape,
|
|
||||||
cloneStore,
|
|
||||||
logger,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { loadStore, withStore } = storeApi;
|
|
||||||
|
|
||||||
function ensureSocketData(socket) {
|
|
||||||
socket.data = socket.data || {};
|
|
||||||
return socket.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function identityFromSocket(socket) {
|
|
||||||
const data = ensureSocketData(socket);
|
|
||||||
return {
|
|
||||||
cookieUserId: normalizeCookieUserId(data.cookieUserId),
|
|
||||||
nickname: sanitizeNickname(getNickname(socket)),
|
|
||||||
ip: getKnownIp(socket),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeNicknameKey(value) {
|
|
||||||
return sanitizeNickname(value).toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeKnownIps(raw = []) {
|
|
||||||
const out = [];
|
|
||||||
(Array.isArray(raw) ? raw : []).forEach((value) => {
|
|
||||||
const ip = typeof value === 'string' ? value.trim() : '';
|
|
||||||
if (!ip) return;
|
|
||||||
if (!out.includes(ip)) {
|
|
||||||
out.push(ip);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findVerifiedMatch(store, { cookieUserId, ip }) {
|
|
||||||
if (!cookieUserId && !ip) return null;
|
|
||||||
const byCookie = cookieUserId
|
|
||||||
? store.verifiedUsers.find((entry) => normalizeCookieUserId(entry.cookieUserId) === cookieUserId) || null
|
|
||||||
: null;
|
|
||||||
if (byCookie) return byCookie;
|
|
||||||
if (!ip) return null;
|
|
||||||
return (
|
|
||||||
store.verifiedUsers.find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(ip)) || null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAdminRole(role) {
|
|
||||||
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
function findDeterredMatch(store, { cookieUserId, nickname, ip }) {
|
|
||||||
const nicknameKey = normalizeNicknameKey(nickname);
|
|
||||||
return (
|
|
||||||
(store.deterredUsers || []).find((entry) => {
|
|
||||||
const entryCookie = normalizeCookieUserId(entry.cookieUserId);
|
|
||||||
if (cookieUserId && entryCookie && entryCookie === cookieUserId) return true;
|
|
||||||
const entryIps = normalizeKnownIps(entry.knownIps);
|
|
||||||
if (ip && entryIps.includes(ip)) return true;
|
|
||||||
const entryNicknameKey = normalizeNicknameKey(entry.nickname);
|
|
||||||
if (nicknameKey && entryNicknameKey && entryNicknameKey === nicknameKey) return true;
|
|
||||||
return false;
|
|
||||||
}) || null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitChange(reason, payload = {}) {
|
function emitChange(reason, payload = {}) {
|
||||||
verificationEvents.emit('change', { reason, ...payload });
|
verificationEvents.emit('change', { reason, ...payload });
|
||||||
}
|
}
|
||||||
|
|
||||||
function reevaluateSocketVerification(socket) {
|
let reevaluateSocketVerification = () => ({ isVerified: false, matchedRecordId: null, reason: 'not_initialized' });
|
||||||
if (!socket) return { isVerified: false, matchedRecordId: null, reason: 'missing_socket' };
|
let reevaluateSocketDeterrence = () => ({ isDeterred: false, matchedRecordId: null, reason: 'not_initialized' });
|
||||||
const store = loadStore();
|
|
||||||
const data = ensureSocketData(socket);
|
|
||||||
const role = getRole(socket);
|
|
||||||
const { cookieUserId, nickname, ip } = identityFromSocket(socket);
|
|
||||||
|
|
||||||
if (role === 'lockdown') {
|
const verificationFlow = createVerificationFlow({
|
||||||
data.isVerified = true;
|
loadStore,
|
||||||
data.verifiedRecordId = null;
|
withStore,
|
||||||
return {
|
io,
|
||||||
isVerified: true,
|
publishEvent,
|
||||||
matchedRecordId: null,
|
emitChange,
|
||||||
reason: 'lockdown_admin',
|
getRole,
|
||||||
cookieUserId,
|
getNickname,
|
||||||
nickname,
|
ensureSocketData,
|
||||||
ip,
|
identityFromSocket,
|
||||||
};
|
normalizeCookieUserId,
|
||||||
}
|
sanitizeNickname,
|
||||||
|
reevaluateSocketDeterrence: (...args) => reevaluateSocketDeterrence(...args),
|
||||||
|
});
|
||||||
|
reevaluateSocketVerification = verificationFlow.reevaluateSocketVerification;
|
||||||
|
|
||||||
const match = findVerifiedMatch(store, { cookieUserId, ip });
|
const deterrenceFlow = createDeterrenceFlow({
|
||||||
const nicknameMatches = Boolean(match && nickname && sanitizeNickname(match.nickname) === nickname);
|
loadStore,
|
||||||
|
withStore,
|
||||||
|
io,
|
||||||
|
publishEvent,
|
||||||
|
emitChange,
|
||||||
|
getRole,
|
||||||
|
ensureSocketData,
|
||||||
|
identityFromSocket,
|
||||||
|
normalizeNicknameKey,
|
||||||
|
normalizeKnownIps,
|
||||||
|
isAdminRole,
|
||||||
|
parseDeterrenceSelector,
|
||||||
|
isRawIp,
|
||||||
|
normalizeCookieUserId,
|
||||||
|
isValidCookieUserId,
|
||||||
|
sanitizeNickname,
|
||||||
|
findVerifiedMatch: verificationFlow.findVerifiedMatch,
|
||||||
|
});
|
||||||
|
reevaluateSocketDeterrence = deterrenceFlow.reevaluateSocketDeterrence;
|
||||||
|
|
||||||
let isVerified = false;
|
const requestFlow = createRequestFlow({
|
||||||
let reason = 'no_match';
|
loadStore,
|
||||||
if (match && nicknameMatches) {
|
withStore,
|
||||||
isVerified = true;
|
io,
|
||||||
reason = 'matched';
|
publishEvent,
|
||||||
} else if (match && !nicknameMatches) {
|
emitChange,
|
||||||
reason = 'nickname_mismatch';
|
ensureSocketData,
|
||||||
}
|
identityFromSocket,
|
||||||
|
isValidCookieUserId,
|
||||||
data.isVerified = isVerified;
|
normalizeCookieUserId,
|
||||||
data.verifiedRecordId = isVerified ? match.id : null;
|
reevaluateSocketVerification,
|
||||||
|
reevaluateSocketDeterrence,
|
||||||
if (isVerified) {
|
});
|
||||||
withStore((draft) => {
|
|
||||||
const record = draft.verifiedUsers.find((entry) => entry.id === match.id);
|
|
||||||
if (!record) return;
|
|
||||||
record.updatedAt = Date.now();
|
|
||||||
record.nickname = nickname;
|
|
||||||
if (ip && !record.knownIps.includes(ip)) {
|
|
||||||
record.knownIps.push(ip);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isVerified,
|
|
||||||
matchedRecordId: isVerified ? match.id : null,
|
|
||||||
reason,
|
|
||||||
cookieUserId,
|
|
||||||
nickname,
|
|
||||||
ip,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function reevaluateSocketDeterrence(socket) {
|
|
||||||
if (!socket) return { isDeterred: false, matchedRecordId: null, reason: 'missing_socket' };
|
|
||||||
const store = loadStore();
|
|
||||||
const data = ensureSocketData(socket);
|
|
||||||
const role = getRole(socket);
|
|
||||||
const { cookieUserId, nickname, ip } = identityFromSocket(socket);
|
|
||||||
|
|
||||||
if (isAdminRole(role)) {
|
|
||||||
data.isDeterred = false;
|
|
||||||
data.deterredRecordId = null;
|
|
||||||
return {
|
|
||||||
isDeterred: false,
|
|
||||||
matchedRecordId: null,
|
|
||||||
reason: 'admin_bypass',
|
|
||||||
cookieUserId,
|
|
||||||
nickname,
|
|
||||||
ip,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = findDeterredMatch(store, { cookieUserId, nickname, ip });
|
|
||||||
const isDeterred = Boolean(match);
|
|
||||||
data.isDeterred = isDeterred;
|
|
||||||
data.deterredRecordId = isDeterred ? match.id : null;
|
|
||||||
|
|
||||||
if (isDeterred) {
|
|
||||||
withStore((draft) => {
|
|
||||||
const record = (draft.deterredUsers || []).find((entry) => entry.id === match.id);
|
|
||||||
if (!record) return;
|
|
||||||
record.updatedAt = Date.now();
|
|
||||||
if (cookieUserId) {
|
|
||||||
record.cookieUserId = cookieUserId;
|
|
||||||
}
|
|
||||||
if (nickname) {
|
|
||||||
record.nickname = nickname;
|
|
||||||
}
|
|
||||||
record.knownIps = normalizeKnownIps(record.knownIps);
|
|
||||||
if (ip && !record.knownIps.includes(ip)) {
|
|
||||||
record.knownIps.push(ip);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isDeterred,
|
|
||||||
matchedRecordId: isDeterred ? match.id : null,
|
|
||||||
reason: isDeterred ? 'matched' : 'no_match',
|
|
||||||
cookieUserId,
|
|
||||||
nickname,
|
|
||||||
ip,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function identifySocket(socket, payload = {}) {
|
function identifySocket(socket, payload = {}) {
|
||||||
if (!socket) {
|
if (!socket) {
|
||||||
@@ -263,537 +123,39 @@ function identifySocket(socket, payload = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVerificationStatus(socket) {
|
|
||||||
const data = socket?.data || {};
|
|
||||||
return {
|
|
||||||
isVerified: Boolean(data.isVerified),
|
|
||||||
recordId: data.verifiedRecordId || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIdentitySummary(socket) {
|
|
||||||
const data = socket?.data || {};
|
|
||||||
return {
|
|
||||||
cookieUserId: normalizeCookieUserId(data.cookieUserId) || null,
|
|
||||||
nickname: getNickname(socket) || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPendingRequestForIdentity(cookieUserId) {
|
|
||||||
const key = normalizeCookieUserId(cookieUserId);
|
|
||||||
if (!key) return null;
|
|
||||||
const store = loadStore();
|
|
||||||
return store.pendingRequests.find((entry) => entry.status === 'pending' && entry.cookieUserId === key) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function listVerifiedUsers() {
|
|
||||||
const store = loadStore();
|
|
||||||
return store.verifiedUsers.map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveVerifiedUserSelector(selector) {
|
|
||||||
const value = String(selector || '').trim();
|
|
||||||
if (!value) return { error: 'selector_required' };
|
|
||||||
const store = loadStore();
|
|
||||||
const byCookie = store.verifiedUsers.find((entry) => entry.cookieUserId === value) || null;
|
|
||||||
if (byCookie) return { record: byCookie };
|
|
||||||
const byNickname = store.verifiedUsers.filter((entry) => sanitizeNickname(entry.nickname) === sanitizeNickname(value));
|
|
||||||
if (byNickname.length === 1) return { record: byNickname[0] };
|
|
||||||
if (byNickname.length > 1) return { error: 'ambiguous_nickname' };
|
|
||||||
return { error: 'not_found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeVerifiedUser(selector, removedBy = null) {
|
|
||||||
const resolved = resolveVerifiedUserSelector(selector);
|
|
||||||
if (resolved.error) {
|
|
||||||
throw new Error(
|
|
||||||
resolved.error === 'ambiguous_nickname'
|
|
||||||
? 'Nickname matches multiple users; remove by cookieUserId.'
|
|
||||||
: 'Verified user not found.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const target = resolved.record;
|
|
||||||
let removed = null;
|
|
||||||
withStore((draft) => {
|
|
||||||
const before = draft.verifiedUsers.length;
|
|
||||||
draft.verifiedUsers = draft.verifiedUsers.filter((entry) => entry.id !== target.id);
|
|
||||||
if (draft.verifiedUsers.length !== before) {
|
|
||||||
removed = target;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!removed) {
|
|
||||||
throw new Error('Verified user not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
io.sockets.sockets.forEach((socket) => {
|
|
||||||
const data = ensureSocketData(socket);
|
|
||||||
if (normalizeCookieUserId(data.cookieUserId) === removed.cookieUserId) {
|
|
||||||
reevaluateSocketVerification(socket);
|
|
||||||
reevaluateSocketDeterrence(socket);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
emitChange('remove', { cookieUserId: removed.cookieUserId });
|
|
||||||
publishEvent({
|
|
||||||
source: 'verification',
|
|
||||||
type: 'verification.userRemoved',
|
|
||||||
payload: {
|
|
||||||
cookieUserId: removed.cookieUserId,
|
|
||||||
nickname: removed.nickname,
|
|
||||||
removedBy,
|
|
||||||
removedAt: Date.now(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createVerificationRequest(socket) {
|
|
||||||
if (!socket) {
|
|
||||||
throw new Error('Socket required');
|
|
||||||
}
|
|
||||||
const data = ensureSocketData(socket);
|
|
||||||
const { cookieUserId, nickname, ip } = identityFromSocket(socket);
|
|
||||||
|
|
||||||
if (data.isVerified) {
|
|
||||||
throw new Error('You are already verified.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cookieUserId) {
|
|
||||||
throw new Error('Identity key missing. Reconnect and try again.');
|
|
||||||
}
|
|
||||||
if (!isValidCookieUserId(cookieUserId)) {
|
|
||||||
throw new Error('Identity key format invalid.');
|
|
||||||
}
|
|
||||||
if (!nickname) {
|
|
||||||
throw new Error('Nickname required before requesting verification.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingPending = getPendingRequestForIdentity(cookieUserId);
|
|
||||||
if (existingPending) {
|
|
||||||
return existingPending;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = {
|
|
||||||
id: `vr_${crypto.randomBytes(8).toString('hex')}`,
|
|
||||||
status: 'pending',
|
|
||||||
cookieUserId,
|
|
||||||
nickname,
|
|
||||||
ip,
|
|
||||||
socketId: socket.id,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
resolvedAt: null,
|
|
||||||
resolvedBy: null,
|
|
||||||
decision: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
withStore((draft) => {
|
|
||||||
draft.pendingRequests.push(request);
|
|
||||||
});
|
|
||||||
|
|
||||||
publishEvent({ source: 'verification', type: 'verification.requested', payload: request });
|
|
||||||
emitChange('request', { requestId: request.id, socketId: socket.id });
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachDmMessage(requestId, messageId, adminDiscordId) {
|
|
||||||
if (!requestId || !messageId) return;
|
|
||||||
withStore((draft) => {
|
|
||||||
const exists = draft.dmMessages.find((entry) => entry.messageId === messageId);
|
|
||||||
if (exists) return;
|
|
||||||
draft.dmMessages.push({
|
|
||||||
requestId,
|
|
||||||
messageId,
|
|
||||||
adminDiscordId: adminDiscordId ? String(adminDiscordId) : null,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPendingRequestById(requestId) {
|
|
||||||
if (!requestId) return null;
|
|
||||||
const store = loadStore();
|
|
||||||
return store.pendingRequests.find((entry) => entry.id === requestId && entry.status === 'pending') || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRequestByMessageId(messageId) {
|
|
||||||
if (!messageId) return null;
|
|
||||||
const store = loadStore();
|
|
||||||
const map = store.dmMessages.find((entry) => entry.messageId === messageId);
|
|
||||||
if (!map) return null;
|
|
||||||
const request = store.pendingRequests.find((entry) => entry.id === map.requestId) || null;
|
|
||||||
return request ? { request, map } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function approveRequest(requestId, actorDiscordId) {
|
|
||||||
const request = getPendingRequestById(requestId);
|
|
||||||
if (!request) {
|
|
||||||
throw new Error('Request not found or already resolved.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const approvedAt = Date.now();
|
|
||||||
const actor = actorDiscordId ? String(actorDiscordId) : null;
|
|
||||||
|
|
||||||
withStore((draft) => {
|
|
||||||
const pending = draft.pendingRequests.find((entry) => entry.id === requestId);
|
|
||||||
if (!pending || pending.status !== 'pending') {
|
|
||||||
throw new Error('Request not found or already resolved.');
|
|
||||||
}
|
|
||||||
pending.status = 'approved';
|
|
||||||
pending.decision = 'approved';
|
|
||||||
pending.resolvedAt = approvedAt;
|
|
||||||
pending.resolvedBy = actor;
|
|
||||||
|
|
||||||
let target =
|
|
||||||
draft.verifiedUsers.find((entry) => entry.cookieUserId === pending.cookieUserId) ||
|
|
||||||
draft.verifiedUsers.find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(pending.ip));
|
|
||||||
|
|
||||||
if (!target) {
|
|
||||||
target = {
|
|
||||||
id: `vu_${crypto.randomBytes(8).toString('hex')}`,
|
|
||||||
cookieUserId: pending.cookieUserId,
|
|
||||||
nickname: pending.nickname,
|
|
||||||
knownIps: pending.ip ? [pending.ip] : [],
|
|
||||||
createdAt: approvedAt,
|
|
||||||
updatedAt: approvedAt,
|
|
||||||
approvedBy: actor,
|
|
||||||
};
|
|
||||||
draft.verifiedUsers.push(target);
|
|
||||||
} else {
|
|
||||||
target.cookieUserId = pending.cookieUserId;
|
|
||||||
target.nickname = pending.nickname;
|
|
||||||
if (pending.ip && !target.knownIps.includes(pending.ip)) {
|
|
||||||
target.knownIps.push(pending.ip);
|
|
||||||
}
|
|
||||||
target.updatedAt = approvedAt;
|
|
||||||
target.approvedBy = actor;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
io.sockets.sockets.forEach((socket) => {
|
|
||||||
const data = ensureSocketData(socket);
|
|
||||||
if (normalizeCookieUserId(data.cookieUserId) === request.cookieUserId) {
|
|
||||||
reevaluateSocketVerification(socket);
|
|
||||||
reevaluateSocketDeterrence(socket);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
publishEvent({
|
|
||||||
source: 'verification',
|
|
||||||
type: 'verification.resolved',
|
|
||||||
payload: {
|
|
||||||
requestId,
|
|
||||||
decision: 'approved',
|
|
||||||
cookieUserId: request.cookieUserId,
|
|
||||||
nickname: request.nickname,
|
|
||||||
resolvedBy: actor,
|
|
||||||
resolvedAt: approvedAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
emitChange('approve', { requestId });
|
|
||||||
}
|
|
||||||
|
|
||||||
function denyRequest(requestId, actorDiscordId) {
|
|
||||||
const request = getPendingRequestById(requestId);
|
|
||||||
if (!request) {
|
|
||||||
throw new Error('Request not found or already resolved.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const deniedAt = Date.now();
|
|
||||||
const actor = actorDiscordId ? String(actorDiscordId) : null;
|
|
||||||
|
|
||||||
withStore((draft) => {
|
|
||||||
const pending = draft.pendingRequests.find((entry) => entry.id === requestId);
|
|
||||||
if (!pending || pending.status !== 'pending') {
|
|
||||||
throw new Error('Request not found or already resolved.');
|
|
||||||
}
|
|
||||||
pending.status = 'denied';
|
|
||||||
pending.decision = 'denied';
|
|
||||||
pending.resolvedAt = deniedAt;
|
|
||||||
pending.resolvedBy = actor;
|
|
||||||
});
|
|
||||||
|
|
||||||
publishEvent({
|
|
||||||
source: 'verification',
|
|
||||||
type: 'verification.resolved',
|
|
||||||
payload: {
|
|
||||||
requestId,
|
|
||||||
decision: 'denied',
|
|
||||||
cookieUserId: request.cookieUserId,
|
|
||||||
nickname: request.nickname,
|
|
||||||
resolvedBy: actor,
|
|
||||||
resolvedAt: deniedAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
emitChange('deny', { requestId });
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVerificationStateForSocket(socket) {
|
function getVerificationStateForSocket(socket) {
|
||||||
const identity = getIdentitySummary(socket);
|
return verificationFlow.getVerificationStateForSocket(socket, requestFlow.getPendingRequestForIdentity);
|
||||||
const pending = getPendingRequestForIdentity(identity.cookieUserId);
|
|
||||||
return {
|
|
||||||
isVerified: Boolean(socket?.data?.isVerified),
|
|
||||||
pendingRequestId: pending?.id || null,
|
|
||||||
pendingRequestedAt: pending?.createdAt || null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModerationStateForSocket(socket) {
|
registerVerificationHooks({
|
||||||
const data = socket?.data || {};
|
io,
|
||||||
return {
|
roleEvents,
|
||||||
isDeterred: Boolean(data.isDeterred),
|
logger,
|
||||||
recordId: data.deterredRecordId || null,
|
identifySocket,
|
||||||
};
|
createVerificationRequest: requestFlow.createVerificationRequest,
|
||||||
}
|
reevaluateSocketVerification,
|
||||||
|
reevaluateSocketDeterrence,
|
||||||
function isVerified(socket) {
|
emitChange,
|
||||||
return Boolean(socket?.data?.isVerified);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDeterred(socket) {
|
|
||||||
return Boolean(socket?.data?.isDeterred);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDeterrenceSelector(selector) {
|
|
||||||
const value = String(selector || '').trim();
|
|
||||||
if (!value) {
|
|
||||||
throw new Error('Selector required.');
|
|
||||||
}
|
|
||||||
const cookie = normalizeCookieUserId(value);
|
|
||||||
if (cookie && isValidCookieUserId(cookie)) {
|
|
||||||
return { cookieUserId: cookie, nickname: '', ip: null };
|
|
||||||
}
|
|
||||||
const ip = normalizeIp(value);
|
|
||||||
if (ip && net.isIP(ip)) {
|
|
||||||
return { cookieUserId: '', nickname: '', ip };
|
|
||||||
}
|
|
||||||
const nickname = sanitizeNickname(value);
|
|
||||||
if (!nickname) {
|
|
||||||
throw new Error('Selector required.');
|
|
||||||
}
|
|
||||||
return { cookieUserId: '', nickname, ip: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function maybeResolveVerifiedRecordForSelector(store, parsed) {
|
|
||||||
if (parsed.cookieUserId) {
|
|
||||||
return store.verifiedUsers.find((entry) => normalizeCookieUserId(entry.cookieUserId) === parsed.cookieUserId) || null;
|
|
||||||
}
|
|
||||||
if (parsed.ip) {
|
|
||||||
return store.verifiedUsers.find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(parsed.ip)) || null;
|
|
||||||
}
|
|
||||||
const byNickname = store.verifiedUsers.filter((entry) => normalizeNicknameKey(entry.nickname) === normalizeNicknameKey(parsed.nickname));
|
|
||||||
if (byNickname.length === 1) return byNickname[0];
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function listDeterredUsers() {
|
|
||||||
const store = loadStore();
|
|
||||||
return (store.deterredUsers || []).map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function deterUser(selector, options = {}) {
|
|
||||||
const parsed = parseDeterrenceSelector(selector);
|
|
||||||
const reasonRaw = String(options?.reason || '').trim();
|
|
||||||
const reason = reasonRaw ? reasonRaw.slice(0, 240) : null;
|
|
||||||
const actor = options?.actor ? String(options.actor) : null;
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
let result = null;
|
|
||||||
|
|
||||||
withStore((draft) => {
|
|
||||||
const verified = maybeResolveVerifiedRecordForSelector(draft, parsed);
|
|
||||||
const cookieUserId = parsed.cookieUserId || normalizeCookieUserId(verified?.cookieUserId || '');
|
|
||||||
const nickname = parsed.nickname || sanitizeNickname(verified?.nickname || '');
|
|
||||||
const knownIps = normalizeKnownIps([
|
|
||||||
...(parsed.ip ? [parsed.ip] : []),
|
|
||||||
...((verified && Array.isArray(verified.knownIps)) ? verified.knownIps : []),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let existing = findDeterredMatch(draft, { cookieUserId, nickname, ip: parsed.ip || null });
|
|
||||||
if (!existing && cookieUserId) {
|
|
||||||
existing = (draft.deterredUsers || []).find((entry) => normalizeCookieUserId(entry.cookieUserId) === cookieUserId) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
if (cookieUserId) {
|
|
||||||
existing.cookieUserId = cookieUserId;
|
|
||||||
}
|
|
||||||
if (nickname) {
|
|
||||||
existing.nickname = nickname;
|
|
||||||
}
|
|
||||||
const mergedIps = normalizeKnownIps([...(existing.knownIps || []), ...knownIps]);
|
|
||||||
existing.knownIps = mergedIps;
|
|
||||||
if (reason) {
|
|
||||||
existing.reason = reason;
|
|
||||||
}
|
|
||||||
existing.updatedAt = now;
|
|
||||||
existing.updatedBy = actor;
|
|
||||||
result = { ...existing, knownIps: [...(existing.knownIps || [])], created: false };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = {
|
|
||||||
id: `du_${crypto.randomBytes(8).toString('hex')}`,
|
|
||||||
cookieUserId: cookieUserId || null,
|
|
||||||
nickname: nickname || null,
|
|
||||||
knownIps,
|
|
||||||
reason,
|
|
||||||
createdAt: now,
|
|
||||||
createdBy: actor,
|
|
||||||
updatedAt: now,
|
|
||||||
updatedBy: actor,
|
|
||||||
};
|
|
||||||
draft.deterredUsers.push(created);
|
|
||||||
result = { ...created, knownIps: [...(created.knownIps || [])], created: true };
|
|
||||||
});
|
|
||||||
|
|
||||||
io.sockets.sockets.forEach((socket) => {
|
|
||||||
reevaluateSocketDeterrence(socket);
|
|
||||||
});
|
|
||||||
|
|
||||||
emitChange('deter_update');
|
|
||||||
publishEvent({
|
|
||||||
source: 'moderation',
|
|
||||||
type: result?.created ? 'moderation.deterred' : 'moderation.deterrenceUpdated',
|
|
||||||
payload: {
|
|
||||||
id: result?.id || null,
|
|
||||||
cookieUserId: result?.cookieUserId || null,
|
|
||||||
nickname: result?.nickname || null,
|
|
||||||
knownIps: result?.knownIps || [],
|
|
||||||
reason: result?.reason || null,
|
|
||||||
actor,
|
|
||||||
ts: now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDeterredSelector(selector) {
|
|
||||||
const store = loadStore();
|
|
||||||
const value = String(selector || '').trim();
|
|
||||||
if (!value) return { error: 'selector_required' };
|
|
||||||
|
|
||||||
const byId = (store.deterredUsers || []).find((entry) => String(entry.id) === value) || null;
|
|
||||||
if (byId) return { record: byId };
|
|
||||||
|
|
||||||
const cookie = normalizeCookieUserId(value);
|
|
||||||
if (cookie && isValidCookieUserId(cookie)) {
|
|
||||||
const byCookie = (store.deterredUsers || []).find((entry) => normalizeCookieUserId(entry.cookieUserId) === cookie) || null;
|
|
||||||
if (byCookie) return { record: byCookie };
|
|
||||||
}
|
|
||||||
|
|
||||||
const ip = typeof value === 'string' ? value.trim() : '';
|
|
||||||
if (ip && net.isIP(ip)) {
|
|
||||||
const byIp = (store.deterredUsers || []).find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(ip)) || null;
|
|
||||||
if (byIp) return { record: byIp };
|
|
||||||
}
|
|
||||||
|
|
||||||
const nicknameKey = normalizeNicknameKey(value);
|
|
||||||
const byNickname = (store.deterredUsers || []).filter((entry) => normalizeNicknameKey(entry.nickname) === nicknameKey);
|
|
||||||
if (byNickname.length === 1) return { record: byNickname[0] };
|
|
||||||
if (byNickname.length > 1) return { error: 'ambiguous_nickname' };
|
|
||||||
|
|
||||||
return { error: 'not_found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function undeterUser(selector, removedBy = null) {
|
|
||||||
const resolved = resolveDeterredSelector(selector);
|
|
||||||
if (resolved.error) {
|
|
||||||
throw new Error(
|
|
||||||
resolved.error === 'ambiguous_nickname'
|
|
||||||
? 'Nickname matches multiple deterred users; remove by id or cookieUserId.'
|
|
||||||
: 'Deterred user not found.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = resolved.record;
|
|
||||||
let removed = null;
|
|
||||||
|
|
||||||
withStore((draft) => {
|
|
||||||
const before = draft.deterredUsers.length;
|
|
||||||
draft.deterredUsers = draft.deterredUsers.filter((entry) => entry.id !== target.id);
|
|
||||||
if (draft.deterredUsers.length !== before) {
|
|
||||||
removed = target;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!removed) {
|
|
||||||
throw new Error('Deterred user not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
io.sockets.sockets.forEach((socket) => {
|
|
||||||
reevaluateSocketDeterrence(socket);
|
|
||||||
});
|
|
||||||
|
|
||||||
const removedAt = Date.now();
|
|
||||||
emitChange('deter_remove');
|
|
||||||
publishEvent({
|
|
||||||
source: 'moderation',
|
|
||||||
type: 'moderation.undeterred',
|
|
||||||
payload: {
|
|
||||||
id: removed.id,
|
|
||||||
cookieUserId: removed.cookieUserId || null,
|
|
||||||
nickname: removed.nickname || null,
|
|
||||||
removedBy: removedBy ? String(removedBy) : null,
|
|
||||||
removedAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { ...removed, knownIps: [...(removed.knownIps || [])] };
|
|
||||||
}
|
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
|
||||||
identifySocket(socket, {});
|
|
||||||
|
|
||||||
socket.on('session:identify', (payload = {}, cb = () => {}) => {
|
|
||||||
try {
|
|
||||||
const result = identifySocket(socket, payload || {});
|
|
||||||
cb({ success: true, ...result });
|
|
||||||
} catch (err) {
|
|
||||||
cb({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('verification:request', (_, cb = () => {}) => {
|
|
||||||
try {
|
|
||||||
const request = createVerificationRequest(socket);
|
|
||||||
cb({ success: true, requestId: request.id, status: request.status });
|
|
||||||
} catch (err) {
|
|
||||||
cb({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
roleEvents.on('change', ({ socket }) => {
|
|
||||||
if (!socket) return;
|
|
||||||
try {
|
|
||||||
reevaluateSocketVerification(socket);
|
|
||||||
reevaluateSocketDeterrence(socket);
|
|
||||||
emitChange('role_change', { socketId: socket.id });
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Failed to reevaluate verification on role change', err.message);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
identifySocket,
|
identifySocket,
|
||||||
getVerificationStatus,
|
getVerificationStatus: verificationFlow.getVerificationStatus,
|
||||||
getIdentitySummary,
|
getIdentitySummary: verificationFlow.getIdentitySummary,
|
||||||
getVerificationStateForSocket,
|
getVerificationStateForSocket,
|
||||||
getModerationStateForSocket,
|
getModerationStateForSocket: deterrenceFlow.getModerationStateForSocket,
|
||||||
createVerificationRequest,
|
createVerificationRequest: requestFlow.createVerificationRequest,
|
||||||
attachDmMessage,
|
attachDmMessage: requestFlow.attachDmMessage,
|
||||||
getRequestByMessageId,
|
getRequestByMessageId: requestFlow.getRequestByMessageId,
|
||||||
approveRequest,
|
approveRequest: requestFlow.approveRequest,
|
||||||
denyRequest,
|
denyRequest: requestFlow.denyRequest,
|
||||||
listVerifiedUsers,
|
listVerifiedUsers: verificationFlow.listVerifiedUsers,
|
||||||
removeVerifiedUser,
|
removeVerifiedUser: verificationFlow.removeVerifiedUser,
|
||||||
listDeterredUsers,
|
listDeterredUsers: deterrenceFlow.listDeterredUsers,
|
||||||
deterUser,
|
deterUser: deterrenceFlow.deterUser,
|
||||||
undeterUser,
|
undeterUser: deterrenceFlow.undeterUser,
|
||||||
isVerified,
|
isVerified: verificationFlow.isVerified,
|
||||||
isDeterred,
|
isDeterred: deterrenceFlow.isDeterred,
|
||||||
reevaluateSocketVerification,
|
reevaluateSocketVerification,
|
||||||
reevaluateSocketDeterrence,
|
reevaluateSocketDeterrence,
|
||||||
verificationEvents,
|
verificationEvents,
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
// Verification Request Flow Module
|
||||||
|
// Purpose: Handles verification request creation, mapping, and approve/deny lifecycle transitions.
|
||||||
|
// Scope: Owns pending request records and the side effects that update connected sockets and event streams.
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
function createRequestFlow(deps) {
|
||||||
|
const {
|
||||||
|
loadStore,
|
||||||
|
withStore,
|
||||||
|
io,
|
||||||
|
publishEvent,
|
||||||
|
emitChange,
|
||||||
|
ensureSocketData,
|
||||||
|
identityFromSocket,
|
||||||
|
isValidCookieUserId,
|
||||||
|
normalizeCookieUserId,
|
||||||
|
reevaluateSocketVerification,
|
||||||
|
reevaluateSocketDeterrence,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
function getPendingRequestForIdentity(cookieUserId) {
|
||||||
|
const key = normalizeCookieUserId(cookieUserId);
|
||||||
|
if (!key) return null;
|
||||||
|
const store = loadStore();
|
||||||
|
return store.pendingRequests.find((entry) => entry.status === 'pending' && entry.cookieUserId === key) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createVerificationRequest(socket) {
|
||||||
|
if (!socket) {
|
||||||
|
throw new Error('Socket required');
|
||||||
|
}
|
||||||
|
const data = ensureSocketData(socket);
|
||||||
|
const { cookieUserId, nickname, ip } = identityFromSocket(socket);
|
||||||
|
|
||||||
|
if (data.isVerified) {
|
||||||
|
throw new Error('You are already verified.');
|
||||||
|
}
|
||||||
|
if (!cookieUserId) {
|
||||||
|
throw new Error('Identity key missing. Reconnect and try again.');
|
||||||
|
}
|
||||||
|
if (!isValidCookieUserId(cookieUserId)) {
|
||||||
|
throw new Error('Identity key format invalid.');
|
||||||
|
}
|
||||||
|
if (!nickname) {
|
||||||
|
throw new Error('Nickname required before requesting verification.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPending = getPendingRequestForIdentity(cookieUserId);
|
||||||
|
if (existingPending) {
|
||||||
|
return existingPending;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
id: `vr_${crypto.randomBytes(8).toString('hex')}`,
|
||||||
|
status: 'pending',
|
||||||
|
cookieUserId,
|
||||||
|
nickname,
|
||||||
|
ip,
|
||||||
|
socketId: socket.id,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
resolvedAt: null,
|
||||||
|
resolvedBy: null,
|
||||||
|
decision: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
withStore((draft) => {
|
||||||
|
draft.pendingRequests.push(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
publishEvent({ source: 'verification', type: 'verification.requested', payload: request });
|
||||||
|
emitChange('request', { requestId: request.id, socketId: socket.id });
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachDmMessage(requestId, messageId, adminDiscordId) {
|
||||||
|
if (!requestId || !messageId) return;
|
||||||
|
withStore((draft) => {
|
||||||
|
const exists = draft.dmMessages.find((entry) => entry.messageId === messageId);
|
||||||
|
if (exists) return;
|
||||||
|
draft.dmMessages.push({
|
||||||
|
requestId,
|
||||||
|
messageId,
|
||||||
|
adminDiscordId: adminDiscordId ? String(adminDiscordId) : null,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPendingRequestById(requestId) {
|
||||||
|
if (!requestId) return null;
|
||||||
|
const store = loadStore();
|
||||||
|
return store.pendingRequests.find((entry) => entry.id === requestId && entry.status === 'pending') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestByMessageId(messageId) {
|
||||||
|
if (!messageId) return null;
|
||||||
|
const store = loadStore();
|
||||||
|
const map = store.dmMessages.find((entry) => entry.messageId === messageId);
|
||||||
|
if (!map) return null;
|
||||||
|
const request = store.pendingRequests.find((entry) => entry.id === map.requestId) || null;
|
||||||
|
return request ? { request, map } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function approveRequest(requestId, actorDiscordId) {
|
||||||
|
const request = getPendingRequestById(requestId);
|
||||||
|
if (!request) {
|
||||||
|
throw new Error('Request not found or already resolved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvedAt = Date.now();
|
||||||
|
const actor = actorDiscordId ? String(actorDiscordId) : null;
|
||||||
|
|
||||||
|
withStore((draft) => {
|
||||||
|
const pending = draft.pendingRequests.find((entry) => entry.id === requestId);
|
||||||
|
if (!pending || pending.status !== 'pending') {
|
||||||
|
throw new Error('Request not found or already resolved.');
|
||||||
|
}
|
||||||
|
pending.status = 'approved';
|
||||||
|
pending.decision = 'approved';
|
||||||
|
pending.resolvedAt = approvedAt;
|
||||||
|
pending.resolvedBy = actor;
|
||||||
|
|
||||||
|
let target =
|
||||||
|
draft.verifiedUsers.find((entry) => entry.cookieUserId === pending.cookieUserId) ||
|
||||||
|
draft.verifiedUsers.find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(pending.ip));
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
target = {
|
||||||
|
id: `vu_${crypto.randomBytes(8).toString('hex')}`,
|
||||||
|
cookieUserId: pending.cookieUserId,
|
||||||
|
nickname: pending.nickname,
|
||||||
|
knownIps: pending.ip ? [pending.ip] : [],
|
||||||
|
createdAt: approvedAt,
|
||||||
|
updatedAt: approvedAt,
|
||||||
|
approvedBy: actor,
|
||||||
|
};
|
||||||
|
draft.verifiedUsers.push(target);
|
||||||
|
} else {
|
||||||
|
target.cookieUserId = pending.cookieUserId;
|
||||||
|
target.nickname = pending.nickname;
|
||||||
|
if (pending.ip && !target.knownIps.includes(pending.ip)) {
|
||||||
|
target.knownIps.push(pending.ip);
|
||||||
|
}
|
||||||
|
target.updatedAt = approvedAt;
|
||||||
|
target.approvedBy = actor;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
io.sockets.sockets.forEach((socket) => {
|
||||||
|
const data = ensureSocketData(socket);
|
||||||
|
if (normalizeCookieUserId(data.cookieUserId) === request.cookieUserId) {
|
||||||
|
reevaluateSocketVerification(socket);
|
||||||
|
reevaluateSocketDeterrence(socket);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
publishEvent({
|
||||||
|
source: 'verification',
|
||||||
|
type: 'verification.resolved',
|
||||||
|
payload: {
|
||||||
|
requestId,
|
||||||
|
decision: 'approved',
|
||||||
|
cookieUserId: request.cookieUserId,
|
||||||
|
nickname: request.nickname,
|
||||||
|
resolvedBy: actor,
|
||||||
|
resolvedAt: approvedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emitChange('approve', { requestId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function denyRequest(requestId, actorDiscordId) {
|
||||||
|
const request = getPendingRequestById(requestId);
|
||||||
|
if (!request) {
|
||||||
|
throw new Error('Request not found or already resolved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const deniedAt = Date.now();
|
||||||
|
const actor = actorDiscordId ? String(actorDiscordId) : null;
|
||||||
|
|
||||||
|
withStore((draft) => {
|
||||||
|
const pending = draft.pendingRequests.find((entry) => entry.id === requestId);
|
||||||
|
if (!pending || pending.status !== 'pending') {
|
||||||
|
throw new Error('Request not found or already resolved.');
|
||||||
|
}
|
||||||
|
pending.status = 'denied';
|
||||||
|
pending.decision = 'denied';
|
||||||
|
pending.resolvedAt = deniedAt;
|
||||||
|
pending.resolvedBy = actor;
|
||||||
|
});
|
||||||
|
|
||||||
|
publishEvent({
|
||||||
|
source: 'verification',
|
||||||
|
type: 'verification.resolved',
|
||||||
|
payload: {
|
||||||
|
requestId,
|
||||||
|
decision: 'denied',
|
||||||
|
cookieUserId: request.cookieUserId,
|
||||||
|
nickname: request.nickname,
|
||||||
|
resolvedBy: actor,
|
||||||
|
resolvedAt: deniedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emitChange('deny', { requestId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getPendingRequestForIdentity,
|
||||||
|
createVerificationRequest,
|
||||||
|
attachDmMessage,
|
||||||
|
getRequestByMessageId,
|
||||||
|
approveRequest,
|
||||||
|
denyRequest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createRequestFlow,
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Verification Store Module
|
||||||
|
// Purpose: Owns persisted verification-service store loading, normalization, and immutable cloning.
|
||||||
|
// Scope: Provides a stable load/write API so higher-level verification and moderation flows stay focused.
|
||||||
|
const { resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
|
const { createJsonStore } = require('../identityService');
|
||||||
|
const logger = require('../../globals/logger').child('verificationService');
|
||||||
|
|
||||||
|
const STORE_PATH = resolveDataPath('verified-users.json');
|
||||||
|
|
||||||
|
function normalizeStoreShape(store) {
|
||||||
|
const next = store && typeof store === 'object' ? store : {};
|
||||||
|
return {
|
||||||
|
verifiedUsers: Array.isArray(next.verifiedUsers) ? next.verifiedUsers : [],
|
||||||
|
pendingRequests: Array.isArray(next.pendingRequests) ? next.pendingRequests : [],
|
||||||
|
dmMessages: Array.isArray(next.dmMessages) ? next.dmMessages : [],
|
||||||
|
deterredUsers: Array.isArray(next.deterredUsers) ? next.deterredUsers : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneStore(current) {
|
||||||
|
return {
|
||||||
|
verifiedUsers: (current.verifiedUsers || []).map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] })),
|
||||||
|
pendingRequests: (current.pendingRequests || []).map((entry) => ({ ...entry })),
|
||||||
|
dmMessages: (current.dmMessages || []).map((entry) => ({ ...entry })),
|
||||||
|
deterredUsers: (current.deterredUsers || []).map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeApi = createJsonStore({
|
||||||
|
path: STORE_PATH,
|
||||||
|
normalizeStoreShape,
|
||||||
|
cloneStore,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadStore: storeApi.loadStore,
|
||||||
|
withStore: storeApi.withStore,
|
||||||
|
};
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// Verification Flow Module
|
||||||
|
// Purpose: Implements verified-user matching, socket verification state reevaluation, and verified roster operations.
|
||||||
|
// Scope: Owns all behavior related to who is considered verified and how verified records are listed or removed.
|
||||||
|
function createVerificationFlow(deps) {
|
||||||
|
const {
|
||||||
|
loadStore,
|
||||||
|
withStore,
|
||||||
|
io,
|
||||||
|
publishEvent,
|
||||||
|
emitChange,
|
||||||
|
getRole,
|
||||||
|
ensureSocketData,
|
||||||
|
identityFromSocket,
|
||||||
|
normalizeCookieUserId,
|
||||||
|
sanitizeNickname,
|
||||||
|
getNickname,
|
||||||
|
reevaluateSocketDeterrence,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
function findVerifiedMatch(store, { cookieUserId, ip }) {
|
||||||
|
if (!cookieUserId && !ip) return null;
|
||||||
|
const byCookie = cookieUserId
|
||||||
|
? store.verifiedUsers.find((entry) => normalizeCookieUserId(entry.cookieUserId) === cookieUserId) || null
|
||||||
|
: null;
|
||||||
|
if (byCookie) return byCookie;
|
||||||
|
if (!ip) return null;
|
||||||
|
return store.verifiedUsers.find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(ip)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reevaluateSocketVerification(socket) {
|
||||||
|
if (!socket) return { isVerified: false, matchedRecordId: null, reason: 'missing_socket' };
|
||||||
|
const store = loadStore();
|
||||||
|
const data = ensureSocketData(socket);
|
||||||
|
const role = getRole(socket);
|
||||||
|
const { cookieUserId, nickname, ip } = identityFromSocket(socket);
|
||||||
|
|
||||||
|
if (role === 'lockdown') {
|
||||||
|
data.isVerified = true;
|
||||||
|
data.verifiedRecordId = null;
|
||||||
|
return {
|
||||||
|
isVerified: true,
|
||||||
|
matchedRecordId: null,
|
||||||
|
reason: 'lockdown_admin',
|
||||||
|
cookieUserId,
|
||||||
|
nickname,
|
||||||
|
ip,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = findVerifiedMatch(store, { cookieUserId, ip });
|
||||||
|
const nicknameMatches = Boolean(match && nickname && sanitizeNickname(match.nickname) === nickname);
|
||||||
|
|
||||||
|
let isVerified = false;
|
||||||
|
let reason = 'no_match';
|
||||||
|
if (match && nicknameMatches) {
|
||||||
|
isVerified = true;
|
||||||
|
reason = 'matched';
|
||||||
|
} else if (match && !nicknameMatches) {
|
||||||
|
reason = 'nickname_mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
data.isVerified = isVerified;
|
||||||
|
data.verifiedRecordId = isVerified ? match.id : null;
|
||||||
|
|
||||||
|
if (isVerified) {
|
||||||
|
withStore((draft) => {
|
||||||
|
const record = draft.verifiedUsers.find((entry) => entry.id === match.id);
|
||||||
|
if (!record) return;
|
||||||
|
record.updatedAt = Date.now();
|
||||||
|
record.nickname = nickname;
|
||||||
|
if (ip && !record.knownIps.includes(ip)) {
|
||||||
|
record.knownIps.push(ip);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isVerified,
|
||||||
|
matchedRecordId: isVerified ? match.id : null,
|
||||||
|
reason,
|
||||||
|
cookieUserId,
|
||||||
|
nickname,
|
||||||
|
ip,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVerificationStatus(socket) {
|
||||||
|
const data = socket?.data || {};
|
||||||
|
return {
|
||||||
|
isVerified: Boolean(data.isVerified),
|
||||||
|
recordId: data.verifiedRecordId || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIdentitySummary(socket) {
|
||||||
|
const data = socket?.data || {};
|
||||||
|
return {
|
||||||
|
cookieUserId: normalizeCookieUserId(data.cookieUserId) || null,
|
||||||
|
nickname: getNickname(socket) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listVerifiedUsers() {
|
||||||
|
const store = loadStore();
|
||||||
|
return store.verifiedUsers.map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVerifiedUserSelector(selector) {
|
||||||
|
const value = String(selector || '').trim();
|
||||||
|
if (!value) return { error: 'selector_required' };
|
||||||
|
const store = loadStore();
|
||||||
|
const byCookie = store.verifiedUsers.find((entry) => entry.cookieUserId === value) || null;
|
||||||
|
if (byCookie) return { record: byCookie };
|
||||||
|
const byNickname = store.verifiedUsers.filter((entry) => sanitizeNickname(entry.nickname) === sanitizeNickname(value));
|
||||||
|
if (byNickname.length === 1) return { record: byNickname[0] };
|
||||||
|
if (byNickname.length > 1) return { error: 'ambiguous_nickname' };
|
||||||
|
return { error: 'not_found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeVerifiedUser(selector, removedBy = null) {
|
||||||
|
const resolved = resolveVerifiedUserSelector(selector);
|
||||||
|
if (resolved.error) {
|
||||||
|
throw new Error(
|
||||||
|
resolved.error === 'ambiguous_nickname'
|
||||||
|
? 'Nickname matches multiple users; remove by cookieUserId.'
|
||||||
|
: 'Verified user not found.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const target = resolved.record;
|
||||||
|
let removed = null;
|
||||||
|
withStore((draft) => {
|
||||||
|
const before = draft.verifiedUsers.length;
|
||||||
|
draft.verifiedUsers = draft.verifiedUsers.filter((entry) => entry.id !== target.id);
|
||||||
|
if (draft.verifiedUsers.length !== before) {
|
||||||
|
removed = target;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!removed) {
|
||||||
|
throw new Error('Verified user not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
io.sockets.sockets.forEach((socket) => {
|
||||||
|
const data = ensureSocketData(socket);
|
||||||
|
if (normalizeCookieUserId(data.cookieUserId) === removed.cookieUserId) {
|
||||||
|
reevaluateSocketVerification(socket);
|
||||||
|
reevaluateSocketDeterrence(socket);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
emitChange('remove', { cookieUserId: removed.cookieUserId });
|
||||||
|
publishEvent({
|
||||||
|
source: 'verification',
|
||||||
|
type: 'verification.userRemoved',
|
||||||
|
payload: {
|
||||||
|
cookieUserId: removed.cookieUserId,
|
||||||
|
nickname: removed.nickname,
|
||||||
|
removedBy,
|
||||||
|
removedAt: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVerificationStateForSocket(socket, getPendingRequestForIdentity) {
|
||||||
|
const identity = getIdentitySummary(socket);
|
||||||
|
const pending = getPendingRequestForIdentity(identity.cookieUserId);
|
||||||
|
return {
|
||||||
|
isVerified: Boolean(socket?.data?.isVerified),
|
||||||
|
pendingRequestId: pending?.id || null,
|
||||||
|
pendingRequestedAt: pending?.createdAt || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVerified(socket) {
|
||||||
|
return Boolean(socket?.data?.isVerified);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
findVerifiedMatch,
|
||||||
|
reevaluateSocketVerification,
|
||||||
|
getVerificationStatus,
|
||||||
|
getIdentitySummary,
|
||||||
|
listVerifiedUsers,
|
||||||
|
resolveVerifiedUserSelector,
|
||||||
|
removeVerifiedUser,
|
||||||
|
getVerificationStateForSocket,
|
||||||
|
isVerified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createVerificationFlow,
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Video Auth HTTP Route
|
||||||
|
// Purpose: Wires the MediaMTX auth endpoint to session validation, audit logging, and policy checks.
|
||||||
|
// Scope: Handles request transport and token/session lookup while delegating stream parsing and auth decisions.
|
||||||
|
function registerVideoAuthRoute(deps) {
|
||||||
|
const {
|
||||||
|
app,
|
||||||
|
io,
|
||||||
|
logger,
|
||||||
|
videoSessions,
|
||||||
|
getRequestIp,
|
||||||
|
logAdminEvent,
|
||||||
|
extractStreamInfoFromBody,
|
||||||
|
canAccessStream,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
app.post('/mediamtx/auth', (req, res) => {
|
||||||
|
const body = req.body || {};
|
||||||
|
const path = (body.path || '').replace(/^\//, '');
|
||||||
|
const sessionId = body.user;
|
||||||
|
const action = (body.action || '').toLowerCase();
|
||||||
|
const protocol = (body.protocol || '').toLowerCase();
|
||||||
|
const ip = getRequestIp(req, body.ip);
|
||||||
|
const streamInfo = extractStreamInfoFromBody(body);
|
||||||
|
|
||||||
|
logger.info('video auth request', { path: body.path, sessionId, stream: streamInfo, action, protocol });
|
||||||
|
if (ip) {
|
||||||
|
logAdminEvent({
|
||||||
|
label: 'mediamtx',
|
||||||
|
message: 'Media auth request',
|
||||||
|
ip,
|
||||||
|
meta: { path: body.path, sessionId, stream: streamInfo, action, protocol },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSrtLikeProtocol = protocol === 'srt' || protocol === 'srtconn' || protocol.startsWith('srt');
|
||||||
|
const isForwardAudioRead = action === 'read' && streamInfo?.id?.endsWith('-fwd');
|
||||||
|
if ((action === 'read' && isSrtLikeProtocol) || isForwardAudioRead) {
|
||||||
|
return res.status(200).end();
|
||||||
|
}
|
||||||
|
if (action === 'publish' && isSrtLikeProtocol) {
|
||||||
|
return res.status(200).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionId || !streamInfo?.id) {
|
||||||
|
logger.warn('auth missing session or stream (session=%s path=%s)', sessionId, path);
|
||||||
|
return res.status(401).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = videoSessions.getSession(sessionId);
|
||||||
|
const streamTypeMatches =
|
||||||
|
info &&
|
||||||
|
(info.sourceType === streamInfo.type || (info.sourceType === 'roverMic' && streamInfo.type === 'rover'));
|
||||||
|
if (!info || !streamTypeMatches || info.sourceId !== streamInfo.id) {
|
||||||
|
logger.warn('invalid session %s for stream %s:%s', sessionId, streamInfo.type, streamInfo.id);
|
||||||
|
return res.status(401).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = io.sockets.sockets.get(info.socketId);
|
||||||
|
if (!socket) {
|
||||||
|
videoSessions.revokeSession(sessionId);
|
||||||
|
return res.status(401).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canAccessStream({ socket, streamInfo, action, sourceType: info.sourceType })) {
|
||||||
|
return res.status(401).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
registerVideoAuthRoute,
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// video Auth Service
|
// Video Auth Service Module
|
||||||
// Purpose: Defines the video Auth Service module and the helpers/state used by this service unit.
|
// Purpose: Composes MediaMTX stream parsing, authorization policy, and HTTP route registration.
|
||||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
// Scope: Exposes the video-auth service boundary while keeping runtime behavior unchanged.
|
||||||
const { app } = require('../../globals/http');
|
const { app } = require('../../globals/http');
|
||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const logger = require('../../globals/logger').child('videoAuth');
|
const logger = require('../../globals/logger').child('videoAuth');
|
||||||
@@ -10,195 +10,35 @@ const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
|
|||||||
const { isVerified } = require('../verificationService');
|
const { isVerified } = require('../verificationService');
|
||||||
const turnService = require('../turnService');
|
const turnService = require('../turnService');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
|
||||||
const { getRequestIp, getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
const { getRequestIp, getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
||||||
const { logAdminEvent } = require('../adminLogService');
|
const { logAdminEvent } = require('../adminLogService');
|
||||||
|
|
||||||
const config = loadConfig();
|
const { extractStreamInfoFromBody } = require('./streamParsing');
|
||||||
const mediaConfig = config.media || {};
|
const { createVideoAuthPolicy } = require('./policy');
|
||||||
|
const { registerVideoAuthRoute } = require('./httpRoute');
|
||||||
|
|
||||||
function getPathPrefix() {
|
const { canAccessStream } = createVideoAuthPolicy({
|
||||||
const base = mediaConfig.whepBaseUrl;
|
getMode,
|
||||||
if (!base) return '';
|
MODES,
|
||||||
try {
|
isAdmin,
|
||||||
const parsed = new URL(base);
|
isLockdownAdmin,
|
||||||
return parsed.pathname || '';
|
getRole,
|
||||||
} catch {
|
isVerified,
|
||||||
return base.replace(/^[^/]*:\/\//, '').replace(/^[^/]+/, '');
|
turnService,
|
||||||
}
|
roverManager,
|
||||||
}
|
getSocketIp,
|
||||||
|
isLocalNetwork,
|
||||||
const whepPathPrefix = getPathPrefix().replace(/\/+$/, '').replace(/^\/+/, '');
|
|
||||||
const whepPrefixSegments = whepPathPrefix ? whepPathPrefix.split('/').filter(Boolean) : [];
|
|
||||||
|
|
||||||
function extractStreamInfo(path) {
|
|
||||||
const segments = (path || '').split('/').filter(Boolean);
|
|
||||||
if (!segments.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let start = 0;
|
|
||||||
if (
|
|
||||||
whepPrefixSegments.length &&
|
|
||||||
whepPrefixSegments.every((segment, idx) => segments[idx] === segment)
|
|
||||||
) {
|
|
||||||
start = whepPrefixSegments.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
let end = segments.length;
|
|
||||||
if (segments[end - 1] === 'whep' || segments[end - 1] === 'whip') {
|
|
||||||
end -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = segments.slice(start, end);
|
|
||||||
if (remaining.length === 1) {
|
|
||||||
const rawId = remaining[0] || '';
|
|
||||||
if (rawId.endsWith('-fwd')) {
|
|
||||||
return { type: 'rover', id: rawId, baseId: rawId.slice(0, -4) };
|
|
||||||
}
|
|
||||||
const baseId = rawId.endsWith('-audio') ? rawId.slice(0, -6) : rawId;
|
|
||||||
return { type: 'rover', id: rawId, baseId };
|
|
||||||
}
|
|
||||||
if (remaining.length === 2 && remaining[0] === 'room') {
|
|
||||||
return { type: 'room', id: remaining[1] || '' };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractSrtStreamId(rawValue) {
|
|
||||||
const value = decodeURIComponent(String(rawValue || '').trim());
|
|
||||||
if (!value) return '';
|
|
||||||
|
|
||||||
// streamid may be passed as the full value or as query text.
|
|
||||||
const match = value.match(/(?:^|[?&]|,|#!::)r=([^,&]+)/);
|
|
||||||
if (match?.[1]) {
|
|
||||||
return match[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: treat plain token as stream id when no separators are present.
|
|
||||||
if (!/[?&=,:]/.test(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractStreamInfoFromBody(body = {}) {
|
|
||||||
const fromPath = extractStreamInfo((body.path || '').replace(/^\//, ''));
|
|
||||||
if (fromPath) return fromPath;
|
|
||||||
|
|
||||||
const srtId =
|
|
||||||
extractSrtStreamId(body.streamid) ||
|
|
||||||
extractSrtStreamId(body.streamId) ||
|
|
||||||
extractSrtStreamId(body.query);
|
|
||||||
if (!srtId) return null;
|
|
||||||
|
|
||||||
if (srtId.endsWith('-fwd')) {
|
|
||||||
return { type: 'rover', id: srtId, baseId: srtId.slice(0, -4) };
|
|
||||||
}
|
|
||||||
const baseId = srtId.endsWith('-audio') ? srtId.slice(0, -6) : srtId;
|
|
||||||
return { type: 'rover', id: srtId, baseId };
|
|
||||||
}
|
|
||||||
|
|
||||||
function canView(socket) {
|
|
||||||
const mode = getMode();
|
|
||||||
if (!socket) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (mode === MODES.LOCKDOWN) {
|
|
||||||
return isLockdownAdmin(socket);
|
|
||||||
}
|
|
||||||
if (mode === MODES.ADMIN) {
|
|
||||||
const role = getRole(socket);
|
|
||||||
return role === 'spectator' || isAdmin(socket);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
app.post('/mediamtx/auth', (req, res) => {
|
|
||||||
const body = req.body || {};
|
|
||||||
const path = (body.path || '').replace(/^\//, '');
|
|
||||||
const sessionId = body.user;
|
|
||||||
const action = (body.action || '').toLowerCase();
|
|
||||||
const protocol = (body.protocol || '').toLowerCase();
|
|
||||||
const ip = getRequestIp(req, body.ip);
|
|
||||||
const streamInfo = extractStreamInfoFromBody(body);
|
|
||||||
|
|
||||||
logger.info('video auth request', { path: body.path, sessionId, stream: streamInfo, action, protocol });
|
|
||||||
if (ip) {
|
|
||||||
logAdminEvent({
|
|
||||||
label: 'mediamtx',
|
|
||||||
message: 'Media auth request',
|
|
||||||
ip,
|
|
||||||
meta: { path: body.path, sessionId, stream: streamInfo, action, protocol },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSrtLikeProtocol = protocol === 'srt' || protocol === 'srtconn' || protocol.startsWith('srt');
|
|
||||||
const isForwardAudioRead = action === 'read' && streamInfo?.id?.endsWith('-fwd');
|
|
||||||
// Rover forward-listener uses SRT read without session tokens; allow these reads.
|
|
||||||
if ((action === 'read' && isSrtLikeProtocol) || isForwardAudioRead) {
|
|
||||||
return res.status(200).end();
|
|
||||||
}
|
|
||||||
// Existing rover/media publishers use SRT without per-session tokens.
|
|
||||||
if (action === 'publish' && isSrtLikeProtocol) {
|
|
||||||
return res.status(200).end();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sessionId || !streamInfo?.id) {
|
|
||||||
logger.warn('auth missing session or stream (session=%s path=%s)', sessionId, path);
|
|
||||||
return res.status(401).end();
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = videoSessions.getSession(sessionId);
|
|
||||||
const streamTypeMatches =
|
|
||||||
info &&
|
|
||||||
(info.sourceType === streamInfo.type || (info.sourceType === 'roverMic' && streamInfo.type === 'rover'));
|
|
||||||
if (!info || !streamTypeMatches || info.sourceId !== streamInfo.id) {
|
|
||||||
logger.warn('invalid session %s for stream %s:%s', sessionId, streamInfo.type, streamInfo.id);
|
|
||||||
return res.status(401).end();
|
|
||||||
}
|
|
||||||
const socket = io.sockets.sockets.get(info.socketId);
|
|
||||||
if (!socket) {
|
|
||||||
videoSessions.revokeSession(sessionId);
|
|
||||||
return res.status(401).end();
|
|
||||||
}
|
|
||||||
if (!canView(socket)) {
|
|
||||||
return res.status(401).end();
|
|
||||||
}
|
|
||||||
if (streamInfo.type === 'rover') {
|
|
||||||
const roverId = streamInfo.baseId || streamInfo.id;
|
|
||||||
if (!roverManager.canSeeRover(roverId, socket)) {
|
|
||||||
return res.status(401).end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (info.sourceType === 'roverMic' && action === 'publish') {
|
|
||||||
const roverId = streamInfo.baseId || streamInfo.id;
|
|
||||||
if (!isVerified(socket)) {
|
|
||||||
return res.status(401).end();
|
|
||||||
}
|
|
||||||
if (!roverManager.isDriver(roverId, socket)) {
|
|
||||||
return res.status(401).end();
|
|
||||||
}
|
|
||||||
if (!turnService.canDrive(roverId, socket)) {
|
|
||||||
return res.status(401).end();
|
|
||||||
}
|
|
||||||
return res.status(200).end();
|
|
||||||
}
|
|
||||||
|
|
||||||
const role = getRole(socket);
|
|
||||||
const isAudio = streamInfo.id?.endsWith('-audio');
|
|
||||||
if (role === 'spectator' && !isAdmin(socket) && !isAudio) {
|
|
||||||
const socketIp = getSocketIp(socket);
|
|
||||||
if (!isLocalNetwork(socketIp)) {
|
|
||||||
return res.status(401).end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (streamInfo.type === 'rover' && role !== 'spectator' && !isAdmin(socket)) {
|
|
||||||
const roverId = streamInfo.baseId || streamInfo.id;
|
|
||||||
if (!roverManager.isDriver(roverId, socket)) {
|
|
||||||
return res.status(401).end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).end();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registerVideoAuthRoute({
|
||||||
|
app,
|
||||||
|
io,
|
||||||
|
logger,
|
||||||
|
videoSessions,
|
||||||
|
getRequestIp,
|
||||||
|
logAdminEvent,
|
||||||
|
extractStreamInfoFromBody,
|
||||||
|
canAccessStream,
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {};
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Video Auth Policy
|
||||||
|
// Purpose: Encapsulates mode, role, and stream-specific authorization decisions for MediaMTX auth checks.
|
||||||
|
// Scope: Evaluates viewer/publisher eligibility from normalized request context and socket/session state.
|
||||||
|
function createVideoAuthPolicy(deps) {
|
||||||
|
const {
|
||||||
|
getMode,
|
||||||
|
MODES,
|
||||||
|
isAdmin,
|
||||||
|
isLockdownAdmin,
|
||||||
|
getRole,
|
||||||
|
isVerified,
|
||||||
|
turnService,
|
||||||
|
roverManager,
|
||||||
|
getSocketIp,
|
||||||
|
isLocalNetwork,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
function canView(socket) {
|
||||||
|
const mode = getMode();
|
||||||
|
if (!socket) return false;
|
||||||
|
if (mode === MODES.LOCKDOWN) {
|
||||||
|
return isLockdownAdmin(socket);
|
||||||
|
}
|
||||||
|
if (mode === MODES.ADMIN) {
|
||||||
|
const role = getRole(socket);
|
||||||
|
return role === 'spectator' || isAdmin(socket);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAccessStream({ socket, streamInfo, action, sourceType }) {
|
||||||
|
if (!canView(socket)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamInfo.type === 'rover') {
|
||||||
|
const roverId = streamInfo.baseId || streamInfo.id;
|
||||||
|
if (!roverManager.canSeeRover(roverId, socket)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceType === 'roverMic' && action === 'publish') {
|
||||||
|
const roverId = streamInfo.baseId || streamInfo.id;
|
||||||
|
if (!isVerified(socket)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!roverManager.isDriver(roverId, socket)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!turnService.canDrive(roverId, socket)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = getRole(socket);
|
||||||
|
const isAudio = streamInfo.id?.endsWith('-audio');
|
||||||
|
if (role === 'spectator' && !isAdmin(socket) && !isAudio) {
|
||||||
|
const socketIp = getSocketIp(socket);
|
||||||
|
if (!isLocalNetwork(socketIp)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamInfo.type === 'rover' && role !== 'spectator' && !isAdmin(socket)) {
|
||||||
|
const roverId = streamInfo.baseId || streamInfo.id;
|
||||||
|
if (!roverManager.isDriver(roverId, socket)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
canAccessStream,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createVideoAuthPolicy,
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Video Auth Stream Parsing
|
||||||
|
// Purpose: Parses MediaMTX path/body payloads into normalized stream targets for rover and room media checks.
|
||||||
|
// Scope: Handles WHEP/WHP path-prefix trimming and SRT streamid extraction without performing auth decisions.
|
||||||
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const mediaConfig = config.media || {};
|
||||||
|
|
||||||
|
function getPathPrefix() {
|
||||||
|
const base = mediaConfig.whepBaseUrl;
|
||||||
|
if (!base) return '';
|
||||||
|
try {
|
||||||
|
const parsed = new URL(base);
|
||||||
|
return parsed.pathname || '';
|
||||||
|
} catch {
|
||||||
|
return base.replace(/^[^/]*:\/\//, '').replace(/^[^/]+/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const whepPathPrefix = getPathPrefix().replace(/\/+$/, '').replace(/^\/+/, '');
|
||||||
|
const whepPrefixSegments = whepPathPrefix ? whepPathPrefix.split('/').filter(Boolean) : [];
|
||||||
|
|
||||||
|
function extractStreamInfo(path) {
|
||||||
|
const segments = (path || '').split('/').filter(Boolean);
|
||||||
|
if (!segments.length) return null;
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
if (whepPrefixSegments.length && whepPrefixSegments.every((segment, idx) => segments[idx] === segment)) {
|
||||||
|
start = whepPrefixSegments.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
let end = segments.length;
|
||||||
|
if (segments[end - 1] === 'whep' || segments[end - 1] === 'whip') {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = segments.slice(start, end);
|
||||||
|
if (remaining.length === 1) {
|
||||||
|
const rawId = remaining[0] || '';
|
||||||
|
if (rawId.endsWith('-fwd')) {
|
||||||
|
return { type: 'rover', id: rawId, baseId: rawId.slice(0, -4) };
|
||||||
|
}
|
||||||
|
const baseId = rawId.endsWith('-audio') ? rawId.slice(0, -6) : rawId;
|
||||||
|
return { type: 'rover', id: rawId, baseId };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining.length === 2 && remaining[0] === 'room') {
|
||||||
|
return { type: 'room', id: remaining[1] || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSrtStreamId(rawValue) {
|
||||||
|
const value = decodeURIComponent(String(rawValue || '').trim());
|
||||||
|
if (!value) return '';
|
||||||
|
|
||||||
|
const match = value.match(/(?:^|[?&]|,|#!::)r=([^,&]+)/);
|
||||||
|
if (match?.[1]) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/[?&=,:]/.test(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractStreamInfoFromBody(body = {}) {
|
||||||
|
const fromPath = extractStreamInfo((body.path || '').replace(/^\//, ''));
|
||||||
|
if (fromPath) return fromPath;
|
||||||
|
|
||||||
|
const srtId =
|
||||||
|
extractSrtStreamId(body.streamid) ||
|
||||||
|
extractSrtStreamId(body.streamId) ||
|
||||||
|
extractSrtStreamId(body.query);
|
||||||
|
if (!srtId) return null;
|
||||||
|
|
||||||
|
if (srtId.endsWith('-fwd')) {
|
||||||
|
return { type: 'rover', id: srtId, baseId: srtId.slice(0, -4) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseId = srtId.endsWith('-audio') ? srtId.slice(0, -6) : srtId;
|
||||||
|
return { type: 'rover', id: srtId, baseId };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
extractStreamInfoFromBody,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user