mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
/video signaling proxy and a global public server http path config item
This commit is contained in:
@@ -7,8 +7,10 @@ const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const Database = require('better-sqlite3');
|
||||
const { defaultConfig, normalizeConfig, assertValidConfig } = require('./validation');
|
||||
const { definitions, rootSchema, secretPaths, featureDefinitions } = require('./definition');
|
||||
const { migrations } = require('./migrations');
|
||||
const { getFeatureFlags } = require('./index');
|
||||
const { createConfigurationDatabase } = require('./database');
|
||||
const {
|
||||
@@ -215,14 +217,12 @@ test('generated feature flags use only each declared enabled switch', () => {
|
||||
});
|
||||
|
||||
test('normalization fills missing legacy fields but strict validation rejects unknown fields', () => {
|
||||
const normalized = normalizeConfig({ media: { whepBaseUrl: 'http://localhost:8889/video' } });
|
||||
// Missing fields now receive the same populated template defaults as a new
|
||||
// installation; normalization must not silently revert this one collection
|
||||
// to the former empty-safe-default policy.
|
||||
assert.deepEqual(normalized.media.additionalHosts, ['rover.example.com', 'media-server.local']);
|
||||
const normalized = normalizeConfig({ media: { additionalHosts: [] } });
|
||||
assert.equal(normalized.publicUrl, 'https://rover.example.com');
|
||||
assert.deepEqual(normalized.media.additionalHosts, []);
|
||||
assert.doesNotThrow(() => assertValidConfig(normalized));
|
||||
|
||||
const invalid = normalizeConfig({ media: { whepBaseUrl: 'http://localhost:8889/video', misspelledHost: 'x' } });
|
||||
const invalid = normalizeConfig({ media: { additionalHosts: [], misspelledHost: 'x' } });
|
||||
assert.throws(() => assertValidConfig(invalid), (error) => {
|
||||
assert.equal(error.code, 'CONFIG_VALIDATION_FAILED');
|
||||
assert.ok(error.validationErrors.some((entry) => entry.path.includes('misspelledHost')));
|
||||
@@ -230,6 +230,40 @@ test('normalization fills missing legacy fields but strict validation rejects un
|
||||
});
|
||||
});
|
||||
|
||||
test('database migration consolidates existing public URLs and removes obsolete media addressing', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-public-url-migration-'));
|
||||
temporaryRoots.push(root);
|
||||
const databasePath = path.join(root, 'configuration.sqlite');
|
||||
const legacyDatabase = new Database(databasePath);
|
||||
legacyDatabase.exec(`
|
||||
CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);
|
||||
${migrations[0].sql}
|
||||
`);
|
||||
legacyDatabase.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (1, ?)').run(Date.now());
|
||||
|
||||
const legacyConfig = structuredClone(defaultConfig);
|
||||
delete legacyConfig.publicUrl;
|
||||
legacyConfig.interInstance.profile.publicUrl = 'https://rover.example.com';
|
||||
legacyConfig.discord.enabled = true;
|
||||
legacyConfig.discord.siteUrl = 'https://canonical.example.com';
|
||||
legacyConfig.media.whepBaseUrl = 'http://127.0.0.1:8889/video';
|
||||
const inserted = legacyDatabase.prepare(`
|
||||
INSERT INTO configuration_revisions (config_json, created_at, actor, source)
|
||||
VALUES (?, ?, 'test', 'legacy-shape')
|
||||
`).run(JSON.stringify(legacyConfig), Date.now());
|
||||
legacyDatabase.prepare('INSERT INTO configuration_state (singleton, active_revision_id) VALUES (1, ?)')
|
||||
.run(inserted.lastInsertRowid);
|
||||
legacyDatabase.close();
|
||||
|
||||
const migrated = createConfigurationDatabase({ databasePath });
|
||||
const active = migrated.getActiveConfigurationRecord().config;
|
||||
assert.equal(active.publicUrl, 'https://canonical.example.com');
|
||||
assert.equal(Object.hasOwn(active.interInstance.profile, 'publicUrl'), false);
|
||||
assert.equal(Object.hasOwn(active.discord, 'siteUrl'), false);
|
||||
assert.equal(Object.hasOwn(active.media, 'whepBaseUrl'), false);
|
||||
migrated.close();
|
||||
});
|
||||
|
||||
test('full-document updates preserve secrets and reject a stale browser revision', () => {
|
||||
const database = createTestDatabase();
|
||||
const initial = database.getActiveConfigurationRecord();
|
||||
@@ -291,6 +325,7 @@ admins:
|
||||
password_hash: "$2b$10$preservedHash"
|
||||
discord_id: "1234"
|
||||
lockdown: true
|
||||
publicUrl: https://production.example.com
|
||||
timezone: America/Chicago
|
||||
media:
|
||||
whepBaseUrl: http://localhost:8889/video
|
||||
@@ -320,6 +355,7 @@ fleetReports:
|
||||
immediateCriticalAlerts: true
|
||||
`;
|
||||
const parsed = parseConfigurationFile(yamlText);
|
||||
assert.equal(parsed.config.publicUrl, 'https://production.example.com');
|
||||
assert.equal(parsed.config.timezone, 'America/Chicago');
|
||||
assert.equal(parsed.administrators[0].passwordHash, '$2b$10$preservedHash');
|
||||
assert.equal(Object.hasOwn(parsed.config.overseerControl, 'heartbeatMs'), false);
|
||||
@@ -428,7 +464,7 @@ test('committed revisions replace the live snapshot and isolate service reload f
|
||||
const record = database.getClientConfiguration();
|
||||
const next = structuredClone(record.config);
|
||||
next.timezone = 'America/Chicago';
|
||||
next.media.whepBaseUrl = 'http://localhost:9999/video';
|
||||
next.media.additionalHosts = ['media.example.test'];
|
||||
database.updateConfiguration({
|
||||
value: next,
|
||||
expectedRevision: record.revision,
|
||||
|
||||
@@ -30,6 +30,7 @@ const fleetReports = require('../services/fleetReportService/configuration');
|
||||
those fragments are placed independently at their historical positions.
|
||||
*/
|
||||
const definitions = [
|
||||
sessionConfiguration.publicUrl,
|
||||
sessionConfiguration.timezone,
|
||||
interInstance,
|
||||
llmCommentary,
|
||||
|
||||
@@ -37,6 +37,41 @@ const migrations = [
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
run(db) {
|
||||
const rows = db.prepare('SELECT id, config_json FROM configuration_revisions').all();
|
||||
const update = db.prepare('UPDATE configuration_revisions SET config_json = ? WHERE id = ?');
|
||||
|
||||
rows.forEach((row) => {
|
||||
const config = JSON.parse(row.config_json);
|
||||
/*
|
||||
publicUrl was formerly repeated under inter-instance, Discord, and
|
||||
media settings. Preserve the public identity already selected by the
|
||||
operator: enabled consumers win first, then non-example values, with
|
||||
inter-instance winning an otherwise equal conflict. Remove only the
|
||||
three fields replaced by the root setting and fixed /video proxy.
|
||||
*/
|
||||
const previousInterInstanceUrl = config.interInstance?.profile?.publicUrl;
|
||||
const previousDiscordUrl = config.discord?.siteUrl;
|
||||
const previousCandidates = [
|
||||
config.interInstance?.enabled ? previousInterInstanceUrl : '',
|
||||
config.discord?.enabled ? previousDiscordUrl : '',
|
||||
previousInterInstanceUrl !== 'https://rover.example.com' ? previousInterInstanceUrl : '',
|
||||
previousDiscordUrl !== 'https://rover.example.com' ? previousDiscordUrl : '',
|
||||
previousInterInstanceUrl,
|
||||
previousDiscordUrl,
|
||||
];
|
||||
config.publicUrl = config.publicUrl
|
||||
|| previousCandidates.find((value) => typeof value === 'string' && value.trim())
|
||||
|| 'https://rover.example.com';
|
||||
if (config.interInstance?.profile) delete config.interInstance.profile.publicUrl;
|
||||
if (config.discord) delete config.discord.siteUrl;
|
||||
if (config.media) delete config.media.whepBaseUrl;
|
||||
update.run(JSON.stringify(config), row.id);
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function applySchemaMigrations(db) {
|
||||
@@ -57,7 +92,8 @@ function applySchemaMigrations(db) {
|
||||
changed database whose version incorrectly appears current.
|
||||
*/
|
||||
db.transaction(() => {
|
||||
db.exec(migration.sql);
|
||||
if (migration.sql) db.exec(migration.sql);
|
||||
if (migration.run) migration.run(db);
|
||||
record.run(migration.version, Date.now());
|
||||
})();
|
||||
});
|
||||
|
||||
@@ -4,9 +4,17 @@ const http = require('http');
|
||||
const express = require('express');
|
||||
const morgan = require('morgan');
|
||||
const config = require('./config');
|
||||
const logger = require('./logger').child('mediaMtxProxy');
|
||||
const { PUBLIC_MEDIA_PREFIX, createMediaMtxProxy } = require('../services/mediaMtxService/proxy');
|
||||
|
||||
const app = express();
|
||||
app.use(morgan('dev'));
|
||||
/*
|
||||
Mount signaling before body parsers so SDP offers and trickle-ICE fragments
|
||||
remain untouched streams. Express removes the /video mount prefix while the
|
||||
proxy is active, giving MediaMTX its native /<path>/whep or /<path>/whip URL.
|
||||
*/
|
||||
app.use(PUBLIC_MEDIA_PREFIX, createMediaMtxProxy({ logger }));
|
||||
app.use(express.json());
|
||||
app.use(express.static(config.staticDir, { index: false }));
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ function resolveSiteMetadata(config = loadConfig()) {
|
||||
const interInstance = config?.interInstance;
|
||||
const profile = interInstance?.profile;
|
||||
const profileName = asTrimmedString(profile?.name);
|
||||
const publicUrl = normalizePublicUrl(config?.publicUrl);
|
||||
|
||||
/*
|
||||
A partially filled profile must not unexpectedly rename the site. The
|
||||
@@ -95,7 +96,11 @@ function resolveSiteMetadata(config = loadConfig()) {
|
||||
the coherent default set above.
|
||||
*/
|
||||
if (interInstance?.enabled !== true || !profileName) {
|
||||
return { ...DEFAULT_SITE_METADATA, accentTextColor: getReadableAccentText(DEFAULT_SITE_METADATA.accentColor) };
|
||||
return {
|
||||
...DEFAULT_SITE_METADATA,
|
||||
publicUrl,
|
||||
accentTextColor: getReadableAccentText(DEFAULT_SITE_METADATA.accentColor),
|
||||
};
|
||||
}
|
||||
|
||||
const accentColor = normalizeHexColor(profile.color) || DEFAULT_SITE_METADATA.accentColor;
|
||||
@@ -110,7 +115,7 @@ function resolveSiteMetadata(config = loadConfig()) {
|
||||
BACKGROUND_BLEND_AMOUNT,
|
||||
),
|
||||
accentTextColor: getReadableAccentText(accentColor),
|
||||
publicUrl: normalizePublicUrl(profile.publicUrl),
|
||||
publicUrl,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ let operations;
|
||||
function replaceAudioForwardRuntime(fullConfig) {
|
||||
operations?.stopAllWorkers('configuration-change');
|
||||
const audioForwardConfig = fullConfig.audioForward || {};
|
||||
const mediaConfig = fullConfig.media || {};
|
||||
serviceEnabled = Boolean(audioForwardConfig.enabled);
|
||||
const streamSuffix = typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
|
||||
? audioForwardConfig.streamSuffix.trim()
|
||||
@@ -72,7 +71,6 @@ function replaceAudioForwardRuntime(fullConfig) {
|
||||
roverManager,
|
||||
turnService,
|
||||
streamSuffix,
|
||||
mediaConfig,
|
||||
});
|
||||
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
|
||||
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
|
||||
@@ -102,7 +100,7 @@ function replaceAudioForwardRuntime(fullConfig) {
|
||||
replaceAudioForwardRuntime(loadConfig());
|
||||
|
||||
// Stable delegates keep the one-time socket/event registrations below pointed
|
||||
// at the newest policy and worker engine after either audio or media changes.
|
||||
// at the newest policy and worker engine after audio-forward changes.
|
||||
const delegate = (name) => (...args) => operations[name](...args);
|
||||
const ensureWorker = delegate('ensureWorker');
|
||||
const stopWorker = delegate('stopWorker');
|
||||
@@ -166,9 +164,6 @@ registerChargeCompleteSound({
|
||||
registerConfigurationHandler('audioForward', (_section, _previous, nextConfig) => {
|
||||
replaceAudioForwardRuntime(nextConfig);
|
||||
});
|
||||
registerConfigurationHandler('media', (_section, _previous, nextConfig) => {
|
||||
replaceAudioForwardRuntime(nextConfig);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getAudioForwardState,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// audio Forward Service policy
|
||||
// Purpose: Encapsulates permission checks and media path/url derivation helpers.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating validation and path-construction logic.
|
||||
const { PUBLIC_MEDIA_PREFIX } = require('../mediaMtxService/proxy');
|
||||
|
||||
function createAudioForwardPolicy(deps) {
|
||||
const {
|
||||
isVerified,
|
||||
@@ -8,7 +10,6 @@ function createAudioForwardPolicy(deps) {
|
||||
roverManager,
|
||||
turnService,
|
||||
streamSuffix,
|
||||
mediaConfig,
|
||||
} = deps;
|
||||
|
||||
function ensureVipVerified(socket) {
|
||||
@@ -43,23 +44,8 @@ function createAudioForwardPolicy(deps) {
|
||||
return `${roverId}${streamSuffix}`;
|
||||
}
|
||||
|
||||
function getMediaPrefix() {
|
||||
const base = mediaConfig.whepBaseUrl;
|
||||
if (!base) return '';
|
||||
try {
|
||||
const parsed = new URL(base);
|
||||
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
|
||||
} catch {
|
||||
return String(base).replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
function buildWhipUrl(pathId) {
|
||||
const prefix = getMediaPrefix();
|
||||
if (!prefix) {
|
||||
throw new Error('Server media base URL missing');
|
||||
}
|
||||
return `${prefix}/${encodeURIComponent(pathId)}/whip`;
|
||||
return `${PUBLIC_MEDIA_PREFIX}/${encodeURIComponent(pathId)}/whip`;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -12,7 +12,6 @@ function createPolicy({ verified = true, muted = false, driver = true, canDrive
|
||||
roverManager: { isDriver: () => driver },
|
||||
turnService: { canDrive: () => canDrive },
|
||||
streamSuffix: '-fwd',
|
||||
mediaConfig: {},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,3 +29,8 @@ test('publishes forwarded audio to the local MediaMTX RTSP path', () => {
|
||||
const policy = createPolicy();
|
||||
assert.equal(policy.resolveForwardUrl('rover one'), 'rtsp://127.0.0.1:8554/rover%20one-fwd');
|
||||
});
|
||||
|
||||
test('publishes browser microphone signaling through the same-origin proxy', () => {
|
||||
const policy = createPolicy();
|
||||
assert.equal(policy.buildWhipUrl('rover one-fwd'), '/video/rover%20one-fwd/whip');
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ const MAX_EXTRACTED_BYTES = 200 * 1024 * 1024 * 1024;
|
||||
const MAX_ARCHIVE_ENTRIES = 100000;
|
||||
const RESERVED_DATA_NAMES = new Set(['backup-restore', 'runtime']);
|
||||
const SUPPORTED_DATABASE_SCHEMA_VERSIONS = {
|
||||
configuration: 1,
|
||||
configuration: 2,
|
||||
identity: 4,
|
||||
fleetReports: 0,
|
||||
};
|
||||
|
||||
@@ -203,7 +203,7 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
|
||||
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
|
||||
isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket),
|
||||
discordConfig,
|
||||
siteUrl: String(discordConfig.siteUrl || ''),
|
||||
publicUrl: String(config.publicUrl || ''),
|
||||
config,
|
||||
createReplayTextCommand: createWebReplayTextCommand(socket, sendSystemMessage, replayApi),
|
||||
};
|
||||
|
||||
@@ -33,7 +33,7 @@ function createReplayCommand({
|
||||
getActiveDrivers,
|
||||
getNickname,
|
||||
rovers,
|
||||
discordConfig,
|
||||
config,
|
||||
}) {
|
||||
const sourceResolver = createReplaySourceResolver({
|
||||
rovers,
|
||||
@@ -137,8 +137,8 @@ function createReplayCommand({
|
||||
if (progressMessage?.edit) {
|
||||
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'ready')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
}
|
||||
const siteUrl = String(discordConfig?.siteUrl || '').replace(/\/$/, '');
|
||||
const publicUrl = siteUrl ? `${siteUrl}${media.url}` : media.url;
|
||||
const publicBaseUrl = String(config?.publicUrl || '').replace(/\/$/, '');
|
||||
const publicUrl = publicBaseUrl ? `${publicBaseUrl}${media.url}` : media.url;
|
||||
await progressMessage.reply({ content: `Replay hosted by the rover server: ${publicUrl}`, allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
return;
|
||||
} catch (fallbackError) {
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
// Scope: Builds a concise time embed for common zones and server local zone.
|
||||
const { EmbedBuilder } = require('discord.js');
|
||||
|
||||
function createTimeStatusCommand({ config, discordConfig }) {
|
||||
function createTimeStatusCommand({ config }) {
|
||||
function buildEmbed({ title, description, color, includeSiteUrl = true }) {
|
||||
const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3);
|
||||
const siteUrl = includeSiteUrl && discordConfig.siteUrl ? String(discordConfig.siteUrl) : '';
|
||||
if (description) embed.setDescription(siteUrl ? `${description}\n\n${siteUrl}` : description);
|
||||
else if (siteUrl) embed.setDescription(siteUrl);
|
||||
const publicUrl = includeSiteUrl && config.publicUrl ? String(config.publicUrl) : '';
|
||||
if (description) embed.setDescription(publicUrl ? `${description}\n\n${publicUrl}` : description);
|
||||
else if (publicUrl) embed.setDescription(publicUrl);
|
||||
embed.setTimestamp(new Date());
|
||||
return embed;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ module.exports = {
|
||||
enabled: false,
|
||||
token: '',
|
||||
guildId: '123456789012345678',
|
||||
siteUrl: 'https://rover.example.com',
|
||||
channels: {
|
||||
general: '123456789012345678',
|
||||
announcements: '123456789012345678',
|
||||
@@ -31,7 +30,6 @@ module.exports = {
|
||||
enabled: boolean({ description: 'Logs the Discord bot in and immediately enables commands, chat bridges, replay delivery, and configured announcements.' }),
|
||||
token: string({ title: 'Bot token', description: 'Discord bot token used to log in. The saved value is never returned to the browser.', examples: ['DISCORD_BOT_TOKEN'], writeOnly: true, maxLength: 10000 }),
|
||||
guildId: string({ title: 'Guild id', description: 'Reserved Discord server identifier. The current bot runtime does not restrict commands or events using this value.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||
siteUrl: string({ title: 'Public site URL', description: 'Public base URL appended to announcement embeds and server-hosted replay links.', examples: ['https://rover.example.com'], maxLength: 2048 }),
|
||||
channels: strictObject({
|
||||
general: string({ description: 'Channel ID used by the button-box stalker-role and everyone-ping rewards.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||
announcements: string({ description: 'Channel ID used for public-mode openings, objective changes, and all-rovers-unlocked announcements.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||
@@ -55,7 +53,7 @@ module.exports = {
|
||||
}),
|
||||
}, {
|
||||
title: 'Discord',
|
||||
description: 'Optional Discord bot credentials, public URL, and notification routing.',
|
||||
required: ['enabled', 'token', 'guildId', 'siteUrl', 'channels', 'roles'],
|
||||
description: 'Optional Discord bot credentials and notification routing; public links use the top-level public URL.',
|
||||
required: ['enabled', 'token', 'guildId', 'channels', 'roles'],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -206,8 +206,8 @@ registerPreferredDeliveryProvider({
|
||||
}
|
||||
},
|
||||
async completeFallback({ context, media }) {
|
||||
const siteUrl = String(discordConfig.siteUrl || '').replace(/\/$/, '');
|
||||
const publicUrl = siteUrl ? `${siteUrl}${media.url}` : media.url;
|
||||
const publicBaseUrl = String(config.publicUrl || '').replace(/\/$/, '');
|
||||
const publicUrl = publicBaseUrl ? `${publicBaseUrl}${media.url}` : media.url;
|
||||
if (context?.progressMessage?.reply) {
|
||||
await context.progressMessage.reply({
|
||||
content: `Replay hosted by the rover server: ${publicUrl}`,
|
||||
@@ -434,7 +434,7 @@ function applySharedConfigSection(section, value) {
|
||||
}
|
||||
|
||||
registerConfigurationHandler('discord', applyDiscordConfig);
|
||||
['commands', 'timezone', 'fleetReports'].forEach((section) => {
|
||||
['commands', 'publicUrl', 'timezone', 'fleetReports'].forEach((section) => {
|
||||
registerConfigurationHandler(section, (value) => applySharedConfigSection(section, value));
|
||||
});
|
||||
|
||||
|
||||
@@ -5,15 +5,15 @@ const { EmbedBuilder, AttachmentBuilder } = require('discord.js');
|
||||
const { buildBatteryStatusEmbed, buildBatteryCaption } = require('../batteryEmbeds');
|
||||
|
||||
function createBusEventHandler(deps) {
|
||||
const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel } = deps;
|
||||
const { logger, config, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel } = deps;
|
||||
const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'rover.helpNeeded', 'rover.helpCleared', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']);
|
||||
let skippedFirstModeAnnouncement = false;
|
||||
|
||||
function buildEmbed({ title, description, color, includeSiteUrl = true }) {
|
||||
const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3);
|
||||
const siteUrl = includeSiteUrl && discordConfig.siteUrl ? String(discordConfig.siteUrl) : '';
|
||||
if (description) embed.setDescription(siteUrl ? `${description}\n\n${siteUrl}` : description);
|
||||
else if (siteUrl) embed.setDescription(siteUrl);
|
||||
const publicUrl = includeSiteUrl && config.publicUrl ? String(config.publicUrl) : '';
|
||||
if (description) embed.setDescription(publicUrl ? `${description}\n\n${publicUrl}` : description);
|
||||
else if (publicUrl) embed.setDescription(publicUrl);
|
||||
embed.setTimestamp(new Date());
|
||||
return embed;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const WATCHED_EVENT_TYPES = new Set([
|
||||
function createUserAnnouncements(deps) {
|
||||
const {
|
||||
discordConfig,
|
||||
config,
|
||||
getMode,
|
||||
rovers,
|
||||
roverManager,
|
||||
@@ -27,7 +28,7 @@ function createUserAnnouncements(deps) {
|
||||
// send/render time so announcements do not retain stale channel or site data.
|
||||
const getAnnouncementChannelId = () => discordConfig?.channels?.announcements || null;
|
||||
const getAnnouncementRoleId = () => discordConfig?.roles?.announcementPing || null;
|
||||
const getSiteUrl = () => (discordConfig?.siteUrl ? String(discordConfig.siteUrl) : '');
|
||||
const getPublicUrl = () => (config?.publicUrl ? String(config.publicUrl) : '');
|
||||
|
||||
let previousSnapshot = buildSnapshot();
|
||||
let skippedFirstModeChange = false;
|
||||
@@ -127,11 +128,11 @@ function createUserAnnouncements(deps) {
|
||||
});
|
||||
}
|
||||
|
||||
const siteUrl = getSiteUrl();
|
||||
if (siteUrl) {
|
||||
const publicUrl = getPublicUrl();
|
||||
if (publicUrl) {
|
||||
embed.addFields({
|
||||
name: 'Join',
|
||||
value: siteUrl,
|
||||
value: publicUrl,
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ module.exports = {
|
||||
pollIntervalMs: 30000,
|
||||
requestTimeoutMs: 5000,
|
||||
profile: {
|
||||
publicUrl: 'https://rover.example.com',
|
||||
name: 'Example Rover Server',
|
||||
description: 'A short public description of this rover server.',
|
||||
color: '#38bdf8',
|
||||
@@ -33,10 +32,9 @@ module.exports = {
|
||||
pollIntervalMs: integer({ description: 'Milliseconds between peer-directory refreshes.', minimum: 1000, maximum: 86400000 }),
|
||||
requestTimeoutMs: integer({ description: 'Maximum milliseconds allowed for each directory or peer information request before it is aborted.', minimum: 250, maximum: 120000 }),
|
||||
profile: strictObject({
|
||||
publicUrl: string({ description: 'Public base URL peers and users use to reach this server; it also identifies and filters this instance from directory results.', examples: ['https://rover.example.com'], maxLength: 2048 }),
|
||||
name: string({ description: 'Public instance name advertised to peer servers.', minLength: 1, maxLength: 120 }),
|
||||
description: string({ description: 'Short public summary advertised with this instance.', examples: ['A short public description of this rover server.'], maxLength: 500 }),
|
||||
color: string({ description: 'Six-digit hexadecimal accent color advertised for this instance.', pattern: '^#[0-9a-fA-F]{6}$' }),
|
||||
}, { description: 'Public identity this server publishes through the inter-instance information endpoint.', required: ['publicUrl', 'name', 'description', 'color'] }),
|
||||
}, { description: 'Public identity this server publishes through the inter-instance information endpoint; its address comes from the top-level public URL.', required: ['name', 'description', 'color'] }),
|
||||
}, { title: 'Inter-instance directory', description: 'Controls discovery and public information exchange between independent MultiRover servers.', required: ['enabled', 'directoryUrls', 'pollIntervalMs', 'requestTimeoutMs', 'profile'] }),
|
||||
};
|
||||
|
||||
@@ -59,7 +59,7 @@ function pollIntervalMs() {
|
||||
}
|
||||
|
||||
function ownPublicUrl() {
|
||||
return normalizeBaseUrl(interInstanceConfig.profile?.publicUrl);
|
||||
return normalizeBaseUrl(loadConfig().publicUrl);
|
||||
}
|
||||
|
||||
function ownInstanceId() {
|
||||
@@ -486,6 +486,15 @@ registerConfigurationHandler('interInstance', (nextConfig = {}) => {
|
||||
startPolling();
|
||||
});
|
||||
|
||||
registerConfigurationHandler('publicUrl', () => {
|
||||
/*
|
||||
The canonical URL participates in self-filtering as well as the published
|
||||
profile. Start a fresh generation immediately so results from an in-flight
|
||||
poll using the former identity cannot be committed afterward.
|
||||
*/
|
||||
startPolling();
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getState,
|
||||
interInstanceEvents,
|
||||
|
||||
@@ -20,19 +20,17 @@ function normalizeAdditionalHosts(rawHosts) {
|
||||
|
||||
function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
|
||||
const media = config?.media || {};
|
||||
let additionalHosts = normalizeAdditionalHosts(media.additionalHosts);
|
||||
if (!additionalHosts.length && media.whepBaseUrl) {
|
||||
try {
|
||||
/*
|
||||
Existing installations predate media.additionalHosts. Using the already-configured
|
||||
WHEP hostname as a one-host migration default keeps them reachable on first restart;
|
||||
administrators can still list every public and LAN candidate explicitly afterward.
|
||||
*/
|
||||
additionalHosts = [new URL(media.whepBaseUrl).hostname].filter(Boolean);
|
||||
} catch {
|
||||
throw new Error('media.whepBaseUrl must be a valid URL when media.additionalHosts is empty');
|
||||
}
|
||||
const configuredHosts = normalizeAdditionalHosts(media.additionalHosts);
|
||||
let publicHostname = '';
|
||||
try {
|
||||
publicHostname = new URL(config?.publicUrl).hostname;
|
||||
} catch {
|
||||
// The assembled configuration schema normally prevents this. Keeping the
|
||||
// builder tolerant makes its pure tests and startup error path explicit.
|
||||
}
|
||||
// The canonical public hostname is always advertised once. Operators only
|
||||
// maintain genuinely additional LAN names, aliases, or fixed IP addresses.
|
||||
const additionalHosts = [...new Set([publicHostname, ...configuredHosts].filter(Boolean))];
|
||||
const authPort = Number(serverPort) || 8080;
|
||||
|
||||
return {
|
||||
@@ -56,6 +54,9 @@ function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
|
||||
hls: false,
|
||||
|
||||
webrtc: true,
|
||||
// WHEP and WHIP signaling is public only through the Node /video proxy.
|
||||
// ICE transport on 8189 remains directly reachable by browsers.
|
||||
webrtcAddress: '127.0.0.1:8889',
|
||||
webrtcLocalUDPAddress: ':8189',
|
||||
webrtcLocalTCPAddress: ':8189',
|
||||
webrtcAdditionalHosts: additionalHosts,
|
||||
|
||||
@@ -8,8 +8,8 @@ const { buildMediaMtxConfig, normalizeAdditionalHosts } = require('./config');
|
||||
test('generates RTSP over TCP without deployment-specific hardcodes', () => {
|
||||
const generated = buildMediaMtxConfig({
|
||||
config: {
|
||||
publicUrl: 'https://public.example.com',
|
||||
media: {
|
||||
whepBaseUrl: 'http://media.internal:8889/video',
|
||||
additionalHosts: ['public.example.com', '10.20.30.40'],
|
||||
},
|
||||
},
|
||||
@@ -23,12 +23,13 @@ test('generates RTSP over TCP without deployment-specific hardcodes', () => {
|
||||
assert.equal(Object.hasOwn(generated, 'rtpAddress'), false);
|
||||
assert.equal(Object.hasOwn(generated, 'rtcpAddress'), false);
|
||||
assert.deepEqual(generated.webrtcAdditionalHosts, ['public.example.com', '10.20.30.40']);
|
||||
assert.equal(generated.webrtcAddress, '127.0.0.1:8889');
|
||||
assert.equal(generated.authHTTPAddress, 'http://127.0.0.1:8123/mediamtx/auth');
|
||||
});
|
||||
|
||||
test('uses the configured WHEP hostname while an older config has no additionalHosts', () => {
|
||||
test('derives the primary ICE hostname from the canonical public URL', () => {
|
||||
const generated = buildMediaMtxConfig({
|
||||
config: { media: { whepBaseUrl: 'https://second-server.example/video' } },
|
||||
config: { publicUrl: 'https://second-server.example', media: { additionalHosts: [] } },
|
||||
serverPort: 8080,
|
||||
snapshotWriterPath: '/usr/local/bin/rover-snapshot-writer.sh',
|
||||
});
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
// Media Transport Configuration
|
||||
// Purpose: Defines browser WHEP addressing and additional MediaMTX ICE hosts.
|
||||
// Purpose: Defines additional MediaMTX ICE hosts not already derived from the server's public URL.
|
||||
// Scope: Contains configuration metadata only and never starts MediaMTX.
|
||||
const { strictObject, string, stringArray } = require('../../configuration/schemaHelpers');
|
||||
const { strictObject, stringArray } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'media',
|
||||
defaultValue: {
|
||||
// Signaling remains server-local because the internal `/video` proxy owns
|
||||
// browser access; only ICE transport addresses come from the legacy sample.
|
||||
whepBaseUrl: 'http://127.0.0.1:8889/video',
|
||||
additionalHosts: ['rover.example.com', 'media-server.local'],
|
||||
additionalHosts: [],
|
||||
},
|
||||
schema: strictObject({
|
||||
whepBaseUrl: string({ title: 'WHEP base URL', description: 'Base HTTP URL used to build browser WHEP playback and WHIP audio-publishing endpoints.', format: 'uri', maxLength: 2048 }),
|
||||
additionalHosts: stringArray({
|
||||
title: 'Additional ICE hosts',
|
||||
item: { description: 'Hostname or IP address MediaMTX advertises as a WebRTC ICE candidate.', examples: ['rover.example.com', 'media-server.local'], minLength: 1, maxLength: 255 },
|
||||
array: { description: 'Additional public or LAN hostnames and addresses browsers may use to reach MediaMTX WebRTC transport.', uniqueItems: true },
|
||||
array: { description: 'Extra public or LAN hostnames and addresses browsers may use in addition to the hostname derived from the top-level public URL.', uniqueItems: true },
|
||||
}),
|
||||
}, { title: 'Media', description: 'Controls browser signaling addresses and WebRTC network candidates generated for the managed MediaMTX process.', required: ['whepBaseUrl', 'additionalHosts'] }),
|
||||
}, { title: 'Media', description: 'Adds optional WebRTC network candidates to the public hostname and local interfaces generated automatically for MediaMTX.', required: ['additionalHosts'] }),
|
||||
};
|
||||
|
||||
@@ -26,14 +26,17 @@ function stopSupervisor() {
|
||||
return new Promise((resolve) => supervisor.stop(resolve));
|
||||
}
|
||||
|
||||
registerConfigurationHandler('media', async () => {
|
||||
async function reloadMediaMtx() {
|
||||
// MediaMTX consumes a generated document rather than the Node configuration
|
||||
// object directly. Replace its child process so every media setting is
|
||||
// regenerated and applied as one coherent revision.
|
||||
// object directly. Replace its child process when either explicit media
|
||||
// hosts or the canonical public hostname changes.
|
||||
await stopSupervisor();
|
||||
supervisor = createSupervisor();
|
||||
if (started) supervisor.start();
|
||||
});
|
||||
}
|
||||
|
||||
registerConfigurationHandler('media', reloadMediaMtx);
|
||||
registerConfigurationHandler('publicUrl', reloadMediaMtx);
|
||||
|
||||
/*
|
||||
Other services already use process signal hooks for their own workers. This hook performs
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// MediaMTX HTTP Proxy
|
||||
// Purpose: Exposes WHEP and WHIP signaling beneath the server-owned /video path while MediaMTX remains loopback-only.
|
||||
// Scope: Proxies signaling HTTP only; WebRTC media continues to travel directly through MediaMTX's ICE listener.
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
|
||||
const PUBLIC_MEDIA_PREFIX = '/video';
|
||||
const INTERNAL_WEBRTC_ORIGIN = 'http://127.0.0.1:8889';
|
||||
const SIGNALING_TIMEOUT_MS = 60 * 60 * 1000;
|
||||
|
||||
function rewriteSessionLocation(location, internalOrigin = INTERNAL_WEBRTC_ORIGIN) {
|
||||
const value = String(location || '');
|
||||
if (!value) return value;
|
||||
|
||||
/*
|
||||
MediaMTX normally returns a root-relative WHEP/WHIP session URL. Browsers
|
||||
subsequently PATCH and DELETE that exact Location, so restore the public
|
||||
mount prefix that was removed before proxying the initial request.
|
||||
*/
|
||||
if (value.startsWith('/') && !value.startsWith(`${PUBLIC_MEDIA_PREFIX}/`)) {
|
||||
return `${PUBLIC_MEDIA_PREFIX}${value}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.origin === new URL(internalOrigin).origin) {
|
||||
return `${PUBLIC_MEDIA_PREFIX}${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
}
|
||||
} catch {
|
||||
// A path relative to the WHEP/WHIP endpoint already resolves beneath
|
||||
// /video in the browser and must not be converted into a root path.
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function createMediaMtxProxy({ target = INTERNAL_WEBRTC_ORIGIN, logger = console } = {}) {
|
||||
return createProxyMiddleware({
|
||||
target,
|
||||
changeOrigin: true,
|
||||
proxyTimeout: SIGNALING_TIMEOUT_MS,
|
||||
timeout: SIGNALING_TIMEOUT_MS,
|
||||
logger,
|
||||
on: {
|
||||
proxyRes(proxyResponse) {
|
||||
const location = proxyResponse.headers.location;
|
||||
if (location) proxyResponse.headers.location = rewriteSessionLocation(location, target);
|
||||
},
|
||||
error(error, request, response) {
|
||||
logger.warn?.('MediaMTX signaling proxy failed', {
|
||||
method: request.method,
|
||||
path: request.originalUrl || request.url,
|
||||
error: error.message,
|
||||
});
|
||||
if (response.headersSent) {
|
||||
response.destroy(error);
|
||||
return;
|
||||
}
|
||||
response.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
response.end('Media signaling is temporarily unavailable.');
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
INTERNAL_WEBRTC_ORIGIN,
|
||||
PUBLIC_MEDIA_PREFIX,
|
||||
createMediaMtxProxy,
|
||||
rewriteSessionLocation,
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
// MediaMTX HTTP Proxy Tests
|
||||
// Purpose: Verifies streaming WHEP/WHIP method, body, header, path, and session-location behavior.
|
||||
// Scope: Uses ephemeral loopback HTTP servers and always closes them; it never starts MediaMTX or the application server.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const { PUBLIC_MEDIA_PREFIX, createMediaMtxProxy, rewriteSessionLocation } = require('./proxy');
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => resolve(server.address().port));
|
||||
});
|
||||
}
|
||||
|
||||
function close(server) {
|
||||
return new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
|
||||
test('streams WHEP session requests and keeps follow-up locations beneath /video', async () => {
|
||||
const received = [];
|
||||
const mediaMtx = http.createServer((request, response) => {
|
||||
const chunks = [];
|
||||
request.on('data', (chunk) => chunks.push(chunk));
|
||||
request.on('end', () => {
|
||||
received.push({
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
authorization: request.headers.authorization,
|
||||
contentType: request.headers['content-type'],
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
});
|
||||
response.writeHead(201, {
|
||||
'Content-Type': 'application/sdp',
|
||||
Location: '/rover-one/whep/session-id',
|
||||
});
|
||||
response.end('answer');
|
||||
});
|
||||
});
|
||||
const mediaPort = await listen(mediaMtx);
|
||||
|
||||
const app = express();
|
||||
const silentLogger = { info() {}, warn() {}, error() {} };
|
||||
app.use(PUBLIC_MEDIA_PREFIX, createMediaMtxProxy({
|
||||
target: `http://127.0.0.1:${mediaPort}`,
|
||||
logger: silentLogger,
|
||||
}));
|
||||
// If the proxy accidentally falls through, this parser would consume the
|
||||
// request and make the integration failure explicit instead of timing out.
|
||||
app.use(express.json());
|
||||
const nodeServer = http.createServer(app);
|
||||
const nodePort = await listen(nodeServer);
|
||||
|
||||
try {
|
||||
const requests = [
|
||||
{ method: 'POST', path: '/video/rover-one/whep', type: 'application/sdp', body: 'v=0\r\no=offer' },
|
||||
{ method: 'PATCH', path: '/video/rover-one/whep/session-id', type: 'application/trickle-ice-sdpfrag', body: 'a=candidate' },
|
||||
{ method: 'DELETE', path: '/video/rover-one/whep/session-id', type: 'application/trickle-ice-sdpfrag', body: '' },
|
||||
];
|
||||
for (const request of requests) {
|
||||
const response = await fetch(`http://127.0.0.1:${nodePort}${request.path}`, {
|
||||
method: request.method,
|
||||
headers: { Authorization: 'Bearer session-token', 'Content-Type': request.type },
|
||||
body: request.method === 'DELETE' ? undefined : request.body,
|
||||
});
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(response.headers.get('location'), '/video/rover-one/whep/session-id');
|
||||
assert.equal(await response.text(), 'answer');
|
||||
}
|
||||
|
||||
assert.deepEqual(received, requests.map((request) => ({
|
||||
method: request.method,
|
||||
path: request.path.slice('/video'.length),
|
||||
authorization: 'Bearer session-token',
|
||||
contentType: request.type,
|
||||
body: request.body,
|
||||
})));
|
||||
} finally {
|
||||
await close(nodeServer);
|
||||
await close(mediaMtx);
|
||||
}
|
||||
});
|
||||
|
||||
test('rewrites only root-relative or internal absolute MediaMTX locations', () => {
|
||||
assert.equal(rewriteSessionLocation('/camera/whep/id'), '/video/camera/whep/id');
|
||||
assert.equal(rewriteSessionLocation('http://127.0.0.1:8889/camera/whep/id?one=two'), '/video/camera/whep/id?one=two');
|
||||
assert.equal(rewriteSessionLocation('whep/id'), 'whep/id');
|
||||
assert.equal(rewriteSessionLocation('https://example.com/camera/whep/id'), 'https://example.com/camera/whep/id');
|
||||
});
|
||||
@@ -37,7 +37,7 @@ test('passes the resolved data root to MediaMTX runOnReady hooks', () => {
|
||||
|
||||
try {
|
||||
const supervisor = createMediaMtxSupervisor({
|
||||
config: { media: { additionalHosts: ['media.example.test'] } },
|
||||
config: { publicUrl: 'https://public.example.test', media: { additionalHosts: ['media.example.test'] } },
|
||||
serverPort: 8080,
|
||||
logger,
|
||||
mediaMtxBin: '/test/bin/mediamtx',
|
||||
|
||||
@@ -11,7 +11,7 @@ function formatTimeInZone(date, timeZone) {
|
||||
}
|
||||
}
|
||||
|
||||
function createWebTransportHandlers({ rovers, roverManager, config, siteUrl = '' }) {
|
||||
function createWebTransportHandlers({ rovers, roverManager, config, publicUrl = '' }) {
|
||||
return {
|
||||
async status(message, roverId) {
|
||||
const resolved = roverId ? resolveRoverSelector(roverId, rovers) : null;
|
||||
@@ -59,7 +59,7 @@ function createWebTransportHandlers({ rovers, roverManager, config, siteUrl = ''
|
||||
if (!zones.some(([, zone]) => zone.toLowerCase() === String(serverTimezone).toLowerCase())) {
|
||||
lines.push(`Server Local — ${formatTimeInZone(now, serverTimezone)} **(server local timezone)**`);
|
||||
}
|
||||
const siteLink = siteUrl ? `\n\n${siteUrl}` : '';
|
||||
const siteLink = publicUrl ? `\n\n${publicUrl}` : '';
|
||||
return message.reply(`Time Status\n${lines.join('\n')}${siteLink}\n\nServer local timezone: ${serverTimezone}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,6 +3,20 @@
|
||||
// Scope: Contains configuration metadata only so the database can import it without initializing the session service.
|
||||
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
|
||||
|
||||
const publicUrl = {
|
||||
key: 'publicUrl',
|
||||
defaultValue: 'https://rover.example.com',
|
||||
schema: string({
|
||||
title: 'Public URL',
|
||||
description: 'Canonical public base URL used for links, peer identity, page metadata, and the public WebRTC hostname.',
|
||||
examples: ['https://rover.example.com'],
|
||||
format: 'uri',
|
||||
pattern: '^https?://',
|
||||
minLength: 1,
|
||||
maxLength: 2048,
|
||||
}),
|
||||
};
|
||||
|
||||
const timezone = {
|
||||
key: 'timezone',
|
||||
defaultValue: 'America/New_York',
|
||||
@@ -73,4 +87,4 @@ function getConfiguredSocials(config) {
|
||||
: [];
|
||||
}
|
||||
|
||||
module.exports = { timezone, socials, driverAd, getConfiguredSocials };
|
||||
module.exports = { publicUrl, timezone, socials, driverAd, getConfiguredSocials };
|
||||
|
||||
@@ -1,40 +1,21 @@
|
||||
// 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('../../configuration');
|
||||
// Scope: Handles native MediaMTX WHEP/WHIP paths and SRT streamid extraction without performing auth decisions.
|
||||
|
||||
const PTZ_STREAM_PATH = 'ptz-camera';
|
||||
|
||||
function getPathPrefix() {
|
||||
const base = loadConfig().media?.whepBaseUrl;
|
||||
if (!base) return '';
|
||||
try {
|
||||
const parsed = new URL(base);
|
||||
return parsed.pathname || '';
|
||||
} catch {
|
||||
return base.replace(/^[^/]*:\/\//, '').replace(/^[^/]+/, '');
|
||||
}
|
||||
}
|
||||
|
||||
function extractStreamInfo(path) {
|
||||
// The path prefix is tiny to derive and must follow media changes immediately;
|
||||
// retaining it at module load would make auth disagree with newly issued URLs.
|
||||
const whepPathPrefix = getPathPrefix().replace(/\/+$/, '').replace(/^\/+/, '');
|
||||
const whepPrefixSegments = whepPathPrefix ? whepPathPrefix.split('/').filter(Boolean) : [];
|
||||
// Node removes its public /video mount before proxying, so MediaMTX reports
|
||||
// only its native stream path to this authorization callback.
|
||||
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);
|
||||
const remaining = segments.slice(0, end);
|
||||
if (remaining.length === 1) {
|
||||
const rawId = remaining[0] || '';
|
||||
/*
|
||||
|
||||
@@ -9,31 +9,14 @@ const videoSessions = require('../videoSessions');
|
||||
const roverManager = require('../roverManager');
|
||||
const ptzCameraService = require('../ptzCameraService');
|
||||
const turnService = require('../turnService');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
||||
const { PUBLIC_MEDIA_PREFIX } = require('../mediaMtxService/proxy');
|
||||
const {
|
||||
shouldUseSnapshotsForNonTurnVideo,
|
||||
shouldUseSnapshotsForExternalSpectatorVideo,
|
||||
} = require('../../helpers/bandwidthSavings');
|
||||
|
||||
function getMediaPrefix() {
|
||||
const base = loadConfig().media?.whepBaseUrl;
|
||||
if (!base) {
|
||||
return '';
|
||||
}
|
||||
let prefix = base;
|
||||
try {
|
||||
const parsed = new URL(base);
|
||||
prefix = `${parsed.origin}${parsed.pathname}`;
|
||||
} catch (err) {
|
||||
// leave prefix as-is when URL parsing fails; fall back to string cleanup below
|
||||
}
|
||||
return prefix.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function buildWhepUrlForSource(source) {
|
||||
const cleanBase = getMediaPrefix();
|
||||
if (!cleanBase) return '';
|
||||
const segments = [];
|
||||
if (source.type === 'room') {
|
||||
segments.push('room', encodeURIComponent(source.id));
|
||||
@@ -49,7 +32,9 @@ function buildWhepUrlForSource(source) {
|
||||
} else {
|
||||
segments.push(encodeURIComponent(source.id));
|
||||
}
|
||||
return `${cleanBase}/${segments.join('/')}/whep`;
|
||||
// A same-origin path works through TLS proxies, LAN access, and future
|
||||
// containers without exposing MediaMTX's internal listener to the browser.
|
||||
return `${PUBLIC_MEDIA_PREFIX}/${segments.join('/')}/whep`;
|
||||
}
|
||||
|
||||
function passesMode(socket) {
|
||||
@@ -173,9 +158,6 @@ io.on('connection', (socket) => {
|
||||
throw new Error('Unsupported video source');
|
||||
}
|
||||
const url = buildWhepUrlForSource(target);
|
||||
if (!url) {
|
||||
throw new Error('Server video base URL missing');
|
||||
}
|
||||
const sessionId = videoSessions.createSession(socket, target);
|
||||
cb({ url, token: sessionId, type: target.type, id: target.id });
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user